diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 37d2962..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "permissions": {
- "allow": [
- "mcp__obsmcp__health_check",
- "mcp__obsmcp__get_project_status_snapshot",
- "mcp__obsmcp__get_current_task",
- "mcp__obsmcp__get_latest_handoff",
- "mcp__obsmcp__get_blockers",
- "mcp__obsmcp__get_recent_work",
- "mcp__obsmcp__get_decisions",
- "mcp__obsmcp__session_open",
- "mcp__obsmcp__get_active_tasks",
- "mcp__obsmcp__get_project_brief",
- "mcp__obsmcp__search_notes",
- "mcp__obsmcp__get_audit_log",
- "mcp__obsmcp__list_projects",
- "mcp__obsmcp__web_search",
- "mcp__obsmcp__get_token_usage_stats",
- "mcp__obsmcp__resolve_active_project"
- ]
- }
-}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e48bddd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.github
+.venv
+frontend/node_modules
+frontend/dist
+server/obsmcp_server/frontend_dist
+**/__pycache__
+**/*.pyc
+.obsmcp
+*.db
+.env
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e98d34f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# OBSMCP server configuration
+# Leave OBSMCP_API_TOKEN blank to disable auth (local mode).
+OBSMCP_API_TOKEN=
+OBSMCP_DB_PATH=~/.obsmcp/data/obsmcp.db
+OBSMCP_HOST=0.0.0.0
+OBSMCP_PORT=8000
+
+# Optional: Anthropic API key for semantic descriptions
+ANTHROPIC_API_KEY=
diff --git a/.gitignore b/.gitignore
index 3f1657b..619313b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,49 @@
-.venv/
+# Python
__pycache__/
-*.pyc
-*.pyo
+*.py[cod]
+*.egg-info/
+.eggs/
+.venv/
+venv/
+env/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+.coverage
+htmlcov/
+dist/
+build/
+*.egg
+
+# Node / frontend
+node_modules/
+frontend/dist/
+server/obsmcp_server/frontend_dist/
+.npm/
+.pnpm-store/
+.vite/
+
+# OBSMCP local data / configs (never commit!)
+.obsmcp/
+*.db
+*.db-journal
+*.sqlite
*.sqlite3
-*.sqlite3-shm
-*.sqlite3-wal
+obsmcp.db*
+
+# IDE / OS
+.vscode/
+.idea/
+.DS_Store
+Thumbs.db
+*.swp
+*.swo
+
+# Env / secrets
+.env
+.env.*
+!.env.example
+
+# Logs
*.log
-*.zip
-/backups/
-/.context/
-/.pytest_cache/
-/.tmp-tests/
-/.tmp-caveman/
-/data/
-/hub/
-/logs/
-/.obsmcp-link.json
-/obsidian/
-/projects/
-/registry/
-/workspace/
+logs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..c78db0c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,14 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-added-large-files
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.5.6
+ hooks:
+ - id: ruff
+ args: [--fix]
+ - id: ruff-format
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index b9c9f40..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# AGENTS.md
-
-You are operating in a shared multi-model continuity system backed by `obsmcp`.
-
-Before doing substantive work, read in this order:
-
-1. `.context/PROJECT_CONTEXT.md`
-2. `.context/CURRENT_TASK.json`
-3. `.context/HANDOFF.md`
-4. `.context/DECISIONS.md`
-5. `.context/BLOCKERS.json`
-6. `.context/RELEVANT_FILES.json`
-7. `.context/SESSION_SUMMARY.md`
-8. Check if a **Code Atlas** exists for this project. Call `get_code_atlas_status()` via MCP or run `ctx.bat atlas status` from the shell. If the atlas does not exist, call `scan_codebase()`. For large or first-time scans, `scan_codebase()` may return a background job instead of a finished atlas; poll `get_scan_job()` or `wait_for_scan_job()` until it completes. The Code Atlas documents every file, function, class, feature, and cross-reference in the project — it gives you a complete structural understanding without reading every source file.
-9. For quick orientation: call `get_audit_log(limit=10)` to see recent activity.
-10. For sprint planning: use `bulk_task_ops` to batch-create or batch-update tasks.
-11. For token-efficient context: use `generate_compact_context_v2(max_tokens=3000)` — includes decision chains, dependency map, session info, and recommended semantic lookups.
-12. For low-latency startup or resumed work, use the cached tiered context and delta tools first:
- - `generate_context_profile(profile="fast"|"balanced"|"deep"|"handoff"|"recovery")`
- - `generate_delta_context(...)`
-13. When you need targeted understanding instead of rereading large files, use semantic tools first:
- - `describe_module(module_path)`
- - `describe_symbol(symbol_name, module_path?)`
- - `describe_feature(feature_name)`
- - `search_code_knowledge(query)`
- - `get_symbol_candidates(symbol_name)` if a symbol name is ambiguous
- - `get_related_symbols(entity_key)` to expand from one symbol to its neighbors
-14. For dependency overview: use `get_blocked_tasks()` and `validate_dependencies()` to check task readiness.
-
-Rules:
-
-- Do not assume you are the first or only agent.
-- Continue the current task instead of restarting discovery.
-- Preserve continuity notes, blockers, and decisions.
-- When you finish a meaningful chunk, log work to `obsmcp`.
-- Before you stop, create a handoff for the next model or tool.
-
-Preferred write paths:
-
-- MCP tools if available
-- `ctx.bat` if MCP is not available
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index bd5d3d2..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# CLAUDE.md
-
-This workspace uses `obsmcp` as the shared continuity layer.
-
-Start here:
-
-1. Read `.context/PROJECT_CONTEXT.md`
-2. Read `.context/CURRENT_TASK.json`
-3. Read `.context/HANDOFF.md`
-4. Read `.context/DECISIONS.md`
-5. Read `.context/BLOCKERS.json`
-
-Operating rules:
-
-- Continue the existing project state instead of re-deriving it.
-- Treat `.context` as the minimum required continuity package.
-- Use `ctx.bat` to log work, create handoffs, and sync files when direct MCP access is missing.
-- Record decisions and blockers explicitly.
-- Leave a new handoff before ending your turn.
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b9777b4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,52 @@
+# Contributing
+
+Thanks for helping improve OBSMCP!
+
+## Local setup
+
+```bash
+python -m pip install -e ".[dev]"
+pre-commit install
+
+cd frontend && npm install
+```
+
+## Running the stack locally
+
+```bash
+# Terminal 1 — backend
+OBSMCP_API_TOKEN=dev obsmcp-server
+
+# Terminal 2 — frontend
+cd frontend
+npm run dev # http://localhost:5173 (proxies /api and /ws to 8000)
+
+# Terminal 3 — local agent (writes to the running backend)
+./start.sh # first run prompts for config
+```
+
+## Code style
+
+- **Python:** formatted + linted by `ruff` (see `pyproject.toml`). 100-char lines.
+- **TypeScript:** strict mode on; lint with `npm run lint`.
+- **No ORMs.** Stick to raw `sqlite3`.
+- **Every mutation must emit an SSE event** via `broadcast_event(...)`. See existing routers.
+
+## Adding a new entity
+
+1. Extend `server/obsmcp_server/schema.sql`.
+2. Add a router under `server/obsmcp_server/routers/`, hook it up in `main.py`, and emit events.
+3. Add a TypeScript type in `frontend/src/api/types.ts`.
+4. Map event types → query keys in `frontend/src/events/EventBus.ts`.
+5. Add a page under `frontend/src/pages/`, wire it into `App.tsx` and the sidebar nav.
+6. Mirror the write path in `tool/obsmcp/client/http_client.py` so agents can use it offline-first.
+7. Add tests in `server/tests/` and `tool/tests/`.
+
+## Tests
+
+```bash
+pytest -q # backend + tool
+cd frontend && npm run typecheck && npm run build
+```
+
+Run all three locally before opening a PR — this project intentionally has no CI/GitHub Actions.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4f4821e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,35 @@
+# syntax=docker/dockerfile:1.6
+
+# ---------- frontend build ----------
+FROM node:20-alpine AS frontend
+WORKDIR /app/frontend
+COPY frontend/package.json frontend/package-lock.json* ./
+RUN npm install --no-audit --no-fund
+COPY frontend .
+RUN npm run build
+
+# ---------- backend image ----------
+FROM python:3.12-slim AS backend
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential git curl && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY pyproject.toml README.md ./
+COPY tool tool
+COPY server server
+# Bring the built frontend into the package's static dir
+COPY --from=frontend /app/server/obsmcp_server/frontend_dist server/obsmcp_server/frontend_dist
+
+RUN pip install --upgrade pip && pip install .
+
+ENV OBSMCP_HOST=0.0.0.0 \
+ OBSMCP_PORT=8000
+
+EXPOSE 8000
+CMD ["obsmcp-server"]
diff --git a/LICENSE b/LICENSE
index 0ae1787..245b239 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 nikzdevz
+Copyright (c) 2026 OBSMCP Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index b17ed87..caf55cf 100644
--- a/README.md
+++ b/README.md
@@ -1,777 +1,166 @@
-# obsmcp
-
-
- OBS MCP / Obsidian MCP for serious project continuity, multi-session AI work, and developer-grade context engineering.
-
+# OBSMCP — Observable Machine Code Protocol
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
+OBSMCP is a three-tier observability system for autonomous coding agents:
-`obsmcp` stands for `Obsidian MCP`.
+1. **Local MCP tool** — runs on the developer's machine, watches the project, streams tasks/sessions/blockers/decisions/work logs/metrics/knowledge-graph edges into a local SQLite database and optionally a remote backend.
+2. **FastAPI backend** — raw-SQL SQLite store with full CRUD for every entity, Server-Sent Events bus for real-time mutations, WebSocket mirror, Bearer-token auth.
+3. **React dashboard** — TanStack-Query-driven SPA with 10 pages (Dashboard, Tasks, Sessions, Blockers, Decisions, Work Logs, Code Atlas, Knowledge Graph, Performance Logs, Settings), live-invalidated from the SSE event bus.
-It is a local-first MCP server and continuity control plane that helps AI coding tools keep working memory between sessions, models, IDEs, and interruptions without turning your chat history into the only source of truth.
-
-Instead of relying on one long conversation, `obsmcp` stores:
-
-- project state in SQLite
-- compact, prompt-friendly continuity files in `.context`
-- human-readable notes and handoffs in Obsidian vaults
-- auditable session history, task history, and model-to-model handoffs
-- code-aware semantic knowledge through a Code Atlas and semantic lookup layer
-
-If you are building with Codex, Claude Code, Cursor, Warp, VS Code MCP clients, or your own internal tooling, `obsmcp` is designed to be the shared memory and project-management layer those tools can all use together.
-
-## At A Glance
-
-| For developers who need... | obsmcp gives you... |
-| --- | --- |
-| reliable restart after interrupted AI work | session tracking, resume packets, startup preflight, stale-session detection |
-| less prompt replay | fast/balanced/deep/delta/retrieval context surfaces |
-| better project memory | tasks, blockers, decisions, handoffs, relevant files, Obsidian notes |
-| code-aware context | Code Atlas, semantic search, symbol descriptions, related symbol lookup |
-| cost discipline | token-aware context assembly, output policy controls, output compaction, token usage stats |
-| multi-client workflows | one project workspace for Codex, Claude Code, Cursor, Warp, and custom MCP clients |
-
-## Table Of Contents
-
-- [What Is OBS MCP?](#what-is-obs-mcp)
-- [Why It Exists](#why-it-exists)
-- [What Makes It Powerful](#what-makes-it-powerful)
-- [Architecture At A Glance](#architecture-at-a-glance)
-- [Feature Inventory](#feature-inventory)
-- [How Token Saving Works](#how-token-saving-works)
-- [Installation](#installation)
-- [Quick Start](#quick-start)
-- [MCP Tool Catalog](#mcp-tool-catalog)
-- [Comparison With Other MCP Servers](#comparison-with-other-mcp-servers)
-- [Where obsmcp Wins](#where-obsmcp-wins)
-- [Where obsmcp Is Weaker](#where-obsmcp-is-weaker)
-- [Next Recommended Commit](#next-recommended-commit)
-- [Documentation Index](#documentation-index)
-
-## What Is OBS MCP?
-
-`obsmcp` is not just another MCP tool server.
-
-It is a project operations layer for AI-assisted development:
-
-- it knows what project is active
-- it knows what task is current
-- it knows what was done recently
-- it knows what is blocked
-- it knows what should happen next
-- it knows which files matter
-- it knows when a session is stale or abandoned
-- it can produce compact startup context instead of replaying long history
-
-You can think of it as a hybrid of:
-
-- a continuity server
-- a local project memory system
-- a task and handoff tracker
-- a prompt-context engineering layer
-- a semantic code knowledge service
-- an MCP gateway for selected external tools
-
-## Why It Exists
-
-Most MCP servers are excellent at one narrow job:
-
-- file access
-- browser automation
-- GitHub automation
-- memory search
-- code execution
-
-Those are useful, but they do not solve the bigger operational problem:
-
-> When an AI model stops, switches, crashes, resumes, or hands off work, how does the next model continue the project cleanly?
-
-That is the problem `obsmcp` is built to solve.
-
-It gives you:
-
-- project-scoped memory instead of chat-scoped memory
-- structured tasks, blockers, and decisions instead of loose notes
-- safe startup checks before a model resumes the wrong thing
-- resumable sessions with labels and workstreams
-- token-aware context surfaces for fast restart
-- human-readable notes for debugging and handoff review
-
-## What Makes It Powerful
-
-### 1. Continuity is attached to the project, not to one chat
-
-`obsmcp` keeps state in a centralized workspace per project, so the next model or IDE can continue from the actual project state.
-
-### 2. It supports real project management
-
-It tracks:
-
-- tasks
-- current task
-- blockers
-- decisions
-- work logs
-- handoffs
-- sessions
-- dependencies
-- recovery state
-
-### 3. It is built for multi-session, multi-model work
-
-With session labels, workstreams, preflight checks, resume boards, and mismatch guards, `obsmcp` is designed for interrupted and branchy work instead of one perfect uninterrupted run.
-
-### 4. It is optimized for token efficiency
-
-It does not only store memory. It helps shape what the next model sees:
-
-- fast context
-- balanced context
-- deep context
-- handoff context
-- recovery context
-- delta context
-- prompt segments
-- retrieval context
-- raw-output compaction
-- output-response policy controls
-
-### 5. It is code-aware
-
-The Code Atlas + semantic layer means tools can ask:
-
-- what this module does
-- what this function does
-- what features exist
-- which symbols are related
-- what changed since the last handoff
-
-That is very different from a plain memory bank.
-
-## Architecture At A Glance
-
-```text
-Developer / IDE / AI Client
- |
- v
- MCP / CLI / File Reads
- |
- v
- obsmcp
- |
- +--> SQLite project state
- +--> .context continuity files
- +--> per-project Obsidian vault
- +--> session folders and handoffs
- +--> semantic code atlas
- +--> optional provider-backed tools
```
-
-### Core layers
-
-| Layer | Purpose |
-| --- | --- |
-| SQLite | System of record for tasks, sessions, blockers, decisions, logs, handoffs, metrics |
-| `.context` | Universal fallback surface for tools that cannot call MCP directly |
-| Obsidian vault | Human-readable operational memory and project notes |
-| Session folders | Durable artifacts like metadata, heartbeat history, worklog, and handoff files |
-| Code Atlas | File/function/class/feature understanding across the repository |
-| MCP server | Structured tool access over HTTP |
-| `ctx.bat` CLI | Shell fallback when MCP integration is unavailable or inconvenient |
-
-### Workspace model
-
-Each project gets its own centralized workspace under:
-
-```text
-projects//
+┌────────────────────────┐ local SQLite (always)
+│ Local MCP Tool (you) │──┬────────────────┐
+│ • monitors • scanners│ │ ↕ cloud sync (optional)
+└────────┬───────────────┘ │ │
+ │ stdio │ ▼
+ ▼ │ ┌──────────────────┐
+ Claude / Cursor / CLI │ │ FastAPI backend │ ←→ React SPA
+ │ │ SSE / WS bus │
+ └─────►│ SQLite store │
+ └──────────────────┘
```
-With subdirectories such as:
-
-- `data/db/`
-- `.context/`
-- `vault/`
-- `sessions/`
-- `logs/`
-
-## Feature Inventory
-
-### Project continuity
-
-- centralized project workspace per repo
-- project registration and routing
-- repo bridge attachment for path inference
-- current-task tracking
-- relevant-file tracking
-- model-to-model handoffs
-- daily note stream
-- audit trail
-
-### Session management
-
-- auditable session open / heartbeat / close lifecycle
-- session labels for human-readable tracking
-- stable workstream keys for related sessions
-- startup preflight warnings
-- startup resume board
-- session mismatch guard for unsafe auto-resume
-- stale-session and abandoned-session detection
-- emergency recovery handoffs
-- session lineage
-
-### Context engineering
-
-- compact context
-- token-budget-aware compact context v2
-- tiered profiles: `fast`, `balanced`, `deep`, `handoff`, `recovery`
-- delta context since handoff/session/timestamp
-- retrieval context
-- startup context
-- prompt segments for cache-friendly assembly
-- progressive chunked context loading
-
-### Token and output optimization
-
-- token usage metrics
-- raw tool-output capture
-- noisy command-output compaction
-- output-response policy
-- operation-aware optimization policy
-- fast-path deterministic responses
-
-### Code understanding
-
-- full codebase scan / Code Atlas
-- semantic module descriptions
-- semantic symbol descriptions
-- feature descriptions
-- related-symbol expansion
-- semantic search
-- background scan jobs
-
-### Developer operations
-
-- command-event recording and replay
-- command risk classification
-- task templates
-- bulk task operations
-- dependency management
-- log retention / expiry
-- project export
-
-### External tool gateway
+## Operating modes
-- web search
-- image understanding
+- **Standalone** (default) — everything lives in `~/.obsmcp/data/obsmcp.db`. The dashboard is served at by the bundled FastAPI server.
+- **Cloud sync** — same local DB + every write is mirrored to a remote backend. The agent stays fully functional offline; writes flush to the server automatically when connectivity returns.
-## How Token Saving Works
+## Quickstart
-This is one of the biggest differences between `obsmcp` and many memory-oriented MCP servers.
+### 1. Install the Python tool
-`obsmcp` tries to save tokens at multiple levels:
-
-### Input-token savings
-
-- use `generate_fast_context` or `generate_context_profile("fast")` for minimal startup context
-- use `generate_delta_context` to send only what changed instead of replaying full history
-- use `generate_retrieval_context` for targeted context instead of large note dumps
-- use semantic lookups instead of rereading giant files
-- use prompt segments for cache-friendly context assembly
-
-### Output-token savings
-
-- `compact_tool_output`
-- `compact_response`
-- `get_output_response_policy`
-- `generate_startup_prompt_template`
-- gateway-enforced response style on the surfaces `obsmcp` actually controls
-
-### What `obsmcp` does better than basic memory servers
-
-Many memory servers reduce repeated context by storing facts. `obsmcp` does that kind of work too, but it also helps decide:
-
-- what to show now
-- how much to show
-- which context tier to use
-- how to avoid replaying unchanged state
-- how to compress verbose tool output safely
-
-### What it does not claim
-
-`obsmcp` does not magically reduce every token in every client. Output savings only happen on the surfaces it controls directly. If a client ignores the optimized context or bypasses its response policies, those savings can be reduced.
-
-## Installation
-
-## Prerequisites
-
-- Windows
-- Python `3.11+`
-- PowerShell or Command Prompt
-- Obsidian installed locally if you want live vault-based workflows
-
-## Recommended install path
-
-```text
-C:\obsmcp
+```bash
+python -m pip install -e ".[dev]" # clones & editable install
+# Optional: LLM-powered semantic descriptions
+python -m pip install -e ".[llm]"
```
-This keeps the batch scripts and Task Scheduler paths simple.
+### 2. First-run setup
-## Install
+Windows:
-```bat
-git clone https://github.com//obsmcp.git C:\obsmcp
-cd /d C:\obsmcp
-bootstrap_obsmcp.bat
```
-
-What `bootstrap_obsmcp.bat` does:
-
-- creates `.venv`
-- upgrades `pip`
-- installs Python dependencies from `requirements.txt`
-
-## Start the server
-
-```bat
-start_obsmcp.bat
+start.bat
```
-The server starts locally on:
+macOS / Linux:
-```text
-http://127.0.0.1:9300
+```
+./start.sh
```
-## Stop the server
+You'll be prompted for:
-```bat
-stop_obsmcp.bat
-```
+- **Project path** (required) — path to the codebase you want to observe.
+- **Backend URL** (optional) — leave blank to stay standalone.
+- **API token** (optional) — required if the backend has `OBSMCP_API_TOKEN` set.
-## Verify health
+Config lands at `~/.obsmcp/config.json` (`%USERPROFILE%\.obsmcp\config.json` on Windows).
-```bat
-curl http://127.0.0.1:9300/healthz
-netstat -ano | findstr :9300
-ctx.bat project list
-```
+### 3. Run
-## Optional local API token
+`start.bat` / `start.sh` launches `python -m obsmcp`, which spins up:
-```bat
-set OBSMCP_API_TOKEN=your-local-token
-```
+- Session monitor + heartbeat
+- File watcher (via `watchfiles`, optional)
+- Git commit/branch monitor
+- Performance monitor (CPU / memory / disk via `psutil`)
+- Code Atlas scanner (regex-based multi-language metadata)
+- Knowledge graph node extractor + edge builder
+- (Standalone only) Local FastAPI dashboard at
+
+### 4. Plug into an MCP client
-## MCP client configuration
+Add a stdio server entry to Claude Desktop / Cursor / Claude Code:
-```json
+```jsonc
{
"mcpServers": {
"obsmcp": {
- "transport": "http",
- "url": "http://127.0.0.1:9300/mcp"
+ "command": "python",
+ "args": ["-m", "obsmcp", "--mcp-stdio"]
}
}
}
```
-## Quick Start
-
-### 1. Register a project
-
-```bat
-ctx.bat project register --repo D:\Work\MyApp --name "My App"
-```
+Tool names exposed: `get_tasks`, `create_task`, `update_task`, `delete_task`, `log_blocker`, `resolve_blocker`, `log_decision`, `log_work`, `start_session`, `end_session`, `scan_codebase`, `get_scan_status`, `add_node`, `add_edge`, `query_graph`, `get_performance_summary`, `sync_state`.
-### 2. Create a task
+## Cloud deployment
-```bat
-ctx.bat --project D:\Work\MyApp task create "Bootstrap obsmcp" --description "Initialize continuity for this repo"
+```bash
+# Build everything (frontend → /server/obsmcp_server/frontend_dist, backend image)
+docker compose up --build
```
-### 3. Mark it current
+Environment variables:
-```bat
-ctx.bat --project D:\Work\MyApp start TASK-REPLACE-ME
-```
+| Variable | Default | Notes |
+|---------------------|------------------------------|------------------------------------------------|
+| `OBSMCP_API_TOKEN` | (unset → no auth) | Required for cloud mode |
+| `OBSMCP_DB_PATH` | `~/.obsmcp/data/obsmcp.db` | In Docker: `/data/obsmcp.db` (volume-mounted) |
+| `OBSMCP_HOST` | `0.0.0.0` | |
+| `OBSMCP_PORT` | `8000` | |
+| `ANTHROPIC_API_KEY` | (unset) | Enables LLM semantic descriptions (opt-in) |
-### 4. Run startup safety checks
+## API surface
-```bat
-ctx.bat --project D:\Work\MyApp preflight --actor codex --initial-request "Continue implementation" --goal "Complete the feature safely"
-ctx.bat --project D:\Work\MyApp resume-board
-```
+Bearer-token auth on everything under `/api/*` when `OBSMCP_API_TOKEN` is set. See [`server/obsmcp_server/routers/`](server/obsmcp_server/routers/) for the exhaustive list. Highlights:
-### 5. Open a named session
-
-```bat
-ctx.bat session open ^
- --actor codex ^
- --client vscode-codex ^
- --model gpt-5 ^
- --project-path D:\Work\MyApp ^
- --task TASK-REPLACE-ME ^
- --label "Managing Director Email" ^
- --workstream managing-director-email ^
- --initial-request "This task is for the managing director's email." ^
- --goal "Draft and finalize the email"
-```
+- `GET /api/stats` — counts for every entity (powers the dashboard cards).
+- `GET /api/events` — SSE stream of every mutation.
+- `WS /ws/dashboard` — WebSocket mirror of the same bus.
+- `GET /healthz`, `/readyz`, `/runtime-discovery`, `/mode` — public.
-### 6. Log work as you go
+All mutations emit a typed SSE event (`task_created`, `blocker_resolved`, `scan_completed`, …). The React client maps event → TanStack Query key and invalidates automatically; no polling.
-```bat
-ctx.bat --project D:\Work\MyApp log "Drafted the first version" --task TASK-REPLACE-ME --files README.md
-```
+## Development
-### 7. Close with a handoff
+```bash
+# Backend
+pytest # unit tests (FastAPI + TestClient against tmp SQLite)
+ruff check . # lint
+mypy # types (best-effort)
-```bat
-ctx.bat handoff --summary "Draft is complete" --next-steps "Review tone and finalize" --to "next-agent"
-ctx.bat session close SESSION-REPLACE-ME --actor codex --summary "Closed cleanly with handoff."
+# Frontend
+cd frontend
+npm install
+npm run dev # http://localhost:5173 (proxies /api to :8000)
+npm run build # emits to server/obsmcp_server/frontend_dist
+npm run typecheck
```
-## MCP Tool Catalog
-
-`obsmcp` currently exposes `117` MCP tools.
-
-This is a deliberately broad surface because `obsmcp` is not only a memory tool. It is a continuity, context, code-understanding, and workflow-management server.
-
-
-Project & Workspace
-
-- `register_project`: Register a repo with obsmcp and create its centralized workspace.
-- `list_projects`: List registered obsmcp projects.
-- `resolve_project`: Resolve a project by slug or repo path.
-- `resolve_active_project`: Resolve the active project from IDE metadata such as cwd, active file, workspace folders, open files, session_id, task_id, repo_path, or environment hints. Use this before the first continuity write from a plugin or IDE client.
-- `get_project_workspace_paths`: Return the workspace paths for a project.
-- `attach_repo_bridge`: Write a lightweight bridge file into the repo that points at the centralized obsmcp workspace.
-- `migrate_project_layout`: Copy legacy repo-local `.context` and `obsidian/vault` content into the centralized project workspace and attach a repo bridge.
-- `sync_hub`: Refresh the central obsmcp hub vault from the registry.
-- `health_check`: Return health information about obsmcp.
-- `get_server_capabilities`: Return server API/schema versions and supported workflow-safety capabilities.
-- `check_client_compatibility`: Compare client API/tool-schema expectations with the current server.
-- `list_tools`: Return the obsmcp tool catalog.
-- `list_resources`: Return the obsmcp resource catalog.
-- `export_project`: Export full project state as JSON (gzipped) and/or Markdown bundle. Creates a timestamped export in `data/exports/`.
-- `get_or_create_project`: Auto-detect or create a project from a path hint, session, task, or environment. Resolves from multiple sources and optionally registers if not known. Returns project type metadata, workspace type, and nearby projects.
-
-
-
-
-Project Memory & Notes
-
-- `get_project_brief`: Return the current project brief sections.
-- `get_current_task`: Return the current task.
-- `get_active_tasks`: Return open, in-progress, and blocked tasks.
-- `get_latest_handoff`: Return the latest handoff.
-- `get_recent_work`: Return recent work logs with cursor-style `limit` and `after_id` parameters.
-- `get_decisions`: Return recent decisions with cursor-style `limit` and `after_id` parameters.
-- `get_blockers`: Return open blockers with cursor-based pagination.
-- `get_relevant_files`: Return relevant file paths for a task or the current task.
-- `get_table_schema`: Return the SQLite schema for a given table.
-- `search_notes`: Search the Obsidian vault for notes.
-- `read_note`: Read a note from the Obsidian vault.
-- `get_project_status_snapshot`: Return a compact project status snapshot.
-
-
-
-
-Tasks, Decisions & Daily Ops
-
-- `log_work`: Append a work log entry.
-- `log_checkpoint`: Record a completed checkpoint or subtask for a task.
-- `update_task`: Update an existing task.
-- `create_task`: Create a task.
-- `get_task_progress`: Return checkpoint progress and recent checkpoints for a task.
-- `log_decision`: Record an ADR-style decision.
-- `log_blocker`: Record a blocker.
-- `resolve_blocker`: Resolve an open blocker.
-- `create_handoff`: Create a model-to-model or user-to-model handoff.
-- `append_handoff_note`: Append an additional note to an existing handoff.
-- `update_project_brief_section`: Update a named project brief section.
-- `create_daily_note_entry`: Append an entry to the daily note stream.
-- `set_current_task`: Set the current active task.
-- `get_task_templates`: List all available task templates.
-- `get_task_template`: Get a specific task template by name.
-- `create_task_template`: Create a new task template.
-- `delete_task_template`: Delete a task template by name.
-- `create_task_from_template`: Create a task from a named template, filling in template variables.
-- `quick_log`: One-liner work log that auto-tags the current task. No `task_id` required.
-- `get_audit_log`: Full project-wide activity timeline with cursor-based pagination.
-- `reset_project`: Wipe project data by scope with audit tracking.
-- `bulk_task_ops`: Execute multiple task operations atomically.
-
-
-
-
-Sessions, Startup & Recovery
-
-- `session_open`: Open an auditable AI session with heartbeat and write-back policy.
-- `session_heartbeat`: Record a session heartbeat and optionally emit a heartbeat work log.
-- `session_close`: Close a session with summary and optional handoff creation.
-- `get_active_sessions`: List open tracked sessions with cursor-based pagination.
-- `detect_missing_writeback`: Audit sessions for missing write-back, missing handoffs, or overdue heartbeats.
-- `get_startup_preflight`: Run startup safety checks before opening or resuming a session.
-- `get_resume_board`: Return a startup dashboard of open tasks, paused tasks, stale sessions, latest handoffs, and the recommended resume target.
-- `generate_resume_packet`: Generate a compact resume packet for the next tool or model and write it to the project workspace.
-- `generate_emergency_handoff`: Generate a best-effort handoff from persisted state when a session ended abruptly.
-- `recover_session`: Recover an interrupted session by generating an emergency handoff and resume packet.
-- `session_replay`: Reconstruct the timeline of events within a session.
-- `generate_cross_tool_handoff`: Generate a structured JSON handoff payload for another tool or IDE.
-- `get_session_lineage_chain`: Traverse parent/child session lineage.
-- `set_session_environment`: Attach IDE/environment metadata to an active session.
-
-
-
-
-Context Engineering & Token Efficiency
-
-- `sync_context_files`: Force a sync of generated context and Obsidian files.
-- `generate_compact_context`: Generate compact context for manual prompt injection.
-- `generate_compact_context_v2`: Token-budget-aware compact context with decision chains, dependency map, session info, and smart truncation.
-- `generate_context_profile`: Generate a cached tiered context profile such as `fast`, `balanced`, `deep`, `handoff`, or `recovery`.
-- `generate_delta_context`: Generate a compact delta view showing what changed since a handoff, session, or timestamp.
-- `generate_prompt_segments`: Generate stable and dynamic prompt segments for cache-friendly context assembly.
-- `generate_retrieval_context`: Generate retrieval-first context with ranked files, recent work, decisions, blockers, and semantic hits for a query.
-- `generate_task_snapshot`: Generate a detailed snapshot for a task.
-- `record_token_usage`: Record provider or local token usage metrics, including prompt cache fields and compaction savings.
-- `get_token_usage_stats`: Return recent token, compaction, and prompt-cache usage aggregates for the project.
-- `get_output_response_policy`: Resolve the effective output-token policy for the current task/operation.
-- `compact_tool_output`: Compact noisy tool output and optionally save full raw output for debugging.
-- `compact_response`: Compress verbose text output while preserving code blocks, URLs, file paths, and errors.
-- `get_raw_output_capture`: Retrieve metadata or full content for a saved raw output capture.
-- `get_fast_path_response`: Return a deterministic no-LLM fast-path response for common startup and status needs.
-- `get_optimization_policy`: Return the active adaptive optimization policy for a mode, task, command, and exit state.
-- `list_context_chunks`: List prioritized chunk metadata for a context artifact.
-- `generate_progressive_context`: Render one or more prioritized chunks from a context artifact.
-- `generate_startup_context`: Generate a delta-first startup context with fast baseline, recent command history, and execution hints.
-- `generate_startup_prompt_template`: Return the first-contact startup prompt template for tools and agents.
-- `generate_fast_context`: Generate a guaranteed-fast L0-only context for startup/resume use cases.
-- `retrieve_context_chunk`: Retrieve a specific chunk of a context artifact for large profile navigation.
-
-
-
-
-Command Intelligence
-
-- `record_command_event`: Record a terminal command outcome with compact summaries and optional raw output capture.
-- `record_command_batch`: Record a batch of command outcomes and return an aggregate summary with risk counts.
-- `get_command_event`: Retrieve a recorded command event by ID.
-- `get_recent_commands`: List recent recorded command events with cursor-based pagination.
-- `get_last_command_result`: Return the most recent recorded command event for a session or task.
-- `get_command_failures`: List recent failing command events for a session or task.
-- `get_command_execution_policy`: Classify a command for batching and review risk.
-
-
-
-
-Code Atlas & Semantic Knowledge
-
-- `scan_codebase`: Scan the project directory and generate a Code Atlas documenting every file, function, class, and feature.
-- `get_code_atlas_status`: Return current atlas status without regenerating it.
-- `start_scan_job`: Queue a background Code Atlas scan job.
-- `get_scan_job`: Get the current status and result payload for a background scan job.
-- `list_scan_jobs`: List recent background scan jobs for the project.
-- `wait_for_scan_job`: Poll a background scan job until it completes or times out.
-- `describe_module`: Return a cached or freshly generated semantic description for a module/file.
-- `describe_symbol`: Return a semantic description for a function or class.
-- `describe_feature`: Return a semantic description for a feature tag from the Code Atlas.
-- `search_code_knowledge`: Search semantic knowledge and symbol index entries.
-- `get_symbol_candidates`: Return matching function/class symbol candidates for a name.
-- `get_related_symbols`: Return nearby or feature-related symbols for a semantic entity.
-- `invalidate_semantic_cache`: Mark semantic description cache entries stale by entity or file.
-- `refresh_semantic_description`: Force a fresh semantic description generation for an entity lookup.
-
-
-
-
-Dependencies & Retention
-
-- `configure_log_expiry`: Set the work log retention period in days.
-- `expire_old_logs`: Purge work logs older than the configured retention period.
-- `get_log_stats`: Return work log statistics and current expiry settings.
-- `add_task_dependency`: Link a task as blocked by other tasks and/or blocking other tasks.
-- `remove_task_dependency`: Remove task dependencies.
-- `get_task_dependency`: Get dependencies for a specific task.
-- `get_all_dependencies`: Get all task dependencies across the project.
-- `get_blocked_tasks`: Return tasks currently blocked by unresolved dependencies.
-- `validate_dependencies`: Validate all task dependencies.
-
-
-
-
-External / Provider Tools
-
-- `web_search`: Run a web search through obsmcp using the configured provider.
-- `understand_image`: Analyze an image through obsmcp using the configured provider.
-
-
-
-## Comparison With Other MCP Servers
-
-This section is intentionally practical and honest.
-
-Not all MCP servers solve the same problem, so this is not a strict "winner takes all" comparison.
-
-`obsmcp` is strongest when you care about continuity, restart safety, project memory, and developer operations.
-
-It is not automatically the best choice when you only need one narrow capability like browser control or GitHub automation.
-
-### Comparison matrix
-
-| Server / category | What it is best at | Where it wins | Where `obsmcp` wins | Where `obsmcp` is weaker |
-| --- | --- | --- | --- | --- |
-| **Caveman / DIY MCP stack** | Minimal custom setup, hand-rolled memory, quick experiments | Lowest conceptual overhead, easiest to customize quickly | Structured continuity, task/handoff/session management, token-aware startup, auditability, semantic knowledge | `obsmcp` is heavier and more opinionated than a tiny one-file or prompt-only setup |
-| **[Context Portal / ConPort](https://github.com/GreatScottyMac/context-portal)** | Project-specific memory bank and RAG backend | Strong structured project memory, SQLite workspace, knowledge graph, semantic search | Stronger session lifecycle, handoffs, startup safety rails, resume board, output/token engineering, command intelligence | ConPort is more narrowly focused on memory-bank workflows and may feel simpler if that is all you need |
-| **[Mem0 / OpenMemory MCP](https://github.com/mem0ai/mem0)** | Long-term agent memory and retrieval | Strong memory-centric positioning, retrieval focus, secure/local memory story | Better project operations, richer handoffs, explicit current-task/task dependency model, audit trail, code atlas, session recovery | Mem0 is more specialized if your main goal is reusable memory across many assistants rather than project execution workflow |
-| **[Claude-Flow / RuFlow ecosystem](https://github.com/ruvnet/ruflo)** | Multi-agent orchestration and swarm-style automation | Agent orchestration, large tool surface, automation-heavy workflows | Simpler local continuity model, cleaner project-state tracking, more explicit handoffs and restart safety, lower operational sprawl for solo/small-team dev work | `obsmcp` is not a swarm/orchestration platform and does less around multi-agent hive execution |
-| **[GitHub MCP Server](https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/use-the-github-mcp-server)** | GitHub-native repository, issue, PR, and workflow operations | Best when the task is "work with GitHub itself" | Better persistent local continuity, local task/project memory, handoff discipline, codebase restart context | `obsmcp` is not a replacement for deep GitHub API operations |
-| **[Playwright MCP](https://github.com/microsoft/playwright-mcp)** | Browser automation, testing, and UI interaction | Best-in-class for browser workflows | Better at long-lived project memory, multi-session continuity, local project governance | `obsmcp` does not replace a browser automation specialist |
-| **[Model Context Protocol reference servers](https://github.com/modelcontextprotocol/servers)** | Focused single-purpose tools like filesystem, fetch, git, and memory | Simple, composable, narrow tools with low ambiguity | `obsmcp` unifies continuity, startup context, handoffs, sessions, semantic code understanding, and optimization in one system | The reference servers are usually simpler and easier to reason about when you only need one narrow capability |
-
-### Token-saving comparison
-
-| Server / category | Token-saving approach | Strengths | Limits |
-| --- | --- | --- | --- |
-| `obsmcp` | Tiered context profiles, delta context, retrieval context, semantic lookups, command-output compaction, output-response policy, token metrics | Broadest token strategy across both input and selected output surfaces | More moving parts to understand and tune |
-| ConPort | Structured project memory, queryable context, vector/RAG support, prompt-caching-friendly structure | Good for memory retrieval over large project memory | Less focused on session startup packets, handoff discipline, and output compaction |
-| Mem0 | Memory retrieval instead of full-history replay | Strong long-term memory efficiency story | Not a full project continuity and startup-governance layer |
-| Claude-Flow / RuFlow | Orchestration, tool specialization, workflow automation | Can reduce manual prompting through agent specialization | More orchestration overhead; not primarily a continuity/token-governance system |
-| GitHub MCP | Tool-level context scoping inside GitHub workflows | Prevents over-fetching when the task is GitHub-specific | Does not solve local repo continuity or multi-session task memory |
-| Playwright MCP | Tool use instead of verbose browser transcripts | Efficient for UI execution flows | Not a continuity engine |
-| DIY / Caveman | Minimal overhead by doing almost nothing automatically | Low system overhead | Most token discipline must be done manually by the operator |
-
-### Feature-by-feature perspective for developers
-
-| Feature | obsmcp | Typical narrow MCP server |
-| --- | --- | --- |
-| Project-scoped memory | Strong | Usually weak or absent |
-| Current task tracking | Native | Usually absent |
-| Structured handoffs | Native | Usually absent |
-| Resume safety | Strong | Usually manual |
-| Session lifecycle | Strong | Often minimal |
-| Token-aware startup context | Strong | Often absent |
-| Code semantic understanding | Strong | Usually absent unless specialized |
-| Browser automation | Weak by itself | Strong in Playwright MCP |
-| GitHub automation | Moderate to weak | Strong in GitHub MCP |
-| Memory graph / agent memory | Moderate to strong | Strong in memory-specialized servers |
-| Operational simplicity | Moderate | Often simpler in narrow servers |
-| Auditability | Strong | Varies widely |
-
-### Important honesty note on "Caveman" and "RuFlow"
-
-As of April 14, 2026, I could verify a maintained public ecosystem around `ruvnet/ruflo` / Claude-Flow-style orchestration, but I could not verify one single canonical MCP product named `Caveman` in the same way. In this README, `Caveman` is therefore treated as shorthand for a very minimal, DIY, or hand-rolled MCP + prompt-memory approach rather than a verified official comparison target.
+Pre-commit hooks (`pre-commit install`) run Ruff + trailing-whitespace fixes.
-That distinction matters, because `obsmcp` is strongest when compared against:
-
-- DIY continuity systems
-- memory-bank-only MCP servers
-- orchestration-heavy MCP stacks
-- narrow specialist MCP servers
-
-## Where obsmcp Wins
-
-Choose `obsmcp` when you want:
-
-- one continuity layer for many clients
-- durable task/session/handoff state
-- safer restarts after interruptions
-- token-aware startup and resume
-- explicit blockers, decisions, and relevant files
-- semantic code understanding tied to project continuity
-- auditable AI work instead of hidden chat-only memory
+## Architecture notes
-It is especially strong for:
-
-- long-lived coding projects
-- multi-day AI-assisted development
-- model switching and handoffs
-- teams experimenting with multiple AI clients
-- debugging "the model forgot what it was doing" problems
-- controlling token costs on large projects
-
-## Where obsmcp Is Weaker
-
-Choose another tool, or combine another MCP with `obsmcp`, when you need:
-
-- first-class browser automation: use Playwright MCP
-- heavy GitHub-native workflows: use GitHub MCP Server
-- swarm-style multi-agent orchestration: use Claude-Flow / RuFlow
-- a simpler memory-bank-only system: use ConPort or Mem0
-- the smallest possible setup with almost zero concepts: use a DIY minimal server
-
-Current practical cons of `obsmcp`:
-
-- Windows-first scripts and docs
-- broad tool surface can feel large at first
-- more state and moving parts than narrow single-purpose servers
-- output-token enforcement only applies where `obsmcp` controls generation
-- not a replacement for specialist browser or GitHub automation servers
-- not a full multi-agent orchestration framework
-
-## Next Recommended Commit
-
-
-
-
-
-The next high-value commit is already scoped in [docs/NEXT_COMMIT_PLAN.md](docs/NEXT_COMMIT_PLAN.md).
-
-Recommended direction:
-
-- improve VS Code startup integration so clients automatically use:
- - `resolve_active_project`
- - `get_startup_preflight`
- - `get_resume_board`
-- make output-token strategy easier to adopt by surfacing:
- - recommended output modes
- - task-type presets
- - token-savings visibility in dashboards
-
-Suggested next commit title:
-
-```text
-Improve VS Code startup flow and expose output-token strategy defaults
-```
+- **No ORM.** The backend uses `sqlite3` directly via `threading.local()` connections. Columns that hold JSON (`tags`, `metadata`, `imports`, `exports`) are stored as JSON strings and decoded in Python.
+- **IDs are UUIDs** minted client-side to allow offline-first writes.
+- **Timestamps are ISO-8601 UTC** strings.
+- **SSE broadcaster is thread-safe** — any handler can call `broadcast_event()` and it will fan out via `loop.call_soon_threadsafe`.
+- **Graceful degradation** — if the SSE stream drops, the React app stays usable and shows an "Offline" indicator in the sidebar. Reconnection is automatic.
+- **Static frontend served by the backend** — after `npm run build` the backend mounts `frontend_dist/` at `/` so a single binary serves the full app.
-## Documentation Index
+## Status / roadmap
-- [Next Commit Plan](docs/NEXT_COMMIT_PLAN.md)
-- [Architecture](docs/ARCHITECTURE.md)
-- [Usage Guide](docs/USAGE.md)
-- [Installation Guide](docs/INSTALLATION.md)
-- [Folder Structure](docs/FOLDER_STRUCTURE.md)
-- [Obsidian Integration](docs/OBSIDIAN.md)
-- [Startup Automation](docs/STARTUP.md)
-- [Testing](docs/TESTING.md)
-- [Troubleshooting](docs/TROUBLESHOOTING.md)
+Scaffolded in this PR:
-## Bottom Line
+- [x] Full CRUD + SSE for every entity in the spec
+- [x] Local dual-mode HTTP client (SQLite + background cloud sync)
+- [x] MCP stdio tool server exposing 17 tool functions
+- [x] 10-page React dashboard with live SSE-driven cache invalidation
+- [x] Docker image + `docker compose` deployment
+- [x] Ruff + Pytest for the Python side, TypeScript typecheck + Vite build for the frontend, `docker build` for the server image (all runnable locally — no CI configured by design)
-If you need a **single-purpose MCP server**, there are excellent specialized options.
+Known gaps / follow-ups:
-If you need a **project continuity system for real development work** that can:
+- [ ] tree-sitter-based language parsing (currently regex heuristics)
+- [ ] SQLite backup rotation
+- [ ] LLM semantic descriptions are wired but opt-in; no batching/cost controls yet
+- [ ] Optional GraphQL endpoint (spec marks as optional)
-- remember what is happening
-- tell the next model what matters
-- survive interruptions
-- reduce token waste
-- track tasks and handoffs
-- understand the codebase
+## License
-then `obsmcp` is a much stronger foundation than a basic MCP tool wrapper or a purely chat-memory approach.
+MIT — see [`LICENSE`](LICENSE).
diff --git a/backup_obsmcp.bat b/backup_obsmcp.bat
deleted file mode 100644
index 25f859e..0000000
--- a/backup_obsmcp.bat
+++ /dev/null
@@ -1,10 +0,0 @@
-@echo off
-setlocal
-cd /d "%~dp0"
-
-if exist ".venv\Scripts\python.exe" (
- ".venv\Scripts\python.exe" scripts\backup_obsmcp.py
-) else (
- py -3 scripts\backup_obsmcp.py
-)
-
diff --git a/bootstrap_obsmcp.bat b/bootstrap_obsmcp.bat
deleted file mode 100644
index 4bb2794..0000000
--- a/bootstrap_obsmcp.bat
+++ /dev/null
@@ -1,13 +0,0 @@
-@echo off
-setlocal
-cd /d "%~dp0"
-
-if not exist ".venv\Scripts\python.exe" (
- py -3 -m venv .venv
-)
-
-".venv\Scripts\python.exe" -m pip install --upgrade pip
-".venv\Scripts\python.exe" -m pip install -r requirements.txt
-
-echo obsmcp bootstrap complete.
-
diff --git a/cli/__init__.py b/cli/__init__.py
deleted file mode 100644
index 433e5ca..0000000
--- a/cli/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-"""obsmcp CLI package."""
-
diff --git a/cli/main.py b/cli/main.py
deleted file mode 100644
index 7382c7c..0000000
--- a/cli/main.py
+++ /dev/null
@@ -1,805 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import json
-from typing import Any
-
-from server.config import load_config
-from server.service import ObsmcpService
-
-
-def _print(value: Any) -> None:
- if isinstance(value, str):
- print(value)
- return
- print(json.dumps(value, indent=2, ensure_ascii=True))
-
-
-def _csv(value: str | None) -> list[str]:
- if not value:
- return []
- return [item.strip() for item in value.split(",") if item.strip()]
-
-
-def build_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(prog="ctx", description="obsmcp continuity CLI")
- parser.add_argument("--config", default=None, help="Path to obsmcp config JSON.")
- parser.add_argument("--project", dest="project_path", default=None, help="Project root path. Defaults to OBSMCP_PROJECT env var or configured default.")
- subparsers = parser.add_subparsers(dest="command", required=True)
-
- start = subparsers.add_parser("start", help="Set the current task and mark it in progress.")
- start.add_argument("task_id")
- start.add_argument("--actor", default="ctx")
-
- log = subparsers.add_parser("log", help="Append a work log entry.")
- log.add_argument("message")
- log.add_argument("--task", dest="task_id")
- log.add_argument("--summary")
- log.add_argument("--files")
- log.add_argument("--actor", default="ctx")
-
- handoff = subparsers.add_parser("handoff", help="Create a handoff for another model or tool.")
- handoff.add_argument("--summary", required=True)
- handoff.add_argument("--next-steps", default="")
- handoff.add_argument("--open-questions", default="")
- handoff.add_argument("--note", default="")
- handoff.add_argument("--task", dest="task_id")
- handoff.add_argument("--from", dest="from_actor", default="ctx")
- handoff.add_argument("--to", dest="to_actor", default="next-agent")
-
- sync = subparsers.add_parser("sync", help="Regenerate .context and Obsidian files.")
-
- status = subparsers.add_parser("status", help="Show the project status snapshot.")
- preflight = subparsers.add_parser("preflight", help="Run startup safety checks before opening or resuming a session.")
- preflight.add_argument("--actor", default="")
- preflight.add_argument("--task", dest="task_id")
- preflight.add_argument("--session", dest="session_id")
- preflight.add_argument("--initial-request", default="")
- preflight.add_argument("--goal", dest="session_goal", default="")
- preflight.add_argument("--label", dest="session_label", default="")
- preflight.add_argument("--workstream", dest="workstream_key", default="")
- preflight.add_argument("--client", dest="client_name", default="")
- preflight.add_argument("--model", dest="model_name", default="")
- resume_board = subparsers.add_parser("resume-board", help="Show the startup resume dashboard.")
- compat = subparsers.add_parser("compat", help="Check client/server compatibility.")
- compat.add_argument("--client-api-version", default="")
- compat.add_argument("--client-tool-schema-version", type=int)
- compat.add_argument("--client", dest="client_name", default="")
- compat.add_argument("--model", dest="model_name", default="")
-
- blockers = subparsers.add_parser("blockers", help="Show open blockers.")
-
- note = subparsers.add_parser("note", help="Append a daily note entry.")
- note.add_argument("entry")
- note.add_argument("--date", dest="note_date")
- note.add_argument("--actor", default="ctx")
-
- current = subparsers.add_parser("current", help="Show the current task.")
-
- project = subparsers.add_parser("project", help="Project registration and workspace utilities.")
- project_sub = project.add_subparsers(dest="project_command", required=True)
- project_register = project_sub.add_parser("register", help="Register a repo with obsmcp.")
- project_register.add_argument("--repo", dest="repo_path", required=True)
- project_register.add_argument("--name")
- project_register.add_argument("--tags")
- project_list = project_sub.add_parser("list", help="List registered projects.")
- project_paths = project_sub.add_parser("paths", help="Show workspace paths for a project.")
- project_paths.add_argument("--slug", dest="project_slug")
- project_paths.add_argument("--repo", dest="project_repo")
- project_migrate = project_sub.add_parser("migrate", help="Migrate legacy repo-local obsmcp notes/context into the centralized workspace.")
- project_migrate.add_argument("--slug", dest="project_slug")
- project_migrate.add_argument("--repo", dest="project_repo")
-
- session = subparsers.add_parser("session", help="Session operations.")
- session_sub = session.add_subparsers(dest="session_command", required=True)
-
- session_open = session_sub.add_parser("open", help="Open a tracked AI work session.")
- session_open.add_argument("--actor", required=True)
- session_open.add_argument("--client", dest="client_name", default="")
- session_open.add_argument("--model", dest="model_name", default="")
- session_open.add_argument("--label", dest="session_label", default="")
- session_open.add_argument("--workstream", dest="workstream_key", default="")
- session_open.add_argument("--workstream-title", dest="workstream_title", default="")
- session_open.add_argument("--project-path", dest="session_project_path", default=None)
- session_open.add_argument("--initial-request", default="")
- session_open.add_argument("--goal", dest="session_goal", default="")
- session_open.add_argument("--task", dest="task_id")
- session_open.add_argument("--resume-strategy", choices=["auto", "new", "resume"], default="auto")
- session_open.add_argument("--resume-session-id", default=None)
- session_open.add_argument("--heartbeat-seconds", dest="heartbeat_interval_seconds", type=int, default=900)
- session_open.add_argument("--worklog-seconds", dest="work_log_interval_seconds", type=int, default=1800)
- session_open.add_argument("--min-worklogs", dest="min_work_logs", type=int, default=1)
-
- session_heartbeat = session_sub.add_parser("heartbeat", help="Heartbeat an active session.")
- session_heartbeat.add_argument("session_id")
- session_heartbeat.add_argument("--actor", required=True)
- session_heartbeat.add_argument("--note", dest="status_note", default="")
- session_heartbeat.add_argument("--task", dest="task_id")
- session_heartbeat.add_argument("--files")
- session_heartbeat.add_argument("--create-work-log", action="store_true")
-
- session_close = session_sub.add_parser("close", help="Close a session and optionally create a handoff.")
- session_close.add_argument("session_id")
- session_close.add_argument("--actor", required=True)
- session_close.add_argument("--summary", default="")
- session_close.add_argument("--handoff-summary", default="")
- session_close.add_argument("--handoff-next-steps", default="")
- session_close.add_argument("--handoff-open-questions", default="")
- session_close.add_argument("--handoff-note", default="")
- session_close.add_argument("--handoff-to", dest="handoff_to_actor", default="next-agent")
- session_close.add_argument("--skip-handoff", action="store_true")
-
- session_list = session_sub.add_parser("list", help="List active sessions.")
-
- atlas = subparsers.add_parser("atlas", help="Code Atlas — scan and document the entire codebase.")
- atlas.add_argument("action", nargs="?", default="status", choices=["status", "refresh", "generate", "jobs", "job", "wait"], help="'status' = check atlas state. 'refresh' = regenerate if stale. 'generate' = force regenerate. 'jobs'/'job'/'wait' operate on background scan jobs.")
- atlas.add_argument("job_id", nargs="?", default=None, help="Optional scan job ID for 'job' or 'wait'.")
- atlas.add_argument("--force", action="store_true", help="Force full regeneration even if up to date.")
- atlas.add_argument("--background", action="store_true", help="Queue the scan in the background and return a pollable job.")
- atlas.add_argument("--wait", action="store_true", help="After queueing a background scan, wait for completion.")
- atlas.add_argument("--wait-seconds", type=int, default=30, help="How long to wait when using --wait or atlas wait.")
- atlas.add_argument("--requested-by", default="ctx", help="Actor label to store on a queued background scan job.")
- atlas.add_argument("--status", dest="job_status", choices=["queued", "running", "completed", "failed", "interrupted"], help="Filter atlas jobs by status.")
-
- describe = subparsers.add_parser("describe", help="Semantic knowledge lookups.")
- describe_sub = describe.add_subparsers(dest="describe_command", required=True)
- describe_module = describe_sub.add_parser("module", help="Describe a module/file.")
- describe_module.add_argument("module_path")
- describe_symbol = describe_sub.add_parser("symbol", help="Describe a function or class.")
- describe_symbol.add_argument("symbol_name")
- describe_symbol.add_argument("--module")
- describe_symbol.add_argument("--entity-key")
- describe_symbol.add_argument("--type", dest="entity_type", choices=["function", "class"])
- describe_feature = describe_sub.add_parser("feature", help="Describe a feature tag.")
- describe_feature.add_argument("feature_name")
-
- knowledge = subparsers.add_parser("knowledge", help="Semantic knowledge search and maintenance.")
- knowledge_sub = knowledge.add_subparsers(dest="knowledge_command", required=True)
- knowledge_search = knowledge_sub.add_parser("search", help="Search semantic knowledge.")
- knowledge_search.add_argument("query")
- knowledge_search.add_argument("--limit", type=int, default=10)
- knowledge_candidates = knowledge_sub.add_parser("candidates", help="Get symbol candidates for a name.")
- knowledge_candidates.add_argument("symbol_name")
- knowledge_candidates.add_argument("--module")
- knowledge_candidates.add_argument("--type", dest="entity_type", choices=["function", "class"])
- knowledge_candidates.add_argument("--limit", type=int, default=20)
- knowledge_related = knowledge_sub.add_parser("related", help="Get related symbols for an entity.")
- knowledge_related.add_argument("entity_key")
- knowledge_related.add_argument("--limit", type=int, default=8)
- knowledge_invalidate = knowledge_sub.add_parser("invalidate", help="Invalidate semantic cache by entity or file.")
- knowledge_invalidate.add_argument("--entity-key")
- knowledge_invalidate.add_argument("--files")
- knowledge_refresh = knowledge_sub.add_parser("refresh", help="Force refresh a semantic description.")
- knowledge_refresh.add_argument("--entity-key")
- knowledge_refresh.add_argument("--module")
- knowledge_refresh.add_argument("--symbol")
- knowledge_refresh.add_argument("--feature")
- knowledge_refresh.add_argument("--type", dest="entity_type", choices=["function", "class"])
-
- # Phase 4: compact_context_v2 CLI
- compact = subparsers.add_parser("compact", help="Generate compact context v2 with token budget.")
- compact.add_argument("--task", dest="task_id")
- compact.add_argument("--profile", choices=["fast", "balanced", "deep", "handoff", "recovery"], default="deep")
- compact.add_argument("--max-tokens", dest="max_tokens", type=int, default=3000)
- compact.add_argument("--no-decision-chain", dest="include_decision_chain", action="store_false", default=True)
- compact.add_argument("--no-dependency-map", dest="include_dependency_map", action="store_false", default=True)
- compact.add_argument("--no-session-info", dest="include_session_info", action="store_false", default=True)
- compact.add_argument("--no-recent-work", dest="include_recent_work", action="store_false", default=True)
- compact.add_argument("--daily-notes", action="store_true", default=False)
-
- delta = subparsers.add_parser("delta", help="Generate delta context since a handoff, session, or timestamp.")
- delta.add_argument("--task", dest="task_id")
- delta.add_argument("--handoff", dest="since_handoff_id", type=int)
- delta.add_argument("--session", dest="since_session_id")
- delta.add_argument("--since", dest="since_timestamp")
-
- audit = subparsers.add_parser("audit", help="Audit sessions for missing write-back and handoffs.")
- audit.add_argument("--include-closed", action="store_true")
-
- fast = subparsers.add_parser("fast", help="Generate a lightweight L0-only fast context for startup/resume.")
- fast.add_argument("--task", dest="task_id")
- fast.add_argument("--tokens", action="store_true", help="Print token count after output.")
-
- resume = subparsers.add_parser("resume", help="Generate a resume packet for the active project/session.")
- resume.add_argument("--session", dest="session_id")
- resume.add_argument("--task", dest="task_id")
-
- recover = subparsers.add_parser("recover", help="Recover an interrupted session with an emergency handoff.")
- recover.add_argument("--session", dest="session_id")
- recover.add_argument("--actor", default="ctx-recovery")
-
- workspace = subparsers.add_parser("workspace", help="Workspace helper commands.")
- workspace_sub = workspace.add_subparsers(dest="workspace_command", required=True)
- workspace_paths = workspace_sub.add_parser("paths", help="Show the workspace paths for the active project.")
- workspace_paths.add_argument("--slug", dest="project_slug")
- workspace_paths.add_argument("--repo", dest="workspace_repo")
-
- hub = subparsers.add_parser("hub", help="Central hub utilities.")
- hub_sub = hub.add_subparsers(dest="hub_command", required=True)
- hub_sync = hub_sub.add_parser("sync", help="Refresh the obsmcp hub vault.")
-
- # Phase 1: Task Templates CLI
- template = subparsers.add_parser("template", help="Task template operations.")
- template_sub = template.add_subparsers(dest="template_command", required=True)
- template_list = template_sub.add_parser("list", help="List all task templates.")
- template_get = template_sub.add_parser("get", help="Get a specific template.")
- template_get.add_argument("name")
- template_create = template_sub.add_parser("create", help="Create a new task template.")
- template_create.add_argument("name")
- template_create.add_argument("--title", required=True, help="Title template with {placeholders}")
- template_create.add_argument("--description", required=True, help="Description template with {placeholders}")
- template_create.add_argument("--priority", default="medium")
- template_create.add_argument("--tags")
- template_delete = template_sub.add_parser("delete", help="Delete a task template.")
- template_delete.add_argument("name")
-
- # Phase 1: Quick Log CLI
- quick = subparsers.add_parser("quick", help="Quick work log — auto-tags current task.")
- quick.add_argument("message")
- quick.add_argument("--files")
- quick.add_argument("--actor", default="ctx")
-
- # Phase 1: Audit Log CLI
- audit_log = subparsers.add_parser("audit-log", help="Full activity timeline.")
- audit_log.add_argument("--actor")
- audit_log.add_argument("--task")
- audit_log.add_argument("--type")
- audit_log.add_argument("--from")
- audit_log.add_argument("--to")
- audit_log.add_argument("--limit", type=int, default=100)
- audit_log.add_argument("--ai-only", action="store_true")
-
- # Phase 2: Reset Project CLI
- reset = subparsers.add_parser("reset", help="Reset project data by scope. WARNING: permanently deletes data.")
- reset.add_argument("--scope", required=True, choices=["tasks", "blockers", "sessions", "work_logs", "decisions", "handoffs", "full"], help="Scope to reset")
- reset.add_argument("--actor", default="ctx")
-
- # Phase 2: Bulk Task Ops CLI
- bulk = subparsers.add_parser("bulk", help="Bulk task operations (JSON array of operations).")
- bulk.add_argument("operations", help='JSON array, e.g. \'[{"action":"create","title":"X","description":"Y"}]\'')
-
- # Phase 2: Project Export CLI
- export = subparsers.add_parser("export", help="Export project state.")
- export.add_argument("--format", default="both", choices=["json", "markdown", "both"])
-
- # Phase 3: Work Log Expiry CLI
- logs = subparsers.add_parser("logs", help="Work log operations.")
- logs_sub = logs.add_subparsers(dest="logs_command", required=True)
- logs_stats = logs_sub.add_parser("stats", help="Show log statistics by age.")
- logs_expire = logs_sub.add_parser("expire", help="Purge old logs.")
- logs_expire.add_argument("--days", type=int, help="Override retention days (default: use configured)")
- logs_expire.add_argument("--actor", default="ctx")
- logs_config = logs_sub.add_parser("config", help="Configure log expiry.")
- logs_config.add_argument("days", type=int, help="Retention days (0=disable)")
-
- # Phase 3: Session Replay CLI
- session_replay = subparsers.add_parser("replay", help="Replay a session timeline.")
- session_replay.add_argument("session_id", nargs="?", help="Session ID (defaults to most recent)")
-
- # Phase 3: Task Dependency CLI
- deps = subparsers.add_parser("deps", help="Task dependency operations.")
- deps_sub = deps.add_subparsers(dest="deps_command", required=True)
- deps_add = deps_sub.add_parser("add", help="Add dependency.")
- deps_add.add_argument("task_id")
- deps_add.add_argument("--blocked-by", help="Comma-separated task IDs this is blocked by")
- deps_add.add_argument("--blocks", help="Comma-separated task IDs this blocks")
- deps_remove = deps_sub.add_parser("remove", help="Remove dependency.")
- deps_remove.add_argument("task_id")
- deps_remove.add_argument("--blocked-by", help="Comma-separated task IDs to unlink")
- deps_remove.add_argument("--blocks", help="Comma-separated task IDs to unlink")
- deps_list = deps_sub.add_parser("list", help="List all dependencies.")
- deps_blocked = deps_sub.add_parser("blocked", help="List blocked tasks.")
- deps_validate = deps_sub.add_parser("validate", help="Validate all dependencies.")
-
- task = subparsers.add_parser("task", help="Task operations.")
- task_sub = task.add_subparsers(dest="task_command", required=True)
-
- task_create = task_sub.add_parser("create", help="Create a new task.")
- task_create.add_argument("title")
- task_create.add_argument("--description", required=True)
- task_create.add_argument("--priority", default="medium")
- task_create.add_argument("--owner")
- task_create.add_argument("--files")
- task_create.add_argument("--tags")
- task_create.add_argument("--actor", default="ctx")
-
- task_update = task_sub.add_parser("update", help="Update an existing task.")
- task_update.add_argument("task_id")
- task_update.add_argument("--title")
- task_update.add_argument("--description")
- task_update.add_argument("--status")
- task_update.add_argument("--priority")
- task_update.add_argument("--owner")
- task_update.add_argument("--files")
- task_update.add_argument("--tags")
- task_update.add_argument("--actor", default="ctx")
-
- decision = subparsers.add_parser("decision", help="Decision operations.")
- decision_sub = decision.add_subparsers(dest="decision_command", required=True)
-
- decision_log = decision_sub.add_parser("log", help="Record a decision.")
- decision_log.add_argument("title")
- decision_log.add_argument("--decision", required=True)
- decision_log.add_argument("--rationale", default="")
- decision_log.add_argument("--impact", default="")
- decision_log.add_argument("--task", dest="task_id")
- decision_log.add_argument("--actor", default="ctx")
-
- return parser
-
-
-def main() -> None:
- parser = build_parser()
- args = parser.parse_args()
-
- config = load_config(args.config)
- service = ObsmcpService(config)
- project_path = args.project_path
-
- if args.command == "start":
- _print(service.set_current_task(task_id=args.task_id, actor=args.actor, project_path=project_path))
- return
-
- if args.command == "log":
- _print(
- service.log_work(
- message=args.message,
- task_id=args.task_id,
- summary=args.summary,
- files=_csv(args.files),
- actor=args.actor,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "handoff":
- task_id = args.task_id or (service.get_current_task(project_path=project_path) or {}).get("id")
- _print(
- service.create_handoff(
- summary=args.summary,
- next_steps=args.next_steps,
- open_questions=args.open_questions,
- note=args.note,
- task_id=task_id,
- from_actor=args.from_actor,
- to_actor=args.to_actor,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "sync":
- _print(service.sync_context(project_path=project_path))
- return
-
- if args.command == "fast":
- result = service.generate_fast_context(task_id=args.task_id, project_path=project_path)
- if args.tokens:
- print(result["markdown"], end="")
- print(f"\n--- {result['used_tokens']} tokens ---")
- else:
- _print(result)
- return
-
- if args.command == "status":
- _print(service.get_project_status_snapshot(project_path=project_path))
- return
-
- if args.command == "preflight":
- _print(
- service.get_startup_preflight(
- actor=args.actor,
- task_id=args.task_id,
- session_id=args.session_id,
- initial_request=args.initial_request,
- session_goal=args.session_goal,
- session_label=args.session_label,
- workstream_key=args.workstream_key,
- client_name=args.client_name,
- model_name=args.model_name,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "resume-board":
- _print(service.get_resume_board(project_path=project_path))
- return
-
- if args.command == "compat":
- _print(
- service.check_client_compatibility(
- client_api_version=args.client_api_version,
- client_tool_schema_version=args.client_tool_schema_version,
- client_name=args.client_name,
- model_name=args.model_name,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "blockers":
- _print(service.get_blockers(project_path=project_path))
- return
-
- if args.command == "note":
- _print(service.create_daily_note_entry(entry=args.entry, actor=args.actor, note_date=args.note_date, project_path=project_path))
- return
-
- if args.command == "current":
- _print(service.get_current_task(project_path=project_path) or {"message": "No current task set."})
- return
-
- if args.command == "project":
- if args.project_command == "register":
- _print(service.register_project(repo_path=args.repo_path, name=args.name, tags=_csv(args.tags)))
- return
- if args.project_command == "list":
- _print(service.list_projects())
- return
- if args.project_command == "paths":
- _print(service.get_project_workspace_paths(project_slug=args.project_slug, project_path=args.project_repo or project_path))
- return
- if args.project_command == "migrate":
- _print(service.migrate_project_layout(project_slug=args.project_slug, project_path=args.project_repo or project_path))
- return
-
- if args.command == "session":
- if args.session_command == "open":
- effective_project_path = args.session_project_path or project_path
- _print(
- service.session_open(
- actor=args.actor,
- client_name=args.client_name,
- model_name=args.model_name,
- session_label=args.session_label,
- workstream_key=args.workstream_key,
- workstream_title=args.workstream_title,
- project_path=effective_project_path,
- initial_request=args.initial_request,
- session_goal=args.session_goal,
- task_id=args.task_id,
- heartbeat_interval_seconds=args.heartbeat_interval_seconds,
- work_log_interval_seconds=args.work_log_interval_seconds,
- min_work_logs=args.min_work_logs,
- resume_strategy=args.resume_strategy,
- resume_session_id=args.resume_session_id,
- )
- )
- return
- if args.session_command == "heartbeat":
- _print(
- service.session_heartbeat(
- session_id=args.session_id,
- actor=args.actor,
- status_note=args.status_note,
- task_id=args.task_id,
- files=_csv(args.files),
- create_work_log=args.create_work_log,
- project_path=project_path,
- )
- )
- return
- if args.session_command == "close":
- _print(
- service.session_close(
- session_id=args.session_id,
- actor=args.actor,
- summary=args.summary,
- create_handoff=not args.skip_handoff,
- handoff_summary=args.handoff_summary,
- handoff_next_steps=args.handoff_next_steps,
- handoff_open_questions=args.handoff_open_questions,
- handoff_note=args.handoff_note,
- handoff_to_actor=args.handoff_to_actor,
- project_path=project_path,
- )
- )
- return
- if args.session_command == "list":
- _print(service.get_active_sessions(project_path=project_path))
- return
-
- if args.command == "atlas":
- if args.action in {"status", None}:
- _print(service.get_code_atlas_status(project_path=project_path))
- return
- if args.action == "jobs":
- _print(service.list_scan_jobs(project_path=project_path, status=args.job_status))
- return
- if args.action == "job":
- if not args.job_id:
- raise SystemExit("atlas job requires JOB_ID")
- _print(service.get_scan_job(args.job_id, project_path=project_path))
- return
- if args.action == "wait":
- if not args.job_id:
- raise SystemExit("atlas wait requires JOB_ID")
- _print(service.wait_for_scan_job(args.job_id, project_path=project_path, wait_seconds=args.wait_seconds))
- return
- force = args.action == "generate" or args.force
- if args.background:
- job = service.start_scan_job(project_path=project_path, force_refresh=force, requested_by=args.requested_by)
- if args.wait:
- _print(service.wait_for_scan_job(job["id"], project_path=project_path, wait_seconds=args.wait_seconds))
- else:
- _print(job)
- return
- _print(service.scan_codebase(force_refresh=force, project_path=project_path))
- return
-
- if args.command == "describe":
- if args.describe_command == "module":
- _print(service.describe_module(module_path=args.module_path, project_path=project_path))
- return
- if args.describe_command == "symbol":
- _print(
- service.describe_symbol(
- symbol_name=args.symbol_name,
- module_path=args.module,
- entity_key=args.entity_key,
- entity_type=args.entity_type,
- project_path=project_path,
- )
- )
- return
- if args.describe_command == "feature":
- _print(service.describe_feature(feature_name=args.feature_name, project_path=project_path))
- return
-
- if args.command == "knowledge":
- if args.knowledge_command == "search":
- _print(service.search_code_knowledge(query=args.query, limit=args.limit, project_path=project_path))
- return
- if args.knowledge_command == "candidates":
- _print(
- service.get_symbol_candidates(
- symbol_name=args.symbol_name,
- module_path=args.module,
- entity_type=args.entity_type,
- limit=args.limit,
- project_path=project_path,
- )
- )
- return
- if args.knowledge_command == "related":
- _print(service.get_related_symbols(entity_key=args.entity_key, limit=args.limit, project_path=project_path))
- return
- if args.knowledge_command == "invalidate":
- _print(
- service.invalidate_semantic_cache(
- entity_key=args.entity_key,
- file_paths=_csv(args.files),
- project_path=project_path,
- )
- )
- return
- if args.knowledge_command == "refresh":
- _print(
- service.refresh_semantic_description(
- entity_key=args.entity_key,
- module_path=args.module,
- symbol_name=args.symbol,
- feature_name=args.feature,
- entity_type=args.entity_type,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "compact":
- if args.profile == "deep":
- result = service.generate_compact_context_v2(
- task_id=args.task_id,
- max_tokens=args.max_tokens,
- include_decision_chain=args.include_decision_chain,
- include_dependency_map=args.include_dependency_map,
- include_session_info=args.include_session_info,
- include_recent_work=args.include_recent_work,
- include_daily_notes=args.daily_notes,
- project_path=project_path,
- )
- else:
- result = service.generate_context_profile(
- profile=args.profile,
- task_id=args.task_id,
- max_tokens=args.max_tokens,
- include_daily_notes=args.daily_notes,
- project_path=project_path,
- )["markdown"]
- _print(result)
- return
-
- if args.command == "delta":
- _print(
- service.generate_delta_context(
- task_id=args.task_id,
- since_handoff_id=args.since_handoff_id,
- since_session_id=args.since_session_id,
- since_timestamp=args.since_timestamp,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "audit":
- _print(service.detect_missing_writeback(include_closed=args.include_closed, project_path=project_path))
- return
-
- if args.command == "resume":
- _print(service.generate_resume_packet(session_id=args.session_id, task_id=args.task_id, project_path=project_path))
- return
-
- if args.command == "recover":
- _print(service.recover_session(session_id=args.session_id, actor=args.actor, project_path=project_path))
- return
-
- if args.command == "workspace":
- if args.workspace_command == "paths":
- _print(service.get_project_workspace_paths(project_slug=args.project_slug, project_path=args.workspace_repo or project_path))
- return
-
- if args.command == "hub":
- if args.hub_command == "sync":
- _print(service.sync_hub())
- return
-
- if args.command == "task":
- if args.task_command == "create":
- _print(
- service.create_task(
- title=args.title,
- description=args.description,
- priority=args.priority,
- owner=args.owner,
- relevant_files=_csv(args.files),
- tags=_csv(args.tags),
- actor=args.actor,
- project_path=project_path,
- )
- )
- return
- if args.task_command == "update":
- _print(
- service.update_task(
- task_id=args.task_id,
- title=args.title,
- description=args.description,
- status=args.status,
- priority=args.priority,
- owner=args.owner,
- relevant_files=_csv(args.files) if args.files is not None else None,
- tags=_csv(args.tags) if args.tags is not None else None,
- actor=args.actor,
- project_path=project_path,
- )
- )
- return
-
- if args.command == "decision":
- if args.decision_command == "log":
- _print(
- service.log_decision(
- title=args.title,
- decision=args.decision,
- rationale=args.rationale,
- impact=args.impact,
- task_id=args.task_id,
- actor=args.actor,
- project_path=project_path,
- )
- )
- return
-
- # Phase 1: Template commands
- if args.command == "template":
- store = service._store(project_path)
- if args.template_command == "list":
- _print(store.get_task_templates())
- return
- if args.template_command == "get":
- result = store.get_task_template(args.name)
- _print(result or {"error": f"Template '{args.name}' not found."})
- return
- if args.template_command == "create":
- _print(
- store.create_task_template(
- name=args.name,
- title_template=args.title,
- description_template=args.description,
- priority=args.priority,
- tags=_csv(args.tags),
- )
- )
- return
- if args.template_command == "delete":
- deleted = store.delete_task_template(args.name)
- _print({"deleted": deleted, "template": args.name})
- return
-
- # Phase 1: Quick log
- if args.command == "quick":
- _print(service.quick_log(message=args.message, files=_csv(args.files), actor=args.actor, project_path=project_path))
- return
-
- # Phase 1: Audit log
- if args.command == "audit-log":
- _print(
- service.get_audit_log(
- actor=args.actor,
- task_id=args.task,
- action_type=args.type,
- from_date=getattr(args, "from"),
- to_date=getattr(args, "to"),
- limit=args.limit,
- include_ai_only=args.ai_only,
- project_path=project_path,
- )
- )
- return
-
- # Phase 2: Reset project
- if args.command == "reset":
- _print(service.reset_project(scope=args.scope, actor=args.actor, project_path=project_path))
- return
-
- # Phase 2: Bulk task operations
- if args.command == "bulk":
- import json as _json
-
- ops = _json.loads(args.operations)
- _print(service.bulk_task_ops(operations=ops, project_path=project_path))
- return
-
- # Phase 2: Project export
- if args.command == "export":
- _print(service.export_project(format=args.format, project_path=project_path))
- return
-
- # Phase 3: Work log expiry
- if args.command == "logs":
- if args.logs_command == "stats":
- _print(service.get_log_stats(project_path=project_path))
- return
- if args.logs_command == "expire":
- if args.days is not None:
- service.configure_log_expiry(days=args.days, actor=args.actor, project_path=project_path)
- _print(service.expire_old_logs(actor=args.actor, project_path=project_path))
- return
- if args.logs_command == "config":
- _print(service.configure_log_expiry(days=args.days, actor="ctx", project_path=project_path))
- return
-
- # Phase 3: Session replay
- if args.command == "replay":
- _print(service.session_replay(session_id=args.session_id, project_path=project_path))
- return
-
- # Phase 3: Task dependencies
- if args.command == "deps":
- if args.deps_command == "add":
- blocked = [x.strip() for x in (args.blocked_by or "").split(",") if x.strip()]
- blocks = [x.strip() for x in (args.blocks or "").split(",") if x.strip()]
- _print(service.add_task_dependency(task_id=args.task_id, blocked_by=blocked, blocks=blocks, project_path=project_path))
- return
- if args.deps_command == "remove":
- blocked = [x.strip() for x in (args.blocked_by or "").split(",") if x.strip()]
- blocks = [x.strip() for x in (args.blocks or "").split(",") if x.strip()]
- _print(service.remove_task_dependency(task_id=args.task_id, blocked_by=blocked if blocked else None, blocks=blocks if blocks else None, project_path=project_path))
- return
- if args.deps_command == "list":
- _print(service.get_all_dependencies(project_path=project_path))
- return
- if args.deps_command == "blocked":
- _print(service.get_blocked_tasks(project_path=project_path))
- return
- if args.deps_command == "validate":
- _print(service.validate_dependencies(project_path=project_path))
- return
-
- parser.error("Unknown command")
-
-
-if __name__ == "__main__":
- main()
diff --git a/config/mcp-client-example.json b/config/mcp-client-example.json
deleted file mode 100644
index 72a099c..0000000
--- a/config/mcp-client-example.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "mcpServers": {
- "obsmcp": {
- "transport": "http",
- "url": "http://127.0.0.1:9300/mcp"
- }
- }
-}
diff --git a/config/obsmcp.json b/config/obsmcp.json
deleted file mode 100644
index 8ce62c2..0000000
--- a/config/obsmcp.json
+++ /dev/null
@@ -1,158 +0,0 @@
-{
- "app_name": "obsmcp",
- "description": "Obsidian MCP continuity server",
- "host": "127.0.0.1",
- "port": 9300,
- "bind_local_only": true,
- "database_path": "./data/db/obsmcp.sqlite3",
- "json_export_dir": "./data/json",
- "backup_dir": "./data/backups",
- "log_dir": "./logs",
- "context_dir": "./.context",
- "obsidian_vault_dir": "./obsidian/vault",
- "pid_file": "./data/obsmcp.pid",
- "workspace_root_dir": "./workspace",
- "projects_root_dir": "./projects",
- "hub_vault_dir": "./hub/vault",
- "registry_path": "./registry/projects.json",
- "repo_bridge_filename": ".obsmcp-link.json",
- "max_recent_work_items": 12,
- "max_decisions": 20,
- "max_blockers": 20,
- "strict_project_routing": true,
- "bootstrap_default_project_on_startup": false,
- "obsidian": {
- "project_brief_note": "Projects/Project Brief.md",
- "current_task_note": "Projects/Current Task.md",
- "status_snapshot_note": "Projects/Status Snapshot.md",
- "latest_handoff_note": "Handoffs/Latest Handoff.md",
- "decision_index_note": "Decisions/Decision Log.md",
- "daily_notes_dir": "Daily",
- "session_note": "Sessions/Latest Session Summary.md",
- "code_atlas_note": "Research/Code Atlas.md"
- },
- "default_project_path": "D:\\Projects\\obsmcp",
- "logging": {
- "level": "INFO",
- "json_output": true,
- "json_output_path": "obsmcp-structured.json",
- "include_traceback": false,
- "console_output": true
- },
- "checkpoints": {
- "enabled": true,
- "render_limit": 12,
- "auto_rollup": true,
- "auto_close_task": false
- },
- "output_compression": {
- "enabled": false,
- "mode": "off",
- "level": "full",
- "style": "concise_professional",
- "respect_user_detail_requests": true,
- "expand_on_request": true,
- "task_overrides": {
- "review": {
- "style": "terse_technical",
- "level": "full"
- },
- "debugging": {
- "style": "concise_professional",
- "level": "full"
- },
- "architecture": {
- "style": "concise_professional",
- "level": "lite"
- },
- "dangerous_actions": {
- "mode": "off"
- }
- },
- "prompt_only": {
- "enabled": true,
- "direct_answer_first": true,
- "no_greetings": true,
- "no_recap": true,
- "short_paragraphs": true,
- "prefer_bullets_for_lists": true,
- "max_paragraph_sentences": 3,
- "findings_first_for_reviews": true
- },
- "gateway_enforced": {
- "enabled": false,
- "inject_contract": true,
- "enforce_direct_answer_first": true,
- "enforce_findings_first_for_reviews": true,
- "max_output_tokens_soft": 900,
- "max_output_sections": 6,
- "max_paragraph_lines": 4
- },
- "safety_bypass": {
- "enabled": true,
- "destructive_actions": true,
- "security_sensitive": true,
- "legal_medical_financial": true,
- "ambiguity_clarification": true,
- "step_by_step_sensitive": true
- },
- "observability": {
- "log_metrics": true,
- "record_mode": true,
- "record_style": true,
- "record_task_type": true,
- "sample_rate": 1.0
- },
- "preserve_patterns": {
- "code_blocks": true,
- "urls": true,
- "filepaths": true,
- "error_messages": true,
- "json_output": true,
- "commands": true,
- "stack_traces": true
- }
- },
- "semantic": {
- "auto_generate": {
- "enabled": true,
- "allow_llm": true,
- "max_modules_per_scan": 5,
- "max_modules_per_write": 3,
- "on_log_work": true,
- "on_update_task": true,
- "on_create_task": true,
- "on_set_current_task": true,
- "on_handoff": true,
- "on_startup": true,
- "max_queue_size": 8,
- "max_concurrent_jobs": 1,
- "wait_ms_on_handoff": 250,
- "wait_ms_on_startup": 150,
- "skip_path_fragments": [
- "/.git/",
- "/.hg/",
- "/.svn/",
- "/.venv/",
- "/venv/",
- "/node_modules/",
- "/dist/",
- "/build/",
- "/target/",
- "/vendor/",
- "/__pycache__/",
- "/coverage/",
- "/.next/"
- ],
- "skip_generated_suffixes": [
- ".min.js",
- ".bundle.js",
- ".generated.ts",
- ".generated.js",
- ".generated.py",
- ".pb.go",
- ".designer.cs"
- ]
- }
- }
-}
diff --git a/ctx.bat b/ctx.bat
deleted file mode 100644
index 6922aa7..0000000
--- a/ctx.bat
+++ /dev/null
@@ -1,11 +0,0 @@
-@echo off
-setlocal
-set "OBSMCP_CALLER_CWD=%CD%"
-if not defined OBSMCP_PROJECT set "OBSMCP_PROJECT=%OBSMCP_CALLER_CWD%"
-cd /d "%~dp0"
-
-if exist ".venv\Scripts\python.exe" (
- ".venv\Scripts\python.exe" -m cli.main %*
-) else (
- py -3 -m cli.main %*
-)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..373a121
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,22 @@
+services:
+ obsmcp:
+ build: .
+ image: obsmcp:latest
+ ports:
+ - "8000:8000"
+ environment:
+ - OBSMCP_API_TOKEN=${OBSMCP_API_TOKEN:-}
+ - OBSMCP_DB_PATH=/data/obsmcp.db
+ - OBSMCP_HOST=0.0.0.0
+ - OBSMCP_PORT=8000
+ volumes:
+ - obsmcp-data:/data
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+volumes:
+ obsmcp-data:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
deleted file mode 100644
index a3e050a..0000000
--- a/docs/ARCHITECTURE.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# obsmcp Architecture
-
-## Why this architecture
-
-`obsmcp` now uses a centralized project-workspace architecture:
-
-- one global control plane rooted at `C:\obsmcp` when deployed
-- one isolated workspace per registered project under `projects//`
-- SQLite as the per-project system of record
-- generated `.context` files as the universal low-friction handoff surface
-- per-project Obsidian vaults as the human-readable knowledge layer
-- a central hub vault for cross-project visibility
-- a local MCP-compatible HTTP server as the structured integration point
-- the `ctx` CLI as the fallback bridge for tools that do not speak MCP
-
-This layout is resilient because each layer has one job and can be repaired independently.
-
-## System layers
-
-### 1. Project workspace model
-
-Each project resolves to a centralized workspace:
-
-- `projects//data/db/obsmcp.sqlite3`
-- `projects//.context/`
-- `projects//vault/`
-- `projects//sessions/`
-- `projects//logs/`
-- `projects//project.json`
-
-The repo stays the code source; the workspace becomes the continuity source.
-
-### 2. System of record
-
-SQLite stores:
-
-- project brief sections
-- current task pointer
-- tasks
-- work logs
-- decisions
-- blockers
-- handoffs
-- session summaries
-- daily entries
-- agent activity
-- session labels and workstream metadata for human-readable project management
-
-Why SQLite:
-
-- zero external service dependency
-- stable on Windows
-- easy backup and inspection
-- easy repair with a single file copy strategy
-- enough concurrency for local multi-tool workflows
-
-### 3. Universal continuity files
-
-Each project workspace has its own `.context` directory, regenerated from SQLite after every write. It exists specifically for tools that:
-
-- cannot connect to MCP
-- can only read files
-- can only accept pasted prompt context
-
-This is the lowest common denominator continuity layer.
-
-Milestone B extends this layer with cached hot-path artifacts:
-
-- `HOT_CONTEXT.md` for fast startup
-- `BALANCED_CONTEXT.md` for normal coding continuity
-- `DEEP_CONTEXT.md` for architecture/debugging sessions
-- `DELTA_CONTEXT.md` for "what changed since the last handoff/session"
-
-These are regenerated during sync so MCP reads and file-based fallbacks can both stay fast.
-
-### 4. Obsidian knowledge layer
-
-Obsidian receives generated project notes plus daily note entries and ADR-style decision notes. Each project gets its own vault, and the hub vault summarizes all registered projects. The project vault is for:
-
-- human review
-- research capture
-- debugging notes
-- handoff reading
-- operational memory outside the token window of any single model
-
-### 5. MCP server
-
-The MCP server binds locally on `127.0.0.1:9300` and routes requests into project workspaces. It exposes:
-
-- read tools for brief, task, blockers, decisions, handoffs, notes, and status
-- write tools for work logs, tasks, decisions, blockers, handoffs, daily entries, and project brief sections
-- meta tools for health, listing, compact context generation, task snapshots, resume packets, recovery, project registration, and hub sync
-- resource endpoints for brief, current task, handoff, status, compact context, resume packets, and project listing
-
-Routing is continuity-aware, not just path-aware:
-
-- explicit `project_path` and `project_slug` always win
-- `session_id` and `task_id` can route later writes back into the correct project automatically
-- repo bridge files and absolute file paths can be used to infer the correct project workspace when a plugin does not pass `project_path`
-- `cwd` and the nearest repo root can be used as first-call routing hints when a client starts inside the project
-- IDE clients can call `resolve_active_project` up front with metadata such as `cwd`, `active_file`, `workspace_folders`, `open_files`, and environment hints to get a stable project scope before the first write
-- recent matching sessions can be resumed automatically on `session_open`
-- `session_open` can attach a readable `session_label` and stable `workstream_key` so logs and dashboards stay understandable to humans
-- `session_open` now normalizes client/model identity strings and applies a mismatch guard before auto-resuming a candidate session
-- if no reliable project hint exists, continuity-sensitive MCP calls fail fast instead of silently falling back to the default project
-
-### 6. CLI bridge
-
-`ctx.bat` calls the same service layer as the server, so the CLI still works if the server is down. This is intentional. The continuity system should not collapse just because the MCP listener is unavailable.
-
-It now also handles:
-
-- project registration
-- workspace path inspection
-- resume packet generation
-- interrupted-session recovery
-- hub refresh
-- startup preflight and resume-board views
-- compatibility checks between client expectations and server tool schema
-
-Server startup is intentionally lazy now:
-
-- booting `obsmcp` does not create a default project workspace unless `bootstrap_default_project_on_startup=true`
-- global `health_check` can report server readiness without creating per-project state
-- project workspaces are created only when a project is explicitly registered, resolved, or otherwise used with a real project scope
-
-## Why this is bulletproof
-
-- Local-only by default: binds to `127.0.0.1`
-- One source of truth: all writes land in SQLite first
-- Atomic file writes: generated files are rewritten atomically
-- Small dependency set: only `fastapi` and `uvicorn` beyond the standard library
-- Restart-safe: startup and stop scripts use PID tracking and Task Scheduler integration
-- Per-project durability: sessions also write metadata, heartbeat, worklog, and handoff files into `sessions//`
-- Session reuse: reopened tools can resume recent matching sessions instead of spawning unnecessary parallel sessions
-- Human-readable session management: labels and workstreams make open/closed session history legible without decoding session ids
-- Safer startup: preflight warnings and resume boards expose stale sessions, done current tasks, handoff mismatches, and taskless substantive work
-- Recovery-aware audits: stale and abandoned sessions are flagged so another model can recover them cleanly
-- Easy inspection: every important artifact is just SQLite, JSON, Markdown, batch, or Python
-- Vendor-neutral: MCP, file reads, CLI, and prompt injection all work from the same state
-
-## Why this is token-efficient
-
-- project workspaces keep compact current-state files instead of full note dumps
-- `generate_compact_context` creates a short prompt-ready summary
-- `generate_context_profile` assembles tiered `fast`, `balanced`, `deep`, `handoff`, and `recovery` context variants from the same state
-- `generate_delta_context` lets the next model read only what changed since the previous reference point
-- `generate_resume_packet` creates a first-read handoff packet for the next model
-- relevant files are tracked explicitly
-- recent work is bounded
-- decisions and blockers are summarized rather than replaying full history
-- handoffs are auto-enriched with task state, relevant files, and semantic suggestions instead of relying only on long freeform prose
-
-## Output-token policy layer
-
-Output-token reduction is implemented as a separate response-policy layer and not as part of the continuity stack.
-
-This separation is deliberate:
-
-- continuity, handoffs, logs, delta context, retrieval context, prompt segments, and semantic caches keep their original fidelity
-- output-token savings target only model-generated prose
-- post-generation compaction remains auxiliary because it does not save model output tokens that were already generated
-
-The control plane lives in `config/obsmcp.json` under `output_compression` and supports:
-
-- `off`
-- `prompt_only`
-- `gateway_enforced`
-
-Current enforcement scope is intentionally narrow and honest:
-
-- `prompt_only` and `gateway_enforced` affect `generate_startup_prompt_template`
-- `gateway_enforced` also affects LLM-backed semantic descriptions by appending the enforced response contract to the OpusMax text-provider system prompt
-- task overrides and safety bypass rules are resolved before contract generation so review/debugging/architecture tasks can use different brevity styles without touching context assembly
-
-This means `obsmcp` saves output tokens only where it actually owns the generation boundary today, while preserving the existing input-token-saving architecture intact.
-
-## Milestone B performance hardening
-
-Milestone B adds a dedicated context artifact cache in SQLite plus generated workspace files. The service now:
-
-- computes a project-local state version from task/work/blocker/handoff/session changes
-- caches tiered context artifacts keyed by profile, task scope, and token budget
-- reuses cached context when the project state has not changed
-- writes fresh tiered context files into `.context` and `data/json` during sync
-- exposes a delta view so resumed agents do not need to replay unchanged project history
-
-This improves latency without sacrificing continuity because the underlying source of truth remains SQLite.
-
-## Why this is especially strong for cross-model continuity
-
-When one model stops halfway, the next model can recover from any of these layers:
-
-- MCP tools: structured read of task, blockers, handoff, decisions, files
-- per-project `.context`: immediate file-based continuity with no protocol support required
-- per-project session folders: metadata, heartbeat timeline, worklog, resume packet, and emergency handoff files
-- Obsidian project vault: human-readable history and architecture notes
-- hub vault: cross-project visibility and quick switching
-- CLI: quick manual updates and state sync from any shell-capable tool
-
-The key design choice is that continuity is not attached to one client. It is attached to `obsmcp`.
diff --git a/docs/FOLDER_STRUCTURE.md b/docs/FOLDER_STRUCTURE.md
deleted file mode 100644
index d5cf741..0000000
--- a/docs/FOLDER_STRUCTURE.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Folder Structure
-
-Recommended production install path: `C:\obsmcp`
-
-This repository is portable, but `C:\obsmcp` is the cleanest Windows deployment path because the batch scripts, scheduled task, and mental model stay simple.
-
-## Layout
-
-```text
-C:\obsmcp\
- server\ Python MCP server, state layer, sync engine
- cli\ ctx CLI implementation
- scripts\ operational helpers for launch, stop, backup
- config\ JSON config and example integration snippets
- logs\ rotating global server logs and startup logs
- docs\ install, usage, architecture, testing, troubleshooting docs
- templates\
- obsidian\ note templates and examples
- context\ context and prompt templates
- registry\
- projects.json global registry of known projects
- hub\
- vault\ central Obsidian hub vault for all projects
- projects\
- \
- project.json per-project manifest
- data\
- db\ per-project SQLite database
- json\ per-project exported snapshots
- backups\ per-project backups
- exports\ per-project export bundles
- .context\ per-project continuity files
- vault\ per-project Obsidian vault
- sessions\ per-session folders with metadata, worklog, and handoff files
- logs\ per-project logs
- tests\ local automated verification
- tools\ universal instructions and helper assets
-```
-
-## Folder purpose
-
-- `server`: the production code that owns state, sync, routing, recovery, and MCP handling
-- `cli`: the `ctx` command surface for shells and non-MCP tools
-- `scripts`: Windows operational actions such as detached launch and backup
-- `config`: editable settings without touching code
-- `logs`: global server, error, and startup logs
-- `docs`: the operator handbook
-- `templates`: reusable note, prompt, and continuity templates
-- `registry/projects.json`: global list of registered repos and their centralized workspaces
-- `hub/vault`: top-level dashboard vault for all projects
-- `projects//data/db`: source-of-truth SQLite file for that project
-- `projects//data/json`: per-project snapshots for debugging or external integrations
-- `projects//data/backups`: copy-based backup targets for that project
-- `projects//data/exports`: markdown/json export bundles
-- `projects//.context`: the minimum viable continuity package every tool should read first
-- `projects//vault`: the human-facing project brain in Markdown
-- `projects//sessions`: durable session folders for recovery and handoff
-- `projects//logs`: project-local logs
-- `tests`: lightweight regression coverage for state, sync, registry, and recovery behavior
-- `tools`: copy-paste onboarding instructions for AI assistants
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
deleted file mode 100644
index 2090152..0000000
--- a/docs/INSTALLATION.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Installation Guide
-
-## Recommended install location
-
-Place the repository at:
-
-```text
-C:\obsmcp
-```
-
-This keeps batch scripts, Task Scheduler paths, and human troubleshooting straightforward.
-
-## Prerequisites
-
-- Windows
-- Python `3.11+`
-- PowerShell or Command Prompt
-- Obsidian installed locally if you want the live vault workflow
-
-## Install steps
-
-From the project root:
-
-```bat
-cd /d C:\obsmcp
-bootstrap_obsmcp.bat
-```
-
-This will:
-
-- create `.venv`
-- upgrade `pip`
-- install `fastapi` and `uvicorn`
-
-## Start locally
-
-```bat
-start_obsmcp.bat
-```
-
-This launches `obsmcp` in the background on:
-
-```text
-http://127.0.0.1:9300
-```
-
-## Stop locally
-
-```bat
-stop_obsmcp.bat
-```
-
-## Verify local health
-
-```bat
-curl http://127.0.0.1:9300/healthz
-netstat -ano | findstr :9300
-ctx.bat project list
-```
-
-## Register a project
-
-The server is installed once, but each repo gets its own centralized workspace under `projects//`.
-
-Register a repo:
-
-```bat
-ctx.bat project register --repo D:\Work\MyApp --name "My App"
-```
-
-Inspect the workspace:
-
-```bat
-ctx.bat project paths --repo D:\Work\MyApp
-```
-
-If the repo already has older repo-local `.context` or `obsidian\vault` content, migrate it:
-
-```bat
-ctx.bat project migrate --repo D:\Work\MyApp
-```
-
-## Optional security hardening
-
-By default, `obsmcp` binds to `127.0.0.1`, which is the safest local-first default. If you want an extra local token requirement for HTTP requests, set:
-
-```bat
-set OBSMCP_API_TOKEN=your-local-token
-```
-
-Then pass `Authorization: Bearer your-local-token` to non-health HTTP requests.
diff --git a/docs/NEXT_COMMIT_PLAN.md b/docs/NEXT_COMMIT_PLAN.md
deleted file mode 100644
index 62c813b..0000000
--- a/docs/NEXT_COMMIT_PLAN.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Next Commit Plan
-
-This document captures the most useful next commit after the public repo polish pass.
-
-## Recommended next commit
-
-Recommended primary follow-up:
-
-`VS Code startup integration + output-token strategy surfacing`
-
-Why this is the best next move:
-
-- the new workflow-safety features are already implemented server-side
-- the highest leverage now is getting clients to actually use them by default
-- this improves the real day-one user experience more than adding another backend feature
-
-## Track A: VS Code integration improvements
-
-Goal:
-
-- make the VS Code / Claude Code / Codex startup flow safer by default
-
-Suggested work:
-
-1. call `resolve_active_project` before continuity-sensitive reads
-2. call `get_startup_preflight` on session startup
-3. call `get_resume_board` when resuming or reopening a project
-4. infer and pass:
- - `session_label`
- - `workstream_key`
- - `client_name`
- - `model_name`
-5. default to `resume_strategy=new` when the new prompt is clearly unrelated to the prior workstream
-
-Expected result:
-
-- fewer accidental resumes
-- clearer session naming
-- better first-run trust in `obsmcp`
-
-## Track B: Output-token strategy improvements
-
-Goal:
-
-- make output-token optimization easier to understand and adopt
-
-Suggested work:
-
-1. expose a compact "recommended output mode" helper for clients
-2. add more docs/examples for:
- - `off`
- - `prompt_only`
- - `gateway_enforced`
-3. surface token savings in a more visible project dashboard or fast-path response
-4. add task-type presets for:
- - review
- - docs
- - debugging
- - architecture
-
-Expected result:
-
-- easier rollout of output-token reduction
-- clearer understanding of where token savings really happen
-
-## Recommended commit message
-
-```text
-Improve VS Code startup flow and expose output-token strategy defaults
-```
-
-## Files likely involved
-
-- `server/service.py`
-- `cli/main.py`
-- `docs/USAGE.md`
-- `docs/ARCHITECTURE.md`
-- `config/obsmcp.json`
-- IDE/client integration config files
diff --git a/docs/OBSIDIAN.md b/docs/OBSIDIAN.md
deleted file mode 100644
index 9bc4495..0000000
--- a/docs/OBSIDIAN.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Obsidian Integration
-
-## Integration mode
-
-The safe default is filesystem-based vault integration. `obsmcp` does not require the Obsidian Local REST API.
-
-Why this is the default:
-
-- fewer moving parts
-- no plugin dependency for the core path
-- safe local writes using generated machine-owned files
-- easier recovery and debugging
-
-## Centralized vault model
-
-`obsmcp` uses two vault layers:
-
-- project vaults under `projects//vault`
-- a central hub vault under `hub/vault`
-
-Project vaults are the canonical note home for each repo. The hub vault is a dashboard used to monitor all registered projects without mixing their detailed notes together.
-
-## What lives in structured state vs Obsidian
-
-### Structured state in SQLite
-
-- current task pointer
-- tasks and task metadata
-- blockers
-- decisions
-- work logs
-- handoffs
-- session summaries
-- daily entries
-- agent activity
-
-### Obsidian vault
-
-- generated project brief
-- generated current task note
-- generated status snapshot
-- generated latest handoff note
-- generated decision index
-- generated ADR notes
-- generated latest session summary
-- appended daily note entries
-- generated architecture map
-- generated module summaries
-- generated feature map
-- generated symbol knowledge notes for cached semantic descriptions
-- human-created research, debug, SOP, and architecture notes
-
-## Safe write pattern
-
-`obsmcp` only fully rewrites machine-owned generated notes:
-
-- `Projects/Project Brief.md`
-- `Projects/Current Task.md`
-- `Projects/Status Snapshot.md`
-- `Handoffs/Latest Handoff.md`
-- `Decisions/Decision Log.md`
-- generated `Decisions/ADR-xxxx.md`
-- `Sessions/Latest Session Summary.md`
-- `Research/Architecture Map.md`
-- `Research/Module Summaries.md`
-- `Research/Feature Map.md`
-- generated `Research/Symbol Knowledge/*.md`
-
-It does not need to overwrite user-authored research or debug notes.
-
-## Project vault structure
-
-```text
-projects//vault/
- Projects/
- Handoffs/
- Decisions/
- Daily/
- Research/
- Symbol Knowledge/
- Debug/
- Sessions/
-```
-
-## Hub vault structure
-
-```text
-hub/vault/
- Projects Overview.md
- Active Projects.md
-```
-
-## Daily note strategy
-
-`ctx.bat --project D:\Work\MyApp note "message"` writes an entry to structured state and syncs it into `projects//vault/Daily/YYYY-MM-DD.md`.
-
-## Optional future extension
-
-If you later want deeper Obsidian automation, you can add the Local REST API as an optional integration layer, but it should stay optional. The filesystem path is the safer baseline.
diff --git a/docs/OPUSMAX_TOOL_AUDIT.md b/docs/OPUSMAX_TOOL_AUDIT.md
deleted file mode 100644
index 225d85c..0000000
--- a/docs/OPUSMAX_TOOL_AUDIT.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# OpusMax Tool Audit
-
-This note captures the verified dependency points before the `OpusMax` MCP server is removed from Claude and replaced with `obsmcp`-backed tool calls.
-
-## Verified current state
-
-1. Claude currently has two MCP servers at the user level:
- - `OpusMax`
- - `obsmcp`
-
-2. Claude still routes its core model API traffic through OpusMax from the user-level Claude settings file:
- - `~/.claude/settings.json`
- - `ANTHROPIC_BASE_URL=https://api.opusmax.pro`
-
-3. `obsmcp` already depends on OpusMax for semantic description generation:
- - [server/llm_client.py](../server/llm_client.py)
-
-4. Before this implementation pass, `obsmcp` did not expose first-class MCP tools for:
- - web search
- - image understanding
-
-## Implemented in phases 2-5
-
-1. Added an internal OpusMax provider abstraction:
- - [server/opusmax_provider.py](../server/opusmax_provider.py)
-
-2. Kept semantic descriptions working through the provider abstraction:
- - [server/llm_client.py](../server/llm_client.py)
-
-3. Added `obsmcp` service methods and MCP tool exposure for:
- - `web_search`
- - `understand_image`
- - [server/service.py](../server/service.py)
-
-4. Added tests for:
- - provider adapters
- - service-level tool exposure and provider-usage logging
- - [tests/test_opusmax_provider.py](../tests/test_opusmax_provider.py)
- - [tests/test_service.py](../tests/test_service.py)
-
-## Cutover Status: COMPLETE (2026-04-13)
-
-1. ✅ Direct `OpusMax` MCP server entry was already absent from `~/.claude.json`
-2. ✅ obsmcp is the sole MCP server at `http://127.0.0.1:9300/mcp`
-3. ✅ web_search and understand_image tools work through obsmcp
-4. ✅ Token usage tracking recorded for both operations
-
-## Remaining Direct Dependencies
-
-1. Claude's core API routing in `~/.claude/settings.json` still uses:
- - `ANTHROPIC_BASE_URL=https://api.opusmax.pro`
- - `ANTHROPIC_AUTH_TOKEN=sk-ant-opm-...` (OpusMax API key)
-
- This is **intentional** - keeps OpusMax as the API gateway for all Claude API calls.
-
-2. obsmcp's semantic descriptions in `server/llm_client.py` still call OpusMax API directly via `OpusMaxTextProvider` for LLM-powered code descriptions.
-
-## Verified Working (2026-04-13)
-
-- web_search: 4 recorded events, 10 results per query, ~2.5s latency
-- understand_image: 1 recorded event, accurate image analysis, ~9s latency
-- Both tools track provider usage metrics via get_token_usage_stats
-- HTTP(S) URL images may return error 2013 - use base64 or file paths instead
\ No newline at end of file
diff --git a/docs/STARTUP.md b/docs/STARTUP.md
deleted file mode 100644
index 4d86551..0000000
--- a/docs/STARTUP.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Startup and Reboot Recovery
-
-## Manual start
-
-```bat
-start_obsmcp.bat
-```
-
-If the detached launcher is blocked by local Windows policy or shell behavior, use this fallback to keep `obsmcp` alive in its own console window:
-
-```powershell
-Start-Process -FilePath cmd.exe -ArgumentList '/k','cd /d D:\Projects\obsmcp && .venv\Scripts\python.exe -m server.main'
-```
-
-## Manual stop
-
-```bat
-stop_obsmcp.bat
-```
-
-## Automatic startup on login
-
-Default recommended method:
-
-```bat
-install_task_scheduler.bat
-```
-
-This creates a Windows Task Scheduler task named `obsmcp` that runs on logon with a short delay.
-
-## Remove automatic startup
-
-```bat
-uninstall_task_scheduler.bat
-```
-
-## Why Task Scheduler is the default
-
-- more reliable than the Startup folder for background processes
-- easier to inspect and repair
-- easy to disable without editing files
-- works cleanly with a batch launcher
-
-## Reboot recovery flow
-
-After reboot:
-
-1. Windows logon triggers the `obsmcp` scheduled task
-2. `start_obsmcp.bat` launches the detached Python server
-3. per-project workspaces under `projects//` remain intact
-4. project `.context`, session folders, and vault files are still available
-5. any new tool can resume from the project workspace or the hub vault
-
-## Manual recovery if startup is not enabled
-
-```bat
-start_obsmcp.bat
-ctx.bat project list
-```
-
-If `start_obsmcp.bat` exits but port `9300` does not stay open, use the dedicated console-window fallback above and then verify with `curl http://127.0.0.1:9300/healthz`.
-
-## Logs
-
-Check:
-
-- `logs/startup.log`
-- `logs/obsmcp.log`
-- `logs/obsmcp-error.log`
-- `projects//logs`
diff --git a/docs/TESTING.md b/docs/TESTING.md
deleted file mode 100644
index 4572c06..0000000
--- a/docs/TESTING.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# Testing Guide
-
-## Automated tests
-
-Run:
-
-```bat
-.venv\Scripts\python.exe -m unittest discover -s tests -v
-```
-
-## Manual health test
-
-```bat
-curl http://127.0.0.1:9300/healthz
-```
-
-Expected:
-
-- JSON response
-- `port` is `9300`
-- `db_exists` is `true`
-
-## Project registration test
-
-```bat
-ctx.bat project register --repo D:\Work\MyApp --name "My App"
-ctx.bat project paths --repo D:\Work\MyApp
-ctx.bat hub sync
-```
-
-Expected:
-
-- a workspace appears under `projects//`
-- a repo bridge file appears at `D:\Work\MyApp\.obsmcp-link.json`
-- the hub vault is refreshed
-
-## CLI test
-
-```bat
-ctx.bat --project D:\Work\MyApp task create "Test continuity" --description "Verify ctx write path"
-ctx.bat --project D:\Work\MyApp session open --actor tester --client cli --model local --project-path D:\Work\MyApp --initial-request "Verify continuity" --goal "Exercise session tracking"
-ctx.bat --project D:\Work\MyApp status
-ctx.bat --project D:\Work\MyApp current
-ctx.bat --project D:\Work\MyApp audit
-ctx.bat --project D:\Work\MyApp resume
-```
-
-## Obsidian sync test
-
-1. Run `ctx.bat --project D:\Work\MyApp note "Testing daily note sync"`
-2. Open `projects//vault/Daily/.md`
-3. Confirm the entry appears
-
-## Semantic knowledge test
-
-```bat
-ctx.bat --project D:\Work\MyApp atlas generate
-ctx.bat --project D:\Work\MyApp describe module server\service.py
-ctx.bat --project D:\Work\MyApp describe symbol generate_resume_packet --module server\service.py --type function
-ctx.bat --project D:\Work\MyApp knowledge search "resume packet"
-```
-
-Expected:
-
-- semantic descriptions are returned
-- `projects//vault/Research/Architecture Map.md` exists
-- `projects//vault/Research/Module Summaries.md` exists
-- `projects//vault/Research/Feature Map.md` exists
-- `projects//vault/Research/Symbol Knowledge/` contains generated notes
-
-## Tiered context and delta test
-
-```bat
-ctx.bat --project D:\Work\MyApp compact --profile fast --max-tokens 1200
-ctx.bat --project D:\Work\MyApp compact --profile balanced --max-tokens 2500
-ctx.bat --project D:\Work\MyApp compact --profile deep --max-tokens 4500
-ctx.bat --project D:\Work\MyApp delta
-```
-
-Expected:
-
-- the compact commands return progressively richer context variants
-- repeated calls on unchanged state should return quickly from cache
-- `delta` shows only changes since the latest handoff/session reference
-- `projects//.context/HOT_CONTEXT.md` exists
-- `projects//.context/BALANCED_CONTEXT.md` exists
-- `projects//.context/DEEP_CONTEXT.md` exists
-- `projects//.context/DELTA_CONTEXT.md` exists
-
-## Background scan job test
-
-```bat
-ctx.bat --project D:\Work\MyApp atlas generate --background
-ctx.bat --project D:\Work\MyApp atlas jobs
-ctx.bat --project D:\Work\MyApp atlas wait SCAN-REPLACE-ME --wait-seconds 60
-```
-
-Expected:
-
-- the first command returns a `SCAN-...` job ID quickly
-- `atlas jobs` shows the job as `queued`, `running`, or `completed`
-- `atlas wait` eventually returns `completed`
-- after completion, `ctx.bat --project D:\Work\MyApp atlas status` shows the updated atlas metadata
-
-## Reboot persistence test
-
-1. Run `install_task_scheduler.bat`
-2. Reboot or sign out and back in
-3. Run `curl http://127.0.0.1:9300/healthz`
-4. Confirm the server is up without manual start
-
-## Multi-tool continuity test
-
-1. Register the repo and inspect the workspace paths
-2. Create a task and set it current
-3. Open a session
-4. Log work and create a handoff
-5. Close the session
-6. Open another tool that can read files
-7. Confirm it can continue from `projects//.context`
-
-## Cross-model handoff test
-
-1. In tool A, open a session and create a task
-2. In tool A, log work and run `ctx.bat --project D:\Work\MyApp handoff ...`
-3. In tool A, close the session
-4. In tool B, read `projects//.context/HANDOFF.md` and `projects//.context/CURRENT_TASK.json`
-5. Continue work without re-explaining the project
-6. In tool B, append more work, create the next handoff, and close the session
-
-## Interrupted-session recovery test
-
-1. Open a session and log at least one work entry
-2. Do not close the session cleanly
-3. Run `ctx.bat --project D:\Work\MyApp recover --session SESSION-REPLACE-ME --actor claude-recovery`
-4. Confirm an emergency handoff and resume packet are written into the project workspace
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
deleted file mode 100644
index 310b884..0000000
--- a/docs/TROUBLESHOOTING.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# Troubleshooting Guide
-
-## Port `9300` already in use
-
-Check:
-
-```bat
-netstat -ano | findstr :9300
-```
-
-If another process owns the port, stop it or change the port only if you are willing to break the fixed `obsmcp` requirement. The safe default is to keep `9300` reserved for `obsmcp`.
-
-## Obsidian not running
-
-That is fine. The filesystem vault still syncs. Open Obsidian later and point it at either:
-
-- `projects//vault` for a single project
-- `hub/vault` for the central dashboard
-
-## Obsidian Local REST API not available
-
-That is also fine. `obsmcp` does not depend on it.
-
-## Startup task fails
-
-Check:
-
-- Task Scheduler history for the `obsmcp` task
-- `logs/startup.log`
-- whether the install path moved after the task was created
-
-Reinstall with:
-
-```bat
-uninstall_task_scheduler.bat
-install_task_scheduler.bat
-```
-
-## Batch file path issues
-
-Keep the project in a stable location, preferably `C:\obsmcp`. If you move it, reinstall the Task Scheduler task.
-
-## Permission issues
-
-Make sure your user can write to:
-
-- `projects\\data\db`
-- `logs`
-- `projects\\.context`
-- `projects\\vault`
-- `hub\vault`
-
-## Sync issues or stale `.context` files
-
-Force a sync:
-
-```bat
-ctx.bat --project D:\Work\MyApp sync
-```
-
-If files still look stale, inspect:
-
-- `projects//data/db/obsmcp.sqlite3`
-- `logs/obsmcp-error.log`
-
-## Corrupted local DB
-
-Recovery approach:
-
-1. Stop `obsmcp`
-2. Copy the newest file from `projects//data/backups`
-3. Replace `projects//data/db/obsmcp.sqlite3`
-4. Start `obsmcp`
-5. Run `ctx.bat --project D:\Work\MyApp sync`
-
-## Resume or handoff is missing after an interrupted session
-
-Use the recovery flow:
-
-```bat
-ctx.bat --project D:\Work\MyApp audit
-ctx.bat --project D:\Work\MyApp resume
-ctx.bat --project D:\Work\MyApp recover --session SESSION-REPLACE-ME --actor claude-recovery
-```
-
-This generates a best-effort emergency handoff and a fresh resume packet from the persisted state.
-
-## Server starts but health check fails
-
-Inspect:
-
-- `logs/startup.log`
-- `logs/obsmcp.log`
-- `logs/obsmcp-error.log`
-
-Then run:
-
-```bat
-stop_obsmcp.bat
-start_obsmcp.bat
-```
-
-If the hidden or detached launcher still does not keep the server alive, start `obsmcp` in its own console window:
-
-```powershell
-Start-Process -FilePath cmd.exe -ArgumentList '/k','cd /d D:\Projects\obsmcp && .venv\Scripts\python.exe -m server.main'
-```
-
-That path is less elegant, but it is a reliable Windows fallback because the server stays attached to its own process window instead of the calling shell.
diff --git a/docs/USAGE.md b/docs/USAGE.md
deleted file mode 100644
index 73274e1..0000000
--- a/docs/USAGE.md
+++ /dev/null
@@ -1,368 +0,0 @@
-# Usage Guide
-
-## Daily operator flow
-
-1. Start `obsmcp`
-2. Register the project repo if it has not been seen before
-3. Open or resolve the centralized project workspace
-4. Open a tracked session with `ctx` or MCP `session_open`
-5. Create or select the current task with `ctx`
-6. Use semantic lookups for targeted understanding before rereading large files
-7. Log work as you go
-8. Heartbeat long sessions
-9. Record blockers and decisions when they happen
-10. Create a handoff before switching models or stopping
-11. Close the session and let the next tool read the project workspace `.context`, session folder, or query MCP
-
-## Automatic vs manual continuity behavior
-
-What is automatic now:
-
-- opening the same repo in another IDE or plugin generally resolves to the same `obsmcp` workspace
-- `obsmcp` can infer project routing from `project_path`, `repo_path`, repo bridge files, and absolute file paths passed in tool arguments
-- `obsmcp` can also infer project routing from `session_id`, `task_id`, `cwd`, and the nearest repo root when the client starts inside a project
-- plugins can now call `resolve_active_project` with IDE metadata such as `cwd`, `active_file`, `workspace_folders`, `open_files`, or `env_variables` before their first write
-- `session_open` defaults to `resume_strategy=auto`, so reopening the same actor/client/project usually resumes the recent open session instead of creating another one
-- `session_open` now derives a readable `session_label` and stable `workstream_key`, or accepts them directly from the client
-- `session_open` now normalizes client/model identity values and blocks unsafe auto-resume when the incoming request conflicts with the candidate session
-- `session_close` now auto-enriches handoffs with relevant files, task state, and recommended semantic lookups even when only a short summary is provided
-
-What is still client-dependent:
-
-- whether the tool actually performs MCP write-back during work
-- whether the tool updates relevant files or handoffs without being prompted
-- whether the client includes a project hint on the first continuity-sensitive MCP call when it is not running inside the repo
-
-When a client does not write reliably, use `ctx.bat` as the fallback bridge.
-When a client does not provide any usable project hint, `obsmcp` now rejects continuity-sensitive MCP calls with a clear error instead of silently writing into the default project.
-
-## Recommended startup guardrail flow
-
-Use this sequence before meaningful work:
-
-1. `ctx.bat --project D:\Work\myapp status`
-2. `ctx.bat --project D:\Work\myapp preflight --actor codex --initial-request "..." --goal "..."`
-3. `ctx.bat --project D:\Work\myapp resume-board`
-4. Create or select the task
-5. Open the session with a label and workstream when possible
-
-This catches the most common continuity problems before the model starts:
-
-- stale or abandoned sessions
-- current task already marked done
-- latest handoff belonging to another task
-- substantial session startup with no task attached
-- unsafe auto-resume candidates
-
-## Project workspace examples
-
-Register a repo:
-
-```bat
-ctx.bat project register --repo D:\Work\myapp --name "My App" --tags python,fastapi
-```
-
-Inspect the centralized workspace paths:
-
-```bat
-ctx.bat project paths --repo D:\Work\myapp
-```
-
-Migrate older repo-local `.context` / `obsidian\vault` content:
-
-```bat
-ctx.bat project migrate --repo D:\Work\myapp
-```
-
-Generate a resume packet for the next model:
-
-```bat
-ctx.bat --project D:\Work\myapp resume
-```
-
-Generate cached tiered context profiles:
-
-```bat
-ctx.bat --project D:\Work\myapp compact --profile fast --max-tokens 1200
-ctx.bat --project D:\Work\myapp compact --profile balanced --max-tokens 2500
-ctx.bat --project D:\Work\myapp compact --profile deep --max-tokens 4500 --daily-notes
-```
-
-Generate a delta view since the latest handoff or a specific session:
-
-```bat
-ctx.bat --project D:\Work\myapp delta
-ctx.bat --project D:\Work\myapp delta --session SESSION-REPLACE-ME
-ctx.bat --project D:\Work\myapp delta --handoff 42
-```
-
-Queue a background Code Atlas scan and wait for it:
-
-```bat
-ctx.bat --project D:\Work\myapp atlas generate --background
-ctx.bat --project D:\Work\myapp atlas jobs
-ctx.bat --project D:\Work\myapp atlas wait SCAN-REPLACE-ME --wait-seconds 60
-```
-
-Recover an interrupted session:
-
-```bat
-ctx.bat --project D:\Work\myapp recover --session SESSION-REPLACE-ME --actor claude-recovery
-```
-
-Refresh the central hub vault:
-
-```bat
-ctx.bat hub sync
-```
-
-## Common CLI examples
-
-Create a task:
-
-```bat
-ctx.bat --project D:\Work\myapp task create "Implement auth cache" --description "Add a local token cache for provider adapters" --files server/main.py,server/service.py
-```
-
-Set current task:
-
-```bat
-ctx.bat --project D:\Work\myapp start TASK-12345678-implement-auth
-```
-
-Log work:
-
-```bat
-ctx.bat --project D:\Work\myapp log "Added cache invalidation path" --task TASK-12345678-implement-auth --files server/service.py,tests/test_service.py
-```
-
-Describe a module:
-
-```bat
-ctx.bat --project D:\Work\myapp describe module server\service.py
-```
-
-Describe a symbol:
-
-```bat
-ctx.bat --project D:\Work\myapp describe symbol generate_resume_packet --module server\service.py --type function
-```
-
-Search semantic knowledge:
-
-```bat
-ctx.bat --project D:\Work\myapp knowledge search "resume packet"
-```
-
-Open a session:
-
-```bat
-ctx.bat session open --actor codex --client vscode-codex --model gpt-5 --project-path D:\Work\myapp --initial-request "Understand the codebase and continue the current task" --goal "Preserve continuity for the next model"
-```
-
-Open a named session inside a stable workstream:
-
-```bat
-ctx.bat session open --actor claude-code --client claude-code-vscode --model claude-opus-4-6 --project-path D:\Work\myapp --task TASK-12345678-docs --label "Managing Director Email" --workstream managing-director-email --initial-request "This task is for the managing director's email." --goal "Draft and finalize the message"
-```
-
-Force a brand-new session instead of auto-resuming:
-
-```bat
-ctx.bat session open --actor codex --client vscode-codex --model gpt-5 --project-path D:\Work\myapp --resume-strategy new
-```
-
-Run startup safety checks before opening or resuming:
-
-```bat
-ctx.bat --project D:\Work\myapp preflight --actor codex --initial-request "Create the ERP documentation" --goal "Write the beginner guide"
-```
-
-Show the startup resume board:
-
-```bat
-ctx.bat --project D:\Work\myapp resume-board
-```
-
-Check client/server compatibility:
-
-```bat
-ctx.bat compat --client claude-code --model opus-4.6 --client-api-version 2026.04.14 --client-tool-schema-version 2
-```
-
-Resume a specific previous session directly:
-
-```bat
-ctx.bat session open --actor codex --client vscode-codex --model gpt-5 --project-path D:\Work\myapp --resume-strategy resume --resume-session-id SESSION-REPLACE-ME
-```
-
-Heartbeat a session:
-
-```bat
-ctx.bat session heartbeat SESSION-REPLACE-ME --actor codex --note "Still tracing the auth path" --files server/service.py,server/store.py --create-work-log
-```
-
-Log a blocker:
-
-```bat
-curl -X POST http://127.0.0.1:9300/mcp ^
- -H "Content-Type: application/json" ^
- -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"log_blocker\",\"arguments\":{\"title\":\"Missing API contract\",\"description\":\"Need final response shape from upstream client\",\"task_id\":\"TASK-12345678-implement-auth\",\"actor\":\"codex\"}}}"
-```
-
-Create a handoff:
-
-```bat
-ctx.bat handoff --summary "Cache path is in place; tests still need edge-case coverage." --next-steps "Add concurrency tests; verify stale token eviction." --open-questions "Should cache be shared across workspaces?" --to "claude-code"
-```
-
-Close the session:
-
-```bat
-ctx.bat session close SESSION-REPLACE-ME --actor codex --summary "Completed cache implementation and left handoff." --handoff-summary "Cache logic is in place and synced."
-```
-
-Audit continuity:
-
-```bat
-ctx.bat --project D:\Work\myapp audit
-```
-
-Verify a reset actually left the workspace clean:
-
-```bat
-curl -X POST http://127.0.0.1:9300/mcp ^
- -H "Content-Type: application/json" ^
- -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"reset_project\",\"arguments\":{\"scope\":\"full\",\"actor\":\"codex\",\"project_path\":\"D:\\Work\\myapp\"}}}"
-```
-
-The `reset_project` response now includes `post_reset_snapshot` so you can immediately verify:
-
-- `current_task`
-- `active_tasks`
-- `latest_handoff`
-- `recent_work`
-- `active_sessions`
-
-## MCP usage pattern
-
-The recommended read order for any MCP-capable client is:
-
-1. `get_project_status_snapshot`
-2. `get_current_task`
-3. `get_latest_handoff`
-4. `get_blockers`
-5. `generate_context_profile(profile="fast"|"balanced")`
-6. `generate_delta_context`
-7. `describe_module` / `describe_symbol` / `describe_feature` when you need targeted semantic understanding
-
-Recommended profile usage:
-
-- `fast`: lowest-latency startup, ideal for small edits or quick continuation
-- `balanced`: default day-to-day context for normal coding sessions
-- `deep`: more history, dependencies, and notes for debugging or architecture work
-- `handoff`: same continuity surface, but biased toward transition quality
-- `recovery`: same continuity surface, but includes audit-heavy recovery cues
-
-## Output response policy
-
-The `output_compression` block in `config/obsmcp.json` controls output-token behavior without changing continuity or context generation.
-
-Modes:
-
-- `off`: disable concise-response policy
-- `prompt_only`: append a concise-response contract where `obsmcp` emits prompt text
-- `gateway_enforced`: apply a stricter response contract where `obsmcp` directly controls model generation
-
-Current generation surfaces:
-
-- `generate_startup_prompt_template`
-- LLM-backed semantic descriptions such as `describe_module`, `describe_symbol`, and `describe_feature` when they use the OpusMax text provider
-
-Inspect the effective policy for a task or operation:
-
-```bat
-curl -X POST http://127.0.0.1:9300/mcp ^
- -H "Content-Type: application/json" ^
- -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_output_response_policy\",\"arguments\":{\"operation_kind\":\"review\",\"project_path\":\"D:\\Work\\myapp\"}}}"
-```
-
-Rollout order:
-
-1. start with `prompt_only`
-2. validate output quality for your real tasks
-3. move selected tasks to `gateway_enforced`
-4. keep dangerous/security-sensitive flows on safety bypass or `off`
-
-For large repos, prefer this scan workflow:
-
-1. call `scan_codebase`
-2. if the response status is `queued` or `running`, poll `get_scan_job`
-3. optionally call `wait_for_scan_job` when the client supports longer polling windows
-4. continue with `get_code_atlas_status`, `describe_module`, `describe_symbol`, or `search_code_knowledge` after the scan completes
-
-The recommended write pattern is:
-
-1. `session_open`
-2. `log_work`
-3. `log_decision` or `log_blocker` when needed
-4. `session_heartbeat` during long work
-5. `create_handoff` before exit or model switch
-6. `session_close`
-
-Recommended first-call pattern for IDE clients and plugins:
-
-1. call `resolve_active_project` with `project_path` if known
-2. otherwise pass `cwd`, `active_file`, `workspace_folders`, `open_files`, or IDE env hints
-3. if the response is resolved, reuse the returned `project_path` for later calls
-4. if the response says `requires_registration`, retry with `auto_register=true` or register explicitly
-5. if the response is unresolved, ask the user for the project or wait until the client has a usable IDE hint
-
-Notes:
-
-- `session_open` now prefers reusing a recent matching open session for the same actor/client/project
-- explicit `session_label` and `workstream_key` are recommended whenever the client can infer a named workstream from the user's prompt
-- `get_startup_preflight` and `get_resume_board` are the preferred startup reads for IDE clients deciding between resume vs new work
-- if a substantial request starts without `task_id`, `obsmcp` warns instead of silently treating the session as untracked work
-- `session_close` will generate a richer handoff automatically if you do not provide every handoff field manually
-- `bootstrap_default_project_on_startup=false` is now the recommended default, so the server starts empty and waits for an explicit project resolution instead of recreating a default project workspace during boot
-- if a client only passes absolute file paths, `obsmcp` can often still route the write to the correct project workspace
-- if the client is inside the repo, `session_open` and other continuity tools can route from `cwd` automatically
-- if there is no reliable project signal at all, continuity-sensitive MCP calls fail and ask the client to pass `project_path` or another project hint first
-- `sync_context_files` now also refreshes `.context/HOT_CONTEXT.md`, `.context/BALANCED_CONTEXT.md`, `.context/DEEP_CONTEXT.md`, and `.context/DELTA_CONTEXT.md`
-
-## Multi-tool continuity pattern
-
-### VS Code + Codex
-
-- start with `.context/PROJECT_CONTEXT.md`
-- read `.context/CURRENT_TASK.json`
-- query MCP if direct integration is available
-- update state with `ctx.bat` or MCP tools
-
-### VS Code + Claude-style agent
-
-- open `.context` first
-- read `AGENTS.md` and `CLAUDE.md`
-- use `ctx.bat` when MCP is not directly available
-- create a handoff before ending the session
-
-### Warp.dev
-
-- use `ctx.bat status`, `ctx.bat current`, and `ctx.bat log`
-- paste the output of `ctx.bat status`, `generate_compact_context`, or `ctx.bat knowledge search ...` into the chat when needed
-
-### Cursor or similar IDE agents
-
-- point the agent to `.context`
-- better: point it to the centralized project workspace `.context`
-- if MCP config is supported, target `http://127.0.0.1:9300/mcp`
-- otherwise use `ctx.bat` plus manual prompt injection
-
-### Manual-only tools
-
-- paste `.context/PROJECT_CONTEXT.md`
-- paste `.context/HANDOFF.md`
-- paste `.context/CURRENT_TASK.json`
-- paste `master prompt.md`
-- instruct the model to preserve continuity and write a new handoff before stopping
diff --git a/docs/assets/obsmcp-overview.svg b/docs/assets/obsmcp-overview.svg
deleted file mode 100644
index bd91bd0..0000000
--- a/docs/assets/obsmcp-overview.svg
+++ /dev/null
@@ -1,42 +0,0 @@
-
- obsmcp overview
- Overview graphic showing obsmcp as a continuity, context, and code understanding layer for AI development.
-
-
-
-
-
-
-
-
-
-
-
-
- obsmcp
- Project continuity + task memory + semantic code understanding for MCP-native AI workflows
-
-
-
- Continuity Layer
- Tasks, handoffs, blockers,
- sessions, resume safety,
- startup preflight, resume board.
-
-
- Context Engine
- Fast, balanced, deep, delta,
- retrieval context, progressive
- loading, token-aware startup.
-
-
- Code Intelligence
- Code Atlas, semantic search,
- module/symbol descriptions,
- related-symbol expansion.
-
-
- Why developers use it
- One project-scoped memory layer for Codex, Claude Code, Cursor, Warp, and custom MCP clients.
- Safer restarts, less prompt replay, better token discipline, and auditable AI work over time.
-
diff --git a/docs/assets/obsmcp-roadmap.svg b/docs/assets/obsmcp-roadmap.svg
deleted file mode 100644
index 944c657..0000000
--- a/docs/assets/obsmcp-roadmap.svg
+++ /dev/null
@@ -1,25 +0,0 @@
-
- obsmcp next roadmap
- Roadmap card showing recommended next improvements for VS Code startup integration and output-token strategy.
-
- Next Recommended Commit
- Improve VS Code startup integration and make output-token strategy easier to adopt.
-
-
- Track A: VS Code Integration
- 1. Resolve project before continuity reads
- 2. Run startup preflight automatically
- 3. Show resume board before resuming work
- 4. Pass session labels + workstreams explicitly
- 5. Prefer new sessions for unrelated prompts
- Outcome: safer startup and cleaner session history
-
-
- Track B: Output Strategy
- 1. Expose recommended output mode helpers
- 2. Add clearer task-type presets
- 3. Show token savings in startup dashboards
- 4. Expand docs for off / prompt_only / enforced
- 5. Keep continuity and output policy clearly separate
- Outcome: easier rollout of real output-token savings
-
diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs
new file mode 100644
index 0000000..40dc4da
--- /dev/null
+++ b/frontend/.eslintrc.cjs
@@ -0,0 +1,15 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2022: true },
+ parser: '@typescript-eslint/parser',
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module', ecmaFeatures: { jsx: true } },
+ plugins: ['@typescript-eslint', 'react-hooks', 'react-refresh'],
+ extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
+ rules: {
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+ ignorePatterns: ['dist', 'node_modules', '.eslintrc.cjs'],
+};
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..acb8340
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+.vite/
+*.tsbuildinfo
+
+# tsc-emitted files (noEmit is set but keep these ignored defensively)
+src/**/*.js
+src/**/*.d.ts
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..5b01aa1
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OBSMCP Dashboard
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..9244c12
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,5025 @@
+{
+ "name": "obsmcp-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "obsmcp-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.51.11",
+ "lucide-react": "^0.414.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.25.1",
+ "reactflow": "^11.11.4",
+ "recharts": "^2.12.7",
+ "zustand": "^4.5.4"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.17.0",
+ "@typescript-eslint/parser": "^7.17.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.9",
+ "postcss": "^8.4.40",
+ "tailwindcss": "^3.4.7",
+ "typescript": "^5.5.4",
+ "vite": "^5.3.5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@reactflow/background": {
+ "version": "11.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
+ "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/controls": {
+ "version": "11.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
+ "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/core": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
+ "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3": "^7.4.0",
+ "@types/d3-drag": "^3.0.1",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/minimap": {
+ "version": "11.7.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
+ "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-resizer": {
+ "version": "2.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
+ "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.4",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-toolbar": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
+ "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz",
+ "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz",
+ "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.99.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
+ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/type-utils": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^7.0.0",
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
+ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
+ "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
+ "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
+ "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
+ "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
+ "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
+ "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
+ "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.1",
+ "@humanwhocodes/config-array": "^0.13.0",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
+ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.414.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.414.0.tgz",
+ "integrity": "sha512-Krr/MHg9AWoJc52qx8hyJ64X9++JNfS1wjaJviLM1EP/68VNB7Tv0VMldLCB1aUe6Ka9QxURPhQm/eB6cqOM3A==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/reactflow": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
+ "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/background": "11.3.14",
+ "@reactflow/controls": "11.2.14",
+ "@reactflow/core": "11.11.4",
+ "@reactflow/minimap": "11.7.14",
+ "@reactflow/node-resizer": "2.2.14",
+ "@reactflow/node-toolbar": "1.3.14"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
+ "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..d814097
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "obsmcp-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint . --ext .ts,.tsx",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.51.11",
+ "lucide-react": "^0.414.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.25.1",
+ "reactflow": "^11.11.4",
+ "recharts": "^2.12.7",
+ "zustand": "^4.5.4"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.17.0",
+ "@typescript-eslint/parser": "^7.17.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.9",
+ "postcss": "^8.4.40",
+ "tailwindcss": "^3.4.7",
+ "typescript": "^5.5.4",
+ "vite": "^5.3.5"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..9915a4a
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,44 @@
+import { useEffect } from 'react';
+import { Route, Routes } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
+
+import Layout from './components/Layout';
+import Dashboard from './pages/Dashboard';
+import Tasks from './pages/Tasks';
+import Sessions from './pages/Sessions';
+import Blockers from './pages/Blockers';
+import Decisions from './pages/Decisions';
+import WorkLogs from './pages/WorkLogs';
+import CodeAtlas from './pages/CodeAtlas';
+import KnowledgeGraph from './pages/KnowledgeGraph';
+import PerformanceLogs from './pages/PerformanceLogs';
+import SettingsPage from './pages/Settings';
+import NotFound from './pages/NotFound';
+import { eventBus } from './events/EventBus';
+
+export default function App(): JSX.Element {
+ const qc = useQueryClient();
+ useEffect(() => {
+ eventBus.attachQueryClient(qc);
+ eventBus.connect();
+ return () => eventBus.disconnect();
+ }, [qc]);
+
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..23c7122
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,63 @@
+const TOKEN_KEY = 'obsmcp:token';
+
+export function getApiToken(): string | null {
+ try {
+ return localStorage.getItem(TOKEN_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function setApiToken(token: string): void {
+ try {
+ localStorage.setItem(TOKEN_KEY, token);
+ } catch {
+ /* noop */
+ }
+}
+
+export function clearApiToken(): void {
+ try {
+ localStorage.removeItem(TOKEN_KEY);
+ } catch {
+ /* noop */
+ }
+}
+
+function authHeaders(): Record {
+ const token = getApiToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
+async function handle(res: Response): Promise {
+ if (!res.ok) {
+ const detail = await res.text();
+ throw new Error(`${res.status} ${res.statusText}: ${detail}`);
+ }
+ if (res.status === 204) return undefined as T;
+ return (await res.json()) as T;
+}
+
+const base = '';
+
+export const api = {
+ get: (path: string) =>
+ fetch(`${base}${path}`, { headers: authHeaders() }).then((r) => handle(r)),
+ post: (path: string, body: unknown) =>
+ fetch(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify(body ?? {}),
+ }).then((r) => handle(r)),
+ put: (path: string, body: unknown) =>
+ fetch(`${base}${path}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ body: JSON.stringify(body ?? {}),
+ }).then((r) => handle(r)),
+ del: (path: string) =>
+ fetch(`${base}${path}`, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ }).then((r) => handle(r)),
+};
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
new file mode 100644
index 0000000..08c7af5
--- /dev/null
+++ b/frontend/src/api/types.ts
@@ -0,0 +1,125 @@
+export interface Task {
+ id: string;
+ project_id: string | null;
+ title: string;
+ description: string | null;
+ status: 'open' | 'in_progress' | 'done' | 'blocked';
+ priority: 'low' | 'medium' | 'high' | 'urgent';
+ tags: string[] | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Session {
+ id: string;
+ project_id: string | null;
+ agent_id: string;
+ started_at: string;
+ ended_at: string | null;
+ duration_seconds: number | null;
+ context: string | null;
+}
+
+export interface Blocker {
+ id: string;
+ project_id: string | null;
+ agent_id: string | null;
+ description: string;
+ severity: 'low' | 'medium' | 'high' | 'critical';
+ status: 'active' | 'resolved';
+ resolved_at: string | null;
+ resolution: string | null;
+ created_at: string;
+}
+
+export interface Decision {
+ id: string;
+ project_id: string | null;
+ agent_id: string | null;
+ decision: string;
+ context: string | null;
+ outcome: string | null;
+ tags: string[] | null;
+ created_at: string;
+}
+
+export interface WorkLog {
+ id: string;
+ project_id: string | null;
+ session_id: string | null;
+ agent_id: string | null;
+ description: string;
+ hours: number | null;
+ tags: string[] | null;
+ created_at: string;
+}
+
+export interface CodeAtlasScan {
+ id: string;
+ project_id: string | null;
+ agent_id: string | null;
+ status: 'pending' | 'running' | 'completed' | 'failed';
+ total_files: number;
+ scanned_files: number;
+ started_at: string;
+ completed_at: string | null;
+ error_message: string | null;
+}
+
+export interface CodeAtlasFile {
+ id: string;
+ scan_id: string;
+ project_id: string | null;
+ file_path: string;
+ language: string | null;
+ functions_count: number;
+ imports: string[];
+ exports: string[];
+ semantic_description: string | null;
+ scanned_at: string;
+}
+
+export interface KnowledgeNode {
+ id: string;
+ project_id: string | null;
+ agent_id: string | null;
+ node_type: string;
+ name: string;
+ description: string | null;
+ metadata: Record;
+ created_at: string;
+}
+
+export interface KnowledgeEdge {
+ id: string;
+ project_id: string | null;
+ from_node_id: string;
+ to_node_id: string;
+ edge_type: string;
+ weight: number;
+ metadata: Record;
+ created_at: string;
+}
+
+export interface PerformanceLog {
+ id: string;
+ project_id: string | null;
+ agent_id: string | null;
+ session_id: string | null;
+ metric_name: string;
+ metric_value: number;
+ unit: string | null;
+ tags: Record;
+ logged_at: string;
+}
+
+export interface Stats {
+ tasks: { total: number; open: number; in_progress: number; blocked: number; done: number };
+ sessions: { total: number; active: number };
+ blockers: { active: number; resolved: number };
+ decisions: number;
+ work_logs: number;
+ nodes: number;
+ edges: number;
+ agents: number;
+}
diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx
new file mode 100644
index 0000000..76b64ea
--- /dev/null
+++ b/frontend/src/components/EmptyState.tsx
@@ -0,0 +1,17 @@
+interface Props {
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+}
+
+export default function EmptyState({ title, description, action }: Props): JSX.Element {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+
+ );
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..80c2211
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -0,0 +1,79 @@
+import { NavLink, Outlet } from 'react-router-dom';
+import {
+ Activity,
+ AlertTriangle,
+ BookOpenText,
+ ClipboardList,
+ Gauge,
+ Layers,
+ LineChart,
+ ListChecks,
+ Network,
+ Settings,
+ Wifi,
+ WifiOff,
+ type LucideIcon,
+} from 'lucide-react';
+import { useConnectionStatus } from '../hooks/useConnectionStatus';
+
+interface NavItem {
+ to: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+const NAV: NavItem[] = [
+ { to: '/', label: 'Dashboard', icon: Gauge },
+ { to: '/tasks', label: 'Tasks', icon: ListChecks },
+ { to: '/sessions', label: 'Sessions', icon: Activity },
+ { to: '/blockers', label: 'Blockers', icon: AlertTriangle },
+ { to: '/decisions', label: 'Decisions', icon: BookOpenText },
+ { to: '/work-logs', label: 'Work Logs', icon: ClipboardList },
+ { to: '/code-atlas', label: 'Code Atlas', icon: Layers },
+ { to: '/graph', label: 'Knowledge Graph', icon: Network },
+ { to: '/logs', label: 'Performance', icon: LineChart },
+ { to: '/settings', label: 'Settings', icon: Settings },
+];
+
+export default function Layout(): JSX.Element {
+ const connected = useConnectionStatus();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx
new file mode 100644
index 0000000..a2d22c6
--- /dev/null
+++ b/frontend/src/components/PageHeader.tsx
@@ -0,0 +1,17 @@
+interface Props {
+ title: string;
+ description?: string;
+ actions?: React.ReactNode;
+}
+
+export default function PageHeader({ title, description, actions }: Props): JSX.Element {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/src/events/EventBus.ts b/frontend/src/events/EventBus.ts
new file mode 100644
index 0000000..03288c3
--- /dev/null
+++ b/frontend/src/events/EventBus.ts
@@ -0,0 +1,160 @@
+import { QueryClient } from '@tanstack/react-query';
+import { getApiToken } from '../api/client';
+
+export type EventType =
+ | 'connected'
+ | 'heartbeat'
+ | 'task_created'
+ | 'task_updated'
+ | 'task_deleted'
+ | 'session_opened'
+ | 'session_closed'
+ | 'session_heartbeat'
+ | 'blocker_logged'
+ | 'blocker_resolved'
+ | 'blocker_deleted'
+ | 'decision_logged'
+ | 'decision_updated'
+ | 'decision_deleted'
+ | 'work_logged'
+ | 'work_log_updated'
+ | 'work_log_deleted'
+ | 'scan_started'
+ | 'scan_progress'
+ | 'scan_completed'
+ | 'node_created'
+ | 'node_updated'
+ | 'node_deleted'
+ | 'nodes_bulk_created'
+ | 'edge_created'
+ | 'edge_deleted'
+ | 'edges_bulk_created'
+ | 'perf_log_received'
+ | 'agent_connected'
+ | 'agent_disconnected';
+
+export interface OBSMCPEvent {
+ type: EventType | string;
+ payload: Record;
+ timestamp: string;
+}
+
+type Listener = (event: OBSMCPEvent) => void;
+
+const EVENT_TO_QUERY: Record = {
+ task_created: ['tasks', 'stats'],
+ task_updated: ['tasks', 'stats'],
+ task_deleted: ['tasks', 'stats'],
+ session_opened: ['sessions', 'stats'],
+ session_closed: ['sessions', 'stats'],
+ session_heartbeat: ['sessions'],
+ blocker_logged: ['blockers', 'stats'],
+ blocker_resolved: ['blockers', 'stats'],
+ blocker_deleted: ['blockers', 'stats'],
+ decision_logged: ['decisions', 'stats'],
+ decision_updated: ['decisions'],
+ decision_deleted: ['decisions', 'stats'],
+ work_logged: ['work-logs', 'stats'],
+ work_log_updated: ['work-logs'],
+ work_log_deleted: ['work-logs', 'stats'],
+ scan_started: ['code-atlas'],
+ scan_progress: ['code-atlas'],
+ scan_completed: ['code-atlas'],
+ node_created: ['knowledge-graph', 'stats'],
+ node_updated: ['knowledge-graph'],
+ node_deleted: ['knowledge-graph', 'stats'],
+ nodes_bulk_created: ['knowledge-graph', 'stats'],
+ edge_created: ['knowledge-graph', 'stats'],
+ edge_deleted: ['knowledge-graph', 'stats'],
+ edges_bulk_created: ['knowledge-graph', 'stats'],
+ perf_log_received: ['performance-logs'],
+ agent_connected: ['agents', 'stats'],
+ agent_disconnected: ['agents', 'stats'],
+};
+
+export class EventBus {
+ private listeners = new Set();
+ private es: EventSource | null = null;
+ private reconnectTimer: number | null = null;
+ private queryClient: QueryClient | null = null;
+ public connected = false;
+ private statusListeners = new Set<(c: boolean) => void>();
+
+ attachQueryClient(qc: QueryClient): void {
+ this.queryClient = qc;
+ }
+
+ connect(): void {
+ if (this.es) return;
+ const token = getApiToken();
+ const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events';
+ const es = new EventSource(url);
+ this.es = es;
+ es.onopen = () => {
+ this.connected = true;
+ this.statusListeners.forEach((fn) => fn(true));
+ };
+ es.onerror = () => {
+ this.connected = false;
+ this.statusListeners.forEach((fn) => fn(false));
+ es.close();
+ this.es = null;
+ this.scheduleReconnect();
+ };
+ es.onmessage = (ev) => this.handleMessage(ev.data);
+ // Listen to all named events too.
+ const knownTypes: string[] = Object.keys(EVENT_TO_QUERY).concat(['connected', 'heartbeat']);
+ for (const t of knownTypes) {
+ es.addEventListener(t, (ev: MessageEvent) => this.handleMessage(ev.data));
+ }
+ }
+
+ disconnect(): void {
+ if (this.es) {
+ this.es.close();
+ this.es = null;
+ }
+ if (this.reconnectTimer) {
+ window.clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ this.connected = false;
+ }
+
+ subscribe(fn: Listener): () => void {
+ this.listeners.add(fn);
+ return () => this.listeners.delete(fn);
+ }
+
+ onStatus(fn: (connected: boolean) => void): () => void {
+ this.statusListeners.add(fn);
+ return () => this.statusListeners.delete(fn);
+ }
+
+ private handleMessage(raw: string): void {
+ if (!raw || raw.startsWith(':')) return;
+ let event: OBSMCPEvent;
+ try {
+ event = JSON.parse(raw);
+ } catch {
+ return;
+ }
+ this.listeners.forEach((l) => l(event));
+ const keys = EVENT_TO_QUERY[event.type] ?? [];
+ if (this.queryClient) {
+ for (const k of keys) {
+ void this.queryClient.invalidateQueries({ queryKey: [k] });
+ }
+ }
+ }
+
+ private scheduleReconnect(): void {
+ if (this.reconnectTimer) return;
+ this.reconnectTimer = window.setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connect();
+ }, 3_000);
+ }
+}
+
+export const eventBus = new EventBus();
diff --git a/frontend/src/hooks/useConnectionStatus.ts b/frontend/src/hooks/useConnectionStatus.ts
new file mode 100644
index 0000000..d6caa5e
--- /dev/null
+++ b/frontend/src/hooks/useConnectionStatus.ts
@@ -0,0 +1,8 @@
+import { useEffect, useState } from 'react';
+import { eventBus } from '../events/EventBus';
+
+export function useConnectionStatus(): boolean {
+ const [connected, setConnected] = useState(eventBus.connected);
+ useEffect(() => eventBus.onStatus(setConnected), []);
+ return connected;
+}
diff --git a/frontend/src/hooks/useEvent.ts b/frontend/src/hooks/useEvent.ts
new file mode 100644
index 0000000..a1f1927
--- /dev/null
+++ b/frontend/src/hooks/useEvent.ts
@@ -0,0 +1,6 @@
+import { useEffect } from 'react';
+import { eventBus, OBSMCPEvent } from '../events/EventBus';
+
+export function useEvent(handler: (event: OBSMCPEvent) => void): void {
+ useEffect(() => eventBus.subscribe(handler), [handler]);
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..017d0fe
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,25 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html, body, #root {
+ height: 100%;
+}
+
+@layer components {
+ .card {
+ @apply rounded-lg border border-slate-200 bg-white p-4 shadow-sm;
+ }
+ .btn {
+ @apply inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium transition;
+ }
+ .btn-primary {
+ @apply btn bg-brand-600 text-white hover:bg-brand-700;
+ }
+ .btn-secondary {
+ @apply btn bg-slate-100 text-slate-800 hover:bg-slate-200;
+ }
+ .tag {
+ @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..1bd1e81
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import App from './App';
+import './index.css';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+});
+
+const root = document.getElementById('root');
+if (!root) throw new Error('Missing #root element');
+
+ReactDOM.createRoot(root).render(
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/pages/Blockers.tsx b/frontend/src/pages/Blockers.tsx
new file mode 100644
index 0000000..e0c8646
--- /dev/null
+++ b/frontend/src/pages/Blockers.tsx
@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { Blocker } from '../api/types';
+
+export default function BlockersPage(): JSX.Element {
+ const qc = useQueryClient();
+ const [description, setDescription] = useState('');
+ const [severity, setSeverity] = useState('medium');
+ const [resolution, setResolution] = useState>({});
+
+ const blockers = useQuery({
+ queryKey: ['blockers'],
+ queryFn: () => api.get('/api/blockers'),
+ });
+ const create = useMutation({
+ mutationFn: (body: Partial) => api.post('/api/blockers', body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['blockers'] }),
+ });
+ const resolve = useMutation({
+ mutationFn: ({ id, r }: { id: string; r: string }) =>
+ api.put(`/api/blockers/${id}/resolve`, { resolution: r }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['blockers'] }),
+ });
+
+ return (
+ <>
+
+
+ {blockers.data && blockers.data.length === 0 ? (
+
+ ) : (
+
+ {(blockers.data ?? []).map((b) => (
+
+
+ {b.description}
+
+ {b.status}
+
+
+
+ severity: {b.severity} · logged {b.created_at}
+
+ {b.status === 'active' ? (
+
+ setResolution((s) => ({ ...s, [b.id]: e.target.value }))}
+ />
+ {
+ const r = resolution[b.id]?.trim();
+ if (!r) return;
+ resolve.mutate({ id: b.id, r });
+ }}
+ >
+ Resolve
+
+
+ ) : (
+ Resolved: {b.resolution}
+ )}
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/CodeAtlas.tsx b/frontend/src/pages/CodeAtlas.tsx
new file mode 100644
index 0000000..f3ea3c6
--- /dev/null
+++ b/frontend/src/pages/CodeAtlas.tsx
@@ -0,0 +1,165 @@
+import { useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Legend,
+ Pie,
+ PieChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { Play } from 'lucide-react';
+
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { CodeAtlasFile, CodeAtlasScan } from '../api/types';
+
+const COLORS = ['#5b71ff', '#f97316', '#22c55e', '#ef4444', '#a855f7', '#eab308', '#06b6d4'];
+
+export default function CodeAtlasPage(): JSX.Element {
+ const qc = useQueryClient();
+ const scans = useQuery({
+ queryKey: ['code-atlas', 'scans'],
+ queryFn: () => api.get('/api/code-atlas'),
+ });
+ const latestScan = scans.data?.[0];
+ const filesQuery = useQuery<{ files: CodeAtlasFile[] }>({
+ queryKey: ['code-atlas', 'files', latestScan?.id],
+ enabled: !!latestScan?.id,
+ queryFn: () =>
+ api.get<{ files: CodeAtlasFile[] }>(`/api/code-atlas/scan/${latestScan!.id}/files?per_page=500`),
+ });
+ const start = useMutation({
+ mutationFn: () => api.post('/api/code-atlas/scan', {}),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['code-atlas'] }),
+ });
+
+ const files = filesQuery.data?.files ?? [];
+ const [selected, setSelected] = useState(null);
+
+ const languageDistribution = useMemo(() => {
+ const counts: Record = {};
+ for (const f of files) {
+ const key = f.language ?? 'unknown';
+ counts[key] = (counts[key] ?? 0) + 1;
+ }
+ return Object.entries(counts).map(([name, value]) => ({ name, value }));
+ }, [files]);
+
+ const topFunctions = useMemo(
+ () =>
+ [...files]
+ .sort((a, b) => b.functions_count - a.functions_count)
+ .slice(0, 10)
+ .map((f) => ({ name: f.file_path.split('/').pop() ?? f.file_path, count: f.functions_count })),
+ [files],
+ );
+
+ return (
+ <>
+ start.mutate()}
+ >
+ {start.isPending ? 'Starting…' : 'Run scan'}
+
+ }
+ />
+ {!latestScan ? (
+
+ ) : (
+ <>
+
+
+ Language distribution
+
+
+
+ {languageDistribution.map((_, i) => (
+ |
+ ))}
+
+
+
+
+
+
+
+ Top files by function count
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Files ({files.length})
+
+
+
+
+ {selected ? (
+ <>
+ {selected.file_path}
+
+ {selected.language} · {selected.functions_count} functions
+
+
+ {selected.semantic_description ?? 'No semantic description yet.'}
+
+ Imports
+
+ {(selected.imports ?? []).map((imp) => (
+
+ {imp}
+
+ ))}
+
+ >
+ ) : (
+ Select a file to see details.
+ )}
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..ce6a4ad
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,113 @@
+import { useQuery } from '@tanstack/react-query';
+import { Link } from 'react-router-dom';
+import {
+ Activity,
+ AlertTriangle,
+ BookOpenText,
+ ClipboardList,
+ Layers,
+ ListChecks,
+ Network,
+ type LucideIcon,
+} from 'lucide-react';
+
+import PageHeader from '../components/PageHeader';
+import { api } from '../api/client';
+import type { Blocker, Session, Stats, Task } from '../api/types';
+
+interface StatCard {
+ label: string;
+ value: string | number;
+ to: string;
+ icon: LucideIcon;
+}
+
+export default function Dashboard(): JSX.Element {
+ const stats = useQuery({
+ queryKey: ['stats'],
+ queryFn: () => api.get('/api/stats'),
+ });
+ const recentTasks = useQuery({
+ queryKey: ['tasks'],
+ queryFn: () => api.get('/api/tasks'),
+ });
+ const activeSessions = useQuery({
+ queryKey: ['sessions', { active: true }],
+ queryFn: () => api.get('/api/sessions?active=true'),
+ });
+ const activeBlockers = useQuery({
+ queryKey: ['blockers', { status: 'active' }],
+ queryFn: () => api.get('/api/blockers?status=active'),
+ });
+
+ const s = stats.data;
+ const cards: StatCard[] = [
+ { label: 'Open tasks', value: s?.tasks.open ?? '—', to: '/tasks', icon: ListChecks },
+ { label: 'Active sessions', value: s?.sessions.active ?? '—', to: '/sessions', icon: Activity },
+ { label: 'Active blockers', value: s?.blockers.active ?? '—', to: '/blockers', icon: AlertTriangle },
+ { label: 'Decisions', value: s?.decisions ?? '—', to: '/decisions', icon: BookOpenText },
+ { label: 'Work logs', value: s?.work_logs ?? '—', to: '/work-logs', icon: ClipboardList },
+ { label: 'Code Atlas nodes', value: s?.nodes ?? '—', to: '/graph', icon: Network },
+ { label: 'Scanned files', value: s?.edges ?? '—', to: '/code-atlas', icon: Layers },
+ ];
+
+ return (
+ <>
+
+
+ {cards.map((c) => (
+
+
+
+
+
{c.value}
+
{c.label}
+
+
+
+ ))}
+
+
+
+
+ Recent tasks
+ {(recentTasks.data ?? []).slice(0, 5).map((t) => (
+
+
{t.title}
+
+ {t.status} · {t.priority}
+
+
+ ))}
+ {recentTasks.data && recentTasks.data.length === 0 && (
+ No tasks yet.
+ )}
+
+
+ Active sessions
+ {(activeSessions.data ?? []).slice(0, 5).map((s2) => (
+
+
{s2.agent_id}
+
since {s2.started_at}
+
+ ))}
+ {activeSessions.data && activeSessions.data.length === 0 && (
+ No active sessions.
+ )}
+
+
+ Active blockers
+ {(activeBlockers.data ?? []).slice(0, 5).map((b) => (
+
+
{b.description}
+
{b.severity}
+
+ ))}
+ {activeBlockers.data && activeBlockers.data.length === 0 && (
+ Nothing blocking.
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Decisions.tsx b/frontend/src/pages/Decisions.tsx
new file mode 100644
index 0000000..0d8d4b5
--- /dev/null
+++ b/frontend/src/pages/Decisions.tsx
@@ -0,0 +1,70 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { Decision } from '../api/types';
+
+export default function DecisionsPage(): JSX.Element {
+ const qc = useQueryClient();
+ const [decision, setDecision] = useState('');
+ const [context, setContext] = useState('');
+ const decisions = useQuery({
+ queryKey: ['decisions'],
+ queryFn: () => api.get('/api/decisions'),
+ });
+ const create = useMutation({
+ mutationFn: (body: Partial) => api.post('/api/decisions', body),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['decisions'] });
+ setDecision('');
+ setContext('');
+ },
+ });
+
+ return (
+ <>
+
+
+ {decisions.data && decisions.data.length === 0 ? (
+
+ ) : (
+
+ {(decisions.data ?? []).map((d) => (
+
+ {d.decision}
+ {d.context && {d.context}
}
+ {d.created_at}
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/KnowledgeGraph.tsx b/frontend/src/pages/KnowledgeGraph.tsx
new file mode 100644
index 0000000..ed36f8e
--- /dev/null
+++ b/frontend/src/pages/KnowledgeGraph.tsx
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import ReactFlow, { Background, Controls, MiniMap, Edge, Node } from 'reactflow';
+import 'reactflow/dist/style.css';
+
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { KnowledgeEdge, KnowledgeNode } from '../api/types';
+
+interface Graph {
+ nodes: KnowledgeNode[];
+ edges: KnowledgeEdge[];
+}
+
+export default function KnowledgeGraphPage(): JSX.Element {
+ const graph = useQuery({
+ queryKey: ['knowledge-graph'],
+ queryFn: () => api.get('/api/knowledge-graph'),
+ });
+
+ const { nodes, edges } = useMemo(() => {
+ const g: Graph = graph.data ?? { nodes: [], edges: [] };
+ const radius = Math.max(200, 32 * g.nodes.length);
+ const rfNodes: Node[] = g.nodes.slice(0, 300).map((n, i) => {
+ const angle = (i / Math.max(g.nodes.length, 1)) * 2 * Math.PI;
+ return {
+ id: n.id,
+ position: { x: Math.cos(angle) * radius + radius, y: Math.sin(angle) * radius + radius },
+ data: { label: `${n.node_type}: ${n.name}` },
+ style: { padding: 6, borderRadius: 6, background: '#fff', border: '1px solid #cbd5e1' },
+ };
+ });
+ const rfEdges: Edge[] = g.edges
+ .slice(0, 500)
+ .map((e) => ({ id: e.id, source: e.from_node_id, target: e.to_node_id, label: e.edge_type }));
+ return { nodes: rfNodes, edges: rfEdges };
+ }, [graph.data]);
+
+ return (
+ <>
+
+ {(graph.data?.nodes.length ?? 0) === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx
new file mode 100644
index 0000000..1761849
--- /dev/null
+++ b/frontend/src/pages/NotFound.tsx
@@ -0,0 +1,13 @@
+import { Link } from 'react-router-dom';
+
+export default function NotFound(): JSX.Element {
+ return (
+
+
Page not found
+
That route doesn't exist.
+
+ Back to dashboard
+
+
+ );
+}
diff --git a/frontend/src/pages/PerformanceLogs.tsx b/frontend/src/pages/PerformanceLogs.tsx
new file mode 100644
index 0000000..2d8da1b
--- /dev/null
+++ b/frontend/src/pages/PerformanceLogs.tsx
@@ -0,0 +1,68 @@
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { PerformanceLog } from '../api/types';
+
+export default function PerformanceLogsPage(): JSX.Element {
+ const logs = useQuery({
+ queryKey: ['performance-logs'],
+ queryFn: () => api.get('/api/performance-logs?limit=500'),
+ });
+
+ const series = useMemo(() => {
+ const byTs: Record> = {};
+ for (const l of logs.data ?? []) {
+ const ts = l.logged_at.slice(0, 19);
+ byTs[ts] = byTs[ts] ?? { time: ts };
+ byTs[ts][l.metric_name] = l.metric_value;
+ }
+ return Object.values(byTs).sort((a, b) => (a.time as string).localeCompare(b.time as string));
+ }, [logs.data]);
+
+ const metricNames = useMemo(() => {
+ const names = new Set();
+ for (const l of logs.data ?? []) names.add(l.metric_name);
+ return [...names];
+ }, [logs.data]);
+
+ return (
+ <>
+
+ {series.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {metricNames.map((m, i) => (
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx
new file mode 100644
index 0000000..bd32c9b
--- /dev/null
+++ b/frontend/src/pages/Sessions.tsx
@@ -0,0 +1,59 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { Session } from '../api/types';
+
+export default function SessionsPage(): JSX.Element {
+ const qc = useQueryClient();
+ const sessions = useQuery({
+ queryKey: ['sessions'],
+ queryFn: () => api.get('/api/sessions'),
+ });
+ const close = useMutation({
+ mutationFn: (id: string) => api.put(`/api/sessions/${id}/close`, {}),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }),
+ });
+
+ return (
+ <>
+
+ {sessions.data && sessions.data.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Agent
+ Started
+ Ended
+ Duration
+
+
+
+
+ {(sessions.data ?? []).map((s) => (
+
+ {s.agent_id}
+ {s.started_at}
+ {s.ended_at ?? '—'}
+
+ {s.duration_seconds != null ? `${s.duration_seconds}s` : 'active'}
+
+
+ {!s.ended_at && (
+ close.mutate(s.id)} className="btn-secondary text-xs">
+ Close
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..ee4c23c
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,70 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import PageHeader from '../components/PageHeader';
+import { api, clearApiToken, getApiToken, setApiToken } from '../api/client';
+
+interface ModeResponse {
+ mode: 'local' | 'cloud';
+}
+interface Runtime {
+ version: string;
+ mode: string;
+ features: string[];
+ db_schema_version: number;
+}
+
+export default function SettingsPage(): JSX.Element {
+ const [token, setToken] = useState(getApiToken() ?? '');
+ const mode = useQuery({ queryKey: ['mode'], queryFn: () => api.get('/mode') });
+ const runtime = useQuery({
+ queryKey: ['runtime-discovery'],
+ queryFn: () => api.get('/runtime-discovery'),
+ });
+
+ const save = () => {
+ if (token.trim()) setApiToken(token.trim());
+ else clearApiToken();
+ window.location.reload();
+ };
+
+ return (
+ <>
+
+
+ Server
+
+ Version
+ {runtime.data?.version ?? '—'}
+ Mode
+ {mode.data?.mode ?? runtime.data?.mode ?? '—'}
+ Features
+
+ {(runtime.data?.features ?? []).map((f) => (
+
+ {f}
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx
new file mode 100644
index 0000000..424ac6b
--- /dev/null
+++ b/frontend/src/pages/Tasks.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Plus, Trash2 } from 'lucide-react';
+
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { Task } from '../api/types';
+
+const STATUSES: Task['status'][] = ['open', 'in_progress', 'blocked', 'done'];
+
+export default function TasksPage(): JSX.Element {
+ const qc = useQueryClient();
+ const [title, setTitle] = useState('');
+ const tasks = useQuery({
+ queryKey: ['tasks'],
+ queryFn: () => api.get('/api/tasks'),
+ });
+
+ const create = useMutation({
+ mutationFn: (body: Partial) => api.post('/api/tasks', body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
+ });
+ const update = useMutation({
+ mutationFn: ({ id, body }: { id: string; body: Partial }) =>
+ api.put(`/api/tasks/${id}`, body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
+ });
+ const destroy = useMutation({
+ mutationFn: (id: string) => api.del<{ ok: boolean }>(`/api/tasks/${id}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
+ });
+
+ const handleCreate = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+ create.mutate({ title: title.trim() });
+ setTitle('');
+ };
+
+ return (
+ <>
+
+
+ setTitle(e.target.value)}
+ />
+
+ Add
+
+
+ {tasks.data && tasks.data.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Title
+ Status
+ Priority
+ Created
+
+
+
+
+ {(tasks.data ?? []).map((t) => (
+
+ {t.title}
+
+
+ update.mutate({ id: t.id, body: { status: e.target.value as Task['status'] } })
+ }
+ >
+ {STATUSES.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ {t.priority}
+ {t.created_at}
+
+ destroy.mutate(t.id)}
+ className="text-slate-400 hover:text-rose-600"
+ aria-label="delete"
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/WorkLogs.tsx b/frontend/src/pages/WorkLogs.tsx
new file mode 100644
index 0000000..706526e
--- /dev/null
+++ b/frontend/src/pages/WorkLogs.tsx
@@ -0,0 +1,43 @@
+import { useQuery } from '@tanstack/react-query';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { api } from '../api/client';
+import type { WorkLog } from '../api/types';
+
+export default function WorkLogsPage(): JSX.Element {
+ const logs = useQuery({
+ queryKey: ['work-logs'],
+ queryFn: () => api.get('/api/work-logs'),
+ });
+ return (
+ <>
+
+ {logs.data && logs.data.length === 0 ? (
+
+ ) : (
+
+
+
+
+ When
+ Agent
+ Description
+ Hours
+
+
+
+ {(logs.data ?? []).map((l) => (
+
+ {l.created_at}
+ {l.agent_id ?? '—'}
+ {l.description}
+ {l.hours ?? '—'}
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..157b0e3
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,19 @@
+import type { Config } from 'tailwindcss';
+
+export default {
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ brand: {
+ 50: '#f5f7ff',
+ 100: '#e7ecff',
+ 500: '#5b71ff',
+ 600: '#4256f0',
+ 700: '#3442c8',
+ },
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..387dee9
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "useDefineForClassFields": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..446690b
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ build: {
+ outDir: '../server/obsmcp_server/frontend_dist',
+ emptyOutDir: true,
+ sourcemap: true,
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:8000',
+ '/ws': {
+ target: 'ws://localhost:8000',
+ ws: true,
+ },
+ },
+ },
+});
diff --git a/install_task_scheduler.bat b/install_task_scheduler.bat
deleted file mode 100644
index bd23dca..0000000
--- a/install_task_scheduler.bat
+++ /dev/null
@@ -1,24 +0,0 @@
-@echo off
-setlocal
-set "TASK_NAME=obsmcp"
-set "OBSPROJ=%~dp0"
-set "BAT_FILE=%OBSPROJ%run_obsmcp.bat"
-
-REM Remove existing task if present
-SCHTASKS /Delete /TN "%TASK_NAME%" /F >nul 2>&1
-
-REM Create scheduled task: runs at logon with 30-second delay
-SCHTASKS /Create /TN "%TASK_NAME%" /SC ONLOGON /DELAY 0000:30 /RL LIMITED /TR "\"%BAT_FILE%\"" /F
-if %ERRORLEVEL% EQU 0 (
- echo.
- echo obsmcp scheduled task installed: "%TASK_NAME%"
- echo Will start automatically 30 seconds after each logon.
- echo.
- SCHTASKS /Query /TN "%TASK_NAME%" /FO LIST
-) else (
- echo.
- echo ERROR: Failed to create scheduled task.
- echo Run as Administrator if this fails.
- echo.
- exit /b 1
-)
diff --git a/master prompt.md b/master prompt.md
deleted file mode 100644
index fbd60f5..0000000
--- a/master prompt.md
+++ /dev/null
@@ -1,415 +0,0 @@
-# Master Prompt
-
-Use `obsmcp` as the primary continuity system for this project.
-
-MCP endpoint:
-
-```text
-http://127.0.0.1:9300/mcp
-```
-
-You must treat `obsmcp` as the shared memory and continuity layer for this project. Do not rely on the full prior chat history as your default memory source. The current user request is the source of truth for the immediate task, the codebase is the source of truth for implementation details, and `obsmcp` is the source of truth for continuity, handoffs, blockers, decisions, and recent work.
-
-## Core policy
-
-You must do all of the following:
-
-1. Read continuity state from `obsmcp` before starting substantive work.
-2. Open a tracked session in `obsmcp`.
-3. Log meaningful progress to `obsmcp` during the session.
-4. Record blockers, decisions, and task changes in `obsmcp`.
-5. Create a handoff in `obsmcp` before ending the session.
-6. Close the session in `obsmcp` with a summary.
-7. If MCP write-back fails, say so explicitly and fall back to the `.context` files.
-
-Do not use full historical chat replay as your primary working memory. Only consult older chat if `obsmcp` is unavailable, stale, or missing critical context.
-
-## Required startup sequence
-
-At the start of this conversation, do the following in order:
-
-1. Call `health_check`.
-2. Call `get_project_status_snapshot`.
-3. Call `get_current_task`.
-4. Call `get_latest_handoff`.
-5. Call `get_blockers`.
-6. Call `get_relevant_files`.
-7. Call `generate_compact_context`.
-8. Call `generate_context_profile(profile="fast" or "balanced")`.
-9. Call `generate_delta_context`.
-10. If you need targeted understanding of specific files, functions, classes, or features, use:
- - `describe_module`
- - `describe_symbol`
- - `describe_feature`
- - `search_code_knowledge`
-11. Call `session_open`.
-
-If this is the first contact with the project, also:
-
-1. Understand the codebase structure.
-2. Check if a **Code Atlas** exists for this project. Call `get_code_atlas_status()`. If `exists: false`, immediately call `scan_codebase()` to generate it. The atlas gives you a structural understanding of every file, function, class, and feature — it is the fastest way to understand a project without reading every source file.
-3. Summarize the codebase in a compact way.
-4. Create or update the current task if needed.
-5. Log an initial work entry describing what you learned.
-
-## Required session_open contract
-
-Open the session with this API shape:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": 1,
- "method": "tools/call",
- "params": {
- "name": "session_open",
- "arguments": {
- "actor": "",
- "client_name": "",
- "model_name": "",
- "project_path": "",
- "initial_request": "",
- "session_goal": "",
- "task_id": "",
- "require_heartbeat": true,
- "require_work_log": true,
- "heartbeat_interval_seconds": 900,
- "work_log_interval_seconds": 1800,
- "min_work_logs": 1,
- "handoff_required": true
- }
- }
-}
-```
-
-Store the returned `session_id` and reuse it in all later write operations.
-
-## Required read tools
-
-Use these tools for continuity reads:
-
-- `get_project_status_snapshot`
-- `get_current_task`
-- `get_active_tasks`
-- `get_latest_handoff`
-- `get_recent_work`
-- `get_decisions`
-- `get_blockers`
-- `get_relevant_files`
-- `search_notes`
-- `read_note`
-- `generate_compact_context`
-- `generate_context_profile`
-- `generate_delta_context`
-- `generate_task_snapshot`
-- `get_active_sessions`
-- `detect_missing_writeback`
-- `get_code_atlas_status` — check if the project has a Code Atlas
-- `scan_codebase` — generate the Code Atlas (only needed if atlas doesn't exist or is stale)
-- `describe_module` — fetch a cached or fresh semantic description for a file/module
-- `describe_symbol` — fetch a cached or fresh semantic description for a function/class
-- `describe_feature` — fetch a feature-level explanation across files
-- `search_code_knowledge` — search semantic knowledge instead of rereading large notes
-- `get_scan_job` / `wait_for_scan_job` — poll background atlas scans when `scan_codebase` returns a job instead of a finished atlas
-- `get_symbol_candidates` — disambiguate duplicate symbol names
-- `get_related_symbols` — expand from one symbol to nearby or feature-related symbols
-- `get_task_templates` — list available task templates
-- `get_audit_log` — full activity timeline
-- `get_log_stats` — work log statistics by age
-- `get_blocked_tasks` — tasks blocked by unresolved dependencies
-- `validate_dependencies` — check for circular/broken task dependencies
-- `get_all_dependencies` — full dependency map
-
-## Required write behavior
-
-You must write to `obsmcp` when any of these happen:
-
-- you begin meaningful work on the task
-- you finish a meaningful chunk
-- you discover a blocker
-- you make a decision
-- you update the task scope or task status
-- you identify important relevant files
-- you are about to stop, hand off, or switch tools
-- you want to use bulk_task_ops for multiple task changes at once (more efficient than individual calls)
-- you complete a sprint or milestone and want to export the full project state (use `export_project`)
-- you want to archive old work logs to keep context bounded (use `expire_old_logs` with configured retention)
-
-Do not wait until the very end to write everything.
-
-## Required write formats
-
-### Log progress
-
-Use `log_work`:
-
-```json
-{
- "name": "log_work",
- "arguments": {
- "actor": "",
- "session_id": "",
- "task_id": "",
- "message": "",
- "summary": "",
- "files": ["", ""]
- }
-}
-```
-
-Use `log_work` for:
-
-- code understanding summaries
-- implementation progress
-- bug investigation findings
-- test results
-- refactor progress
-- ingestion summaries on first project contact
-- semantic knowledge generation results when you discover important modules, features, or symbol behaviors
-
-### Create or update tasks
-
-Use `create_task` when a new actionable unit of work is discovered.
-
-```json
-{
- "name": "create_task",
- "arguments": {
- "actor": "",
- "session_id": "",
- "title": "",
- "description": "",
- "priority": "low|medium|high",
- "owner": "",
- "relevant_files": ["", ""],
- "tags": ["", ""]
- }
-}
-```
-
-Use `update_task` when scope or status changes.
-
-```json
-{
- "name": "update_task",
- "arguments": {
- "task_id": "",
- "actor": "",
- "session_id": "",
- "status": "open|in_progress|blocked|done",
- "description": "",
- "priority": "low|medium|high",
- "relevant_files": ["", ""],
- "tags": ["", ""]
- }
-}
-```
-
-Use `set_current_task` whenever you know the active task:
-
-```json
-{
- "name": "set_current_task",
- "arguments": {
- "task_id": "",
- "actor": "",
- "session_id": ""
- }
-}
-```
-
-### Log decisions
-
-Use `log_decision` for architectural, tooling, debugging, or implementation decisions.
-
-```json
-{
- "name": "log_decision",
- "arguments": {
- "actor": "",
- "session_id": "",
- "task_id": "",
- "title": "",
- "decision": "",
- "rationale": "",
- "impact": ""
- }
-}
-```
-
-### Log blockers
-
-Use `log_blocker` immediately when progress is blocked.
-
-```json
-{
- "name": "log_blocker",
- "arguments": {
- "actor": "",
- "session_id": "",
- "task_id": "",
- "title": "",
- "description": ""
- }
-}
-```
-
-When the blocker is resolved, use `resolve_blocker`.
-
-### Handoffs
-
-Before ending the session, create a handoff:
-
-```json
-{
- "name": "create_handoff",
- "arguments": {
- "from_actor": "",
- "to_actor": "",
- "session_id": "",
- "task_id": "",
- "summary": "",
- "next_steps": "",
- "open_questions": "",
- "note": ""
- }
-}
-```
-
-The handoff must be good enough that another model can continue without re-explaining the project.
-
-## Heartbeat policy
-
-If the session stays open for a while, call `session_heartbeat`.
-
-Use this shape:
-
-```json
-{
- "name": "session_heartbeat",
- "arguments": {
- "session_id": "",
- "actor": "",
- "status_note": "",
- "task_id": "",
- "files": ["", ""],
- "create_work_log": true
- }
-}
-```
-
-Rules:
-
-- heartbeat at least every 15 minutes during long sessions
-- if meaningful progress happened, set `create_work_log` to `true`
-- do not leave long silent stretches with no write-back
-
-## Session close policy
-
-Before ending the conversation or stopping work, call `session_close`.
-
-Use this shape:
-
-```json
-{
- "name": "session_close",
- "arguments": {
- "session_id": "",
- "actor": "",
- "summary": "",
- "create_handoff": true,
- "handoff_summary": "",
- "handoff_next_steps": "",
- "handoff_open_questions": "",
- "handoff_note": "",
- "handoff_to_actor": ""
- }
-}
-```
-
-Do not close the session silently without a handoff unless the user explicitly says no continuity write-back is needed.
-
-## First-time project ingestion rule
-
-If this is the first time you are seeing the project, you must:
-
-1. Inspect the codebase structure.
-2. Identify major modules, entry points, configs, tests, and risks.
-3. Create or update the active task.
-4. Log at least one `log_work` entry summarizing the codebase understanding.
-5. Record important files with the task or work log.
-6. Record a decision only if a real decision was made.
-7. Create a handoff before ending, even if the work was only discovery.
-
-## Bug-fixing rule
-
-If the task is bug related, you must log:
-
-- what the bug is
-- where it appears
-- what files are involved
-- what hypothesis you tested
-- what fix you applied or why it remains blocked
-
-Minimum expected write-back for bug work:
-
-1. `log_work` when bug analysis starts
-2. `log_blocker` if reproduction or root cause is blocked
-3. `log_decision` if a fix strategy is chosen
-4. `log_work` after fix or test result
-5. `create_handoff` before exit
-
-## If MCP fails
-
-If MCP read or write fails:
-
-1. Say clearly that MCP failed.
-2. Read these fallback files:
- - `.context/PROJECT_CONTEXT.md`
- - `.context/CURRENT_TASK.json`
- - `.context/HANDOFF.md`
- - `.context/DECISIONS.md`
- - `.context/BLOCKERS.json`
- - `.context/RELEVANT_FILES.json`
- - `.context/SESSION_SUMMARY.md`
- - `.context/SESSION_AUDIT.json`
-3. Continue work using those files.
-4. If shell access exists, use `ctx.bat`.
-5. If neither MCP nor CLI write-back is available, say that continuity write-back could not be completed.
-
-If `scan_codebase` returns a queued or running background job instead of a finished atlas:
-
-1. store the returned `job_id`
-2. poll `get_scan_job(job_id)` until the status is `completed`, `failed`, or `interrupted`
-3. if your client supports longer waits, call `wait_for_scan_job(job_id, wait_seconds=30+)`
-4. once completed, continue with `get_code_atlas_status`, `describe_module`, `describe_symbol`, or `search_code_knowledge`
-
-## CLI fallback
-
-If MCP tool calls are unavailable but shell access exists, use:
-
-```bat
-ctx.bat session open --actor "" --client "" --model "" --project-path "" --initial-request "" --goal ""
-ctx.bat log "message" --task TASK-ID --actor ""
-ctx.bat decision log "title" --decision "decision text" --task TASK-ID --actor ""
-ctx.bat handoff --summary "summary" --next-steps "next steps" --open-questions "questions" --to "next-agent"
-ctx.bat session close SESSION-ID --actor "" --summary "summary"
-ctx.bat audit
-```
-
-## Final operating rule
-
-You are not just answering the user. You are maintaining continuity for the next model too.
-
-Every meaningful session must leave behind:
-
-- current task state
-- recent progress
-- decisions
-- blockers
-- relevant files
-- recommended semantic lookups when specific modules/symbols/features matter for the next model
-- handoff
-- closed session summary
-
-If you cannot perform any of that, you must say so explicitly.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5ee420a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,67 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "obsmcp"
+version = "0.1.0"
+description = "OBSMCP — Observable Machine Code Protocol. Local MCP tool + FastAPI backend + React dashboard."
+readme = "README.md"
+license = { text = "MIT" }
+requires-python = ">=3.12"
+authors = [{ name = "OBSMCP Contributors" }]
+dependencies = [
+ "fastapi>=0.115",
+ "uvicorn[standard]>=0.30",
+ "httpx>=0.27",
+ "pydantic>=2.7",
+ "python-multipart>=0.0.9",
+ "psutil>=5.9",
+ "watchfiles>=0.22",
+ "websockets>=12",
+ "anyio>=4",
+ "mcp>=1.0",
+]
+
+[project.optional-dependencies]
+llm = ["anthropic>=0.34"]
+dev = [
+ "pytest>=8",
+ "pytest-asyncio>=0.23",
+ "ruff>=0.5",
+ "mypy>=1.10",
+ "httpx>=0.27",
+]
+
+[project.scripts]
+obsmcp = "obsmcp.__main__:main"
+obsmcp-setup = "obsmcp.obsmcp_setup:main"
+obsmcp-server = "obsmcp_server.main:run"
+
+[tool.setuptools.packages.find]
+where = ["tool", "server"]
+include = ["obsmcp*", "obsmcp_server*"]
+
+[tool.setuptools.package-data]
+"obsmcp_server" = ["schema.sql", "frontend_dist/**/*"]
+
+[tool.ruff]
+target-version = "py312"
+line-length = 100
+extend-exclude = ["frontend", "build", "dist", "node_modules"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
+ignore = ["E501", "B008"]
+
+[tool.mypy]
+python_version = "3.12"
+strict_optional = true
+warn_unused_ignores = true
+ignore_missing_imports = true
+packages = ["obsmcp", "obsmcp_server"]
+exclude = ["frontend/", "build/", "dist/"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tool/tests", "server/tests"]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 82ece55..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-fastapi>=0.115.0,<1.0.0
-uvicorn[standard]>=0.30.0,<1.0.0
-requests>=2.32.0,<3.0.0
diff --git a/run_obsmcp.bat b/run_obsmcp.bat
deleted file mode 100644
index 8f481f0..0000000
--- a/run_obsmcp.bat
+++ /dev/null
@@ -1,3 +0,0 @@
-@echo off
-cd /d "%~dp0"
-.venv\Scripts\python.exe -u -m server.main
diff --git a/scripts/backup_obsmcp.py b/scripts/backup_obsmcp.py
deleted file mode 100644
index 62a20c3..0000000
--- a/scripts/backup_obsmcp.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from __future__ import annotations
-
-import shutil
-import sys
-from datetime import datetime
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parent.parent
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-from server.config import load_config
-
-
-def main() -> None:
- config = load_config()
- timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
- backup_path = config.backup_dir / f"obsmcp-{timestamp}.sqlite3"
- shutil.copy2(config.database_path, backup_path)
- print(f"Created backup at {backup_path}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/launch_obsmcp.py b/scripts/launch_obsmcp.py
deleted file mode 100644
index 87cea4c..0000000
--- a/scripts/launch_obsmcp.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from __future__ import annotations
-
-import os
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parent.parent
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-from server.config import load_config
-from server.utils import is_port_open
-
-
-DETACHED_PROCESS = 0x00000008
-CREATE_NEW_PROCESS_GROUP = 0x00000200
-CREATE_NO_WINDOW = 0x08000000
-
-
-def main() -> None:
- root = ROOT
- config = load_config()
- config.log_dir.mkdir(parents=True, exist_ok=True)
- log_path = config.log_dir / "startup.log"
-
- if is_port_open(config.host, config.port):
- print(f"obsmcp already appears to be listening on {config.host}:{config.port}")
- return
-
- python_exe = root / ".venv" / "Scripts" / "python.exe"
- executable_path = (
- python_exe
- if python_exe.exists()
- else Path(sys.executable)
- )
- command = [str(executable_path), "-u", "-m", "server.main"]
-
- with log_path.open("a", encoding="utf-8") as log_handle:
- log_handle.write(f"Launching obsmcp via {' '.join(command)}\n")
- log_handle.flush()
-
- process = subprocess.Popen(
- command,
- cwd=root,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- stdin=subprocess.DEVNULL,
- close_fds=True,
- env={**os.environ, "OBSMCP_NO_CONSOLE": "1"},
- creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW,
- )
- config.pid_file.write_text(f"{process.pid}\n", encoding="utf-8")
-
- for _ in range(10):
- time.sleep(0.5)
- if is_port_open(config.host, config.port):
- break
- if is_port_open(config.host, config.port):
- print(f"obsmcp started on http://{config.host}:{config.port}")
- else:
- print(f"obsmcp launch requested, but port {config.port} did not open yet. Check {log_path}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/launch_obsmcp.vbs b/scripts/launch_obsmcp.vbs
deleted file mode 100644
index 6c3e87c..0000000
--- a/scripts/launch_obsmcp.vbs
+++ /dev/null
@@ -1,21 +0,0 @@
-Option Explicit
-
-Dim shell
-Dim fso
-Dim root
-Dim pythonPath
-Dim command
-
-Set shell = CreateObject("WScript.Shell")
-Set fso = CreateObject("Scripting.FileSystemObject")
-
-root = fso.GetParentFolderName(fso.GetParentFolderName(WScript.ScriptFullName))
-pythonPath = root & "\.venv\Scripts\python.exe"
-
-If fso.FileExists(pythonPath) Then
- command = "cmd.exe /c cd /d """ & root & """ && """ & pythonPath & """ -u -m server.main"
-Else
- command = "cmd.exe /c cd /d """ & root & """ && py -3 -u -m server.main"
-End If
-
-shell.Run command, 0, False
diff --git a/scripts/stop_obsmcp.py b/scripts/stop_obsmcp.py
deleted file mode 100644
index e1d6ff6..0000000
--- a/scripts/stop_obsmcp.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from __future__ import annotations
-
-import subprocess
-import sys
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parent.parent
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-from server.config import load_config
-
-
-def main() -> None:
- config = load_config()
- pid_path = config.pid_file
- if not pid_path.exists():
- print("No obsmcp pid file found.")
- return
-
- pid_value = pid_path.read_text(encoding="utf-8").strip()
- if not pid_value.isdigit():
- print("PID file is invalid. Remove it manually if needed.")
- return
-
- subprocess.run(["taskkill", "/PID", pid_value, "/T", "/F"], check=False)
- try:
- pid_path.unlink()
- except OSError:
- pass
- print(f"Stopped obsmcp process {pid_value}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/server/__init__.py b/server/__init__.py
deleted file mode 100644
index faae51a..0000000
--- a/server/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""obsmcp server package."""
diff --git a/server/code_atlas.py b/server/code_atlas.py
deleted file mode 100644
index 782909d..0000000
--- a/server/code_atlas.py
+++ /dev/null
@@ -1,1578 +0,0 @@
-"""
-Code Atlas — Multi-language code structure extractor for obsmcp.
-
-Scans a codebase and produces a structured Markdown document that documents
-every file, function, class, feature, and cross-reference. Designed for use
-as a project map that lets new AI agents understand the codebase instantly.
-
-Supported languages:
- Python (.py), JavaScript/TypeScript (.js/.ts/.jsx/.tsx),
- Rust (.rs), Java (.java), C/C++ (.c/.cpp/.h/.hpp),
- Go (.go), Shell (.sh/.bat), HTML (.html),
- CSS (.css), JSON (.json), YAML (.yml/.yaml),
- TOML (.toml), Markdown (.md), Plain text (.txt),
- C# (.cs), PHP (.php), Ruby (.rb), SQL (.sql)
-"""
-
-from __future__ import annotations
-
-import ast
-import os
-import re
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-
-from .utils import utc_now
-
-
-# ---------------------------------------------------------------------------
-# Exclusion patterns — folders and files to skip entirely
-# ---------------------------------------------------------------------------
-
-EXCLUDE_DIRS = {
- ".git",
- ".venv",
- "venv",
- "env",
- ".env",
- "node_modules",
- "__pycache__",
- ".pytest_cache",
- ".mypy_cache",
- ".ruff_cache",
- "dist",
- "build",
- ".eggs",
- "*.egg-info",
- ".tox",
- ".coverage",
- "htmlcov",
- ".next",
- ".nuxt",
- ".svelte-kit",
- ".parcel-cache",
- ".cache",
- "tmp",
- "temp",
- ".tmp",
- ".temp",
- ".svn",
- ".hg",
- ".idea",
- ".vscode",
- ".vs",
- "target", # Rust build output
- "bin",
- "obj", # .NET
- "packages", # NuGet
- ".gradle",
- ".cocoapods",
-}
-
-EXCLUDE_FILES = {
- ".DS_Store",
- "Thumbs.db",
- "desktop.ini",
- ".gitignore",
- ".gitattributes",
- "package-lock.json",
- "yarn.lock",
- "pnpm-lock.yaml",
- "poetry.lock",
- " Pipfile.lock",
- "requirements.txt",
- "*.min.js",
- "*.min.css",
- "*.bundle.js",
-}
-
-EXCLUDE_EXTENSIONS = {
- ".png",
- ".jpg",
- ".jpeg",
- ".gif",
- ".ico",
- ".svg",
- ".webp",
- ".mp4",
- ".mp3",
- ".wav",
- ".pdf",
- ".zip",
- ".tar",
- ".gz",
- ".rar",
- ".7z",
- ".exe",
- ".dll",
- ".so",
- ".dylib",
- ".bin",
- ".dat",
- ".db",
- ".sqlite",
- ".sqlite3",
- ".woff",
- ".woff2",
- ".ttf",
- ".eot",
- ".ico",
-}
-
-# ---------------------------------------------------------------------------
-# Language definitions
-# ---------------------------------------------------------------------------
-
-LANGUAGE_MAP: dict[str, str] = {
- ".py": "Python",
- ".js": "JavaScript",
- ".jsx": "JavaScript (JSX)",
- ".ts": "TypeScript",
- ".tsx": "TypeScript (TSX)",
- ".rs": "Rust",
- ".java": "Java",
- ".c": "C",
- ".cpp": "C++",
- ".cc": "C++",
- ".cxx": "C++",
- ".h": "C/C++ Header",
- ".hpp": "C++ Header",
- ".go": "Go",
- ".sh": "Shell",
- ".bat": "Batch",
- ".ps1": "PowerShell",
- ".html": "HTML",
- ".htm": "HTML",
- ".css": "CSS",
- ".scss": "SCSS",
- ".sass": "Sass",
- ".less": "Less",
- ".json": "JSON",
- ".yaml": "YAML",
- ".yml": "YAML",
- ".toml": "TOML",
- ".md": "Markdown",
- ".txt": "Plain Text",
- ".cs": "C#",
- ".php": "PHP",
- ".rb": "Ruby",
- ".sql": "SQL",
- ".xml": "XML",
- ".vue": "Vue",
- ".svelte": "Svelte",
- ".swift": "Swift",
- ".kt": "Kotlin",
- ".kts": "Kotlin Script",
- ".scala": "Scala",
- ".r": "R",
- ".lua": "Lua",
- ".pl": "Perl",
- ".ex": "Elixir",
- ".exs": "Elixir",
- ".erl": "Erlang",
- ".hs": "Haskell",
- ".erl": "Erlang",
- ".jl": "Julia",
- ".dart": "Dart",
- ".groovy": "Groovy",
- ".gradle": "Gradle",
- ".tf": "Terraform",
- ".dockerfile": "Dockerfile",
-}
-
-
-# ---------------------------------------------------------------------------
-# Data structures
-# ---------------------------------------------------------------------------
-
-@dataclass
-class FunctionInfo:
- name: str
- line_number: int
- signature: str
- docstring: str
- visibility: str = "public" # public, private, protected
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "name": self.name,
- "line": self.line_number,
- "signature": self.signature,
- "docstring": self.docstring,
- "visibility": self.visibility,
- }
-
-
-@dataclass
-class ClassInfo:
- name: str
- line_number: int
- docstring: str
- bases: list[str]
- methods: list[FunctionInfo]
- visibility: str = "public"
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "name": self.name,
- "line": self.line_number,
- "docstring": self.docstring,
- "bases": self.bases,
- "methods": [m.to_dict() for m in self.methods],
- "visibility": self.visibility,
- }
-
-
-@dataclass
-class ImportInfo:
- module: str
- names: list[str]
- line_number: int
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "module": self.module,
- "names": self.names,
- "line": self.line_number,
- }
-
-
-@dataclass
-class FileInfo:
- path: Path
- relative_path: str
- language: str
- total_lines: int
- code_lines: int
- blank_lines: int
- comment_lines: int
- docstring: str
- imports: list[ImportInfo] = field(default_factory=list)
- classes: list[ClassInfo] = field(default_factory=list)
- functions: list[FunctionInfo] = field(default_factory=list)
- # For non-class-based languages
- raw_functions: list[FunctionInfo] = field(default_factory=list)
- # For config/markup files
- structure: dict[str, Any] = field(default_factory=dict)
- # File-level notes (e.g., Django views, React components)
- feature_tags: list[str] = field(default_factory=list)
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "path": self.relative_path,
- "language": self.language,
- "lines": self.total_lines,
- "code_lines": self.code_lines,
- "blank_lines": self.blank_lines,
- "comment_lines": self.comment_lines,
- "docstring": self.docstring,
- "imports": [i.to_dict() for i in self.imports],
- "classes": [c.to_dict() for c in self.classes],
- "functions": [f.to_dict() for f in self.functions],
- "raw_functions": [f.to_dict() for f in self.raw_functions],
- "structure": self.structure,
- "feature_tags": self.feature_tags,
- }
-
-
-@dataclass
-class AtlasResult:
- project_name: str
- project_path: str
- generated_at: str
- total_files: int
- total_lines: int
- languages: dict[str, int] # language -> file count
- files: list[FileInfo] = field(default_factory=list)
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "project_name": self.project_name,
- "project_path": self.project_path,
- "generated_at": self.generated_at,
- "total_files": self.total_files,
- "total_lines": self.total_lines,
- "languages": self.languages,
- "files": [f.to_dict() for f in self.files],
- }
-
-
-# ---------------------------------------------------------------------------
-# Python AST scanner
-# ---------------------------------------------------------------------------
-
-class PythonScanner:
- """Extracts structured information from Python source using AST."""
-
- @staticmethod
- def _get_docstring(node: ast.AST) -> str:
- doc = ast.get_docstring(node)
- return doc or ""
-
- @staticmethod
- def _get_decorator_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
- return [ast.unparse(d) if hasattr(ast, "unparse") else "" for d in getattr(node, "decorator_list", [])]
-
- @staticmethod
- def _is_private(name: str) -> bool:
- return name.startswith("_") and not name.startswith("__")
-
- @staticmethod
- def _is_dunder(name: str) -> bool:
- return name.startswith("__") and name.endswith("__")
-
- @staticmethod
- def _get_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
- try:
- sig = ast.get_source_segment
- # Fallback: build from ast node
- args = [a.arg for a in node.args.args]
- vararg = f"*{node.args.vararg.arg}" if node.args.vararg else ""
- kwarg = f"**{node.args.kwarg.arg}" if node.args.kwarg else ""
- defaults = node.args.defaults
- params = args[: len(args) - len(defaults)] if defaults else args
- defaults_start = len(params)
- params_with_defaults = []
- for i, arg in enumerate(args):
- if i >= defaults_start:
- default = defaults[i - defaults_start]
- try:
- default_str = ast.unparse(default)
- except Exception:
- default_str = "?"
- params_with_defaults.append(f"{arg}={default_str}")
- else:
- params_with_defaults.append(arg)
- params_str = ", ".join(params_with_defaults)
- if vararg:
- params_str = f"{params_str}, {vararg}" if params_str else vararg
- if kwarg:
- params_str = f"{params_str}, {kwarg}" if params_str else kwarg
- async_kw = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
- return f"{async_kw}def {node.name}({params_str})"
- except Exception:
- return f"def {node.name}(...)"
-
- def scan(self, content: str, file_path: Path, relative_path: str) -> FileInfo:
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language="Python",
- total_lines=0,
- code_lines=0,
- blank_lines=0,
- comment_lines=0,
- docstring="",
- )
-
- lines = content.splitlines()
- info.total_lines = len(lines)
- info.blank_lines = sum(1 for l in lines if not l.strip())
- info.comment_lines = sum(
- 1 for l in lines if l.strip().startswith("#") or l.strip().startswith('"""') or l.strip().startswith("'''")
- )
-
- info.code_lines = info.total_lines - info.blank_lines
-
- try:
- tree = ast.parse(content, filename=str(file_path))
- except SyntaxError:
- return info
-
- # Module docstring
- if (
- tree.body
- and isinstance(tree.body[0], (ast.Expr, ast.Constant))
- and isinstance(tree.body[0].value, (ast.Str, ast.Constant))
- ):
- if isinstance(tree.body[0].value, ast.Str):
- info.docstring = tree.body[0].value.s or ""
- elif isinstance(tree.body[0].value, ast.Constant) and isinstance(tree.body[0].value.value, str):
- info.docstring = tree.body[0].value.value or ""
-
- # Imports
- for node in ast.walk(tree):
- if isinstance(node, ast.Import):
- module = ""
- names = []
- for alias in node.names:
- names.append(alias.asname or alias.name)
- module = alias.name.split(".")[0]
- info.imports.append(ImportInfo(module=module, names=names, line_number=node.lineno or 0))
- elif isinstance(node, ast.ImportFrom):
- module = node.module or ""
- names = [a.asname or a.name for a in node.names]
- info.imports.append(ImportInfo(module=module, names=names, line_number=node.lineno or 0))
-
- # Classes and their methods
- for node in tree.body:
- if isinstance(node, ast.ClassDef):
- bases = []
- for b in node.bases:
- try:
- bases.append(ast.unparse(b))
- except Exception:
- bases.append("")
- decorators = self._get_decorator_names(node)
- visibility = "private" if self._is_private(node.name) else "public"
-
- methods = []
- for item in node.body:
- if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
- m = FunctionInfo(
- name=item.name,
- line_number=item.lineno or 0,
- signature=self._get_signature(item),
- docstring=self._get_docstring(item),
- visibility="private" if self._is_private(item.name) else "public",
- )
- methods.append(m)
-
- cls = ClassInfo(
- name=node.name,
- line_number=node.lineno or 0,
- docstring=self._get_docstring(node),
- bases=bases,
- methods=methods,
- visibility=visibility,
- )
- info.classes.append(cls)
-
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- if not self._is_dunder(node.name):
- fn = FunctionInfo(
- name=node.name,
- line_number=node.lineno or 0,
- signature=self._get_signature(node),
- docstring=self._get_docstring(node),
- visibility="private" if self._is_private(node.name) else "public",
- )
- info.functions.append(fn)
-
- # Feature tagging
- info.feature_tags = self._detect_features(info)
-
- return info
-
- def _detect_features(self, info: FileInfo) -> list[str]:
- tags = []
- imports = {imp.module: imp.names for imp in info.imports}
-
- # Web frameworks
- if "flask" in imports or any("flask" in str(v) for v in imports.values()):
- tags.append("Flask")
- if "fastapi" in imports or any("fastapi" in str(v) for v in imports.values()):
- tags.append("FastAPI")
- if "django" in imports or any("django" in str(v) for v in imports.values()):
- tags.append("Django")
- if "pytest" in imports or any("pytest" in str(v) for v in imports.values()):
- tags.append("pytest")
- if "unittest" in imports or any("unittest" in str(v) for v in imports.values()):
- tags.append("unittest")
- if "pydantic" in imports or any("pydantic" in str(v) for v in imports.values()):
- tags.append("Pydantic")
- if "sqlalchemy" in imports or any("sqlalchemy" in str(v) for v in imports.values()):
- tags.append("SQLAlchemy")
- if "requests" in imports or any("requests" in str(v) for v in imports.values()):
- tags.append("HTTP Client")
- if "aiohttp" in imports or "httpx" in imports:
- tags.append("Async HTTP")
- if "redis" in imports:
- tags.append("Redis")
- if "celery" in imports:
- tags.append("Celery")
- if "numpy" in imports or "pandas" in imports:
- tags.append("Data Science")
-
- return tags
-
-
-# ---------------------------------------------------------------------------
-# JavaScript / TypeScript scanner
-# ---------------------------------------------------------------------------
-
-class JavaScriptScanner:
- """Extracts structured information from JavaScript/TypeScript using regex."""
-
- # Named function: function myFunc() {
- JS_FUNC_NAMED = re.compile(r"function\s+(\w+)\s*\([^)]*\)\s*\{", re.MULTILINE)
- # Export named function: export function myFunc() {
- JS_FUNC_EXPORT = re.compile(r"export\s+function\s+(\w+)\s*\([^)]*\)\s*\{", re.MULTILINE)
- # Arrow function with name: const myFunc = (...) => {
- JS_ARROW_NAMED = re.compile(r"(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{", re.MULTILINE)
- # Arrow function with name: const myFunc = async (...) => {
- JS_ARROW_ASYNC = re.compile(r"(?:const|let|var)\s+(\w+)\s*=\s*async\s+\([^)]*\)\s*=>\s*\{", re.MULTILINE)
- # Method shorthand: myMethod() {
- JS_METHOD = re.compile(r"^\s*(\w+)\s*\([^)]*\)\s*\{", re.MULTILINE)
- # Class definition
- JS_CLASS_RE = re.compile(
- r"(?:export\s+)?(?:abstract\s+)?class\s+(\w+)"
- r"(?:\s+extends\s+(\w+))?"
- r"(?:\s+implements\s+[\w, ]+)?\s*\{",
- re.MULTILINE,
- )
- JS_IMPORT_RE = re.compile(r"import\s+(?:{[^}]+}|(\w+)|(\*\s+as\s+\w+)|\*)\s*from\s+['\"]([^'\"]+)['\"]", re.MULTILINE)
- TS_DECOR_RE = re.compile(r"@(\w+)", re.MULTILINE)
-
- def scan(self, content: str, file_path: Path, relative_path: str) -> FileInfo:
- ext = file_path.suffix
- language = LANGUAGE_MAP.get(ext, "JavaScript")
- lines = content.splitlines()
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language=language,
- total_lines=len(lines),
- code_lines=len([l for l in lines if l.strip() and not l.strip().startswith("//")]),
- blank_lines=sum(1 for l in lines if not l.strip()),
- comment_lines=sum(1 for l in lines if l.strip().startswith("//")),
- docstring="",
- )
-
- # Extract module docstring (first block comment)
- doc_match = re.search(r"/\*\*[\s\S]*?\*/", content)
- if doc_match:
- info.docstring = re.sub(r"[\s/*#]+", " ", doc_match.group()).strip()
-
- # Imports
- for match in self.JS_IMPORT_RE.finditer(content):
- module = match.group(3) or ""
- names = []
- if match.group(1):
- names.append(match.group(1))
- if match.group(2):
- names.extend(match.group(2).split())
- info.imports.append(ImportInfo(module=module.split("/")[-1], names=names, line_number=content[: match.start()].count("\n") + 1))
-
- # Classes
- for match in self.JS_CLASS_RE.finditer(content):
- name = match.group(1)
- bases = [match.group(2)] if match.group(2) else []
- line = content[: match.start()].count("\n") + 1
- # Find methods inside the class
- methods = self._extract_js_methods(content, match.end())
- info.classes.append(
- ClassInfo(
- name=name,
- line_number=line,
- docstring="",
- bases=bases,
- methods=methods,
- visibility="private" if name.startswith("_") else "public",
- )
- )
-
- # Functions: export function name() {
- for match in self.JS_FUNC_EXPORT.finditer(content):
- name = match.group(1)
- if not name.startswith("_"):
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(
- name=name,
- line_number=line,
- signature=f"export function {name}(...)",
- docstring="",
- visibility="private" if name.startswith("_") else "public",
- )
- )
-
- # Functions: function name() {
- for match in self.JS_FUNC_NAMED.finditer(content):
- name = match.group(1)
- if not name.startswith("_") and not any(content[max(0, match.start() - 20) : match.start()].endswith(k) for k in ["export", "async"]):
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(
- name=name,
- line_number=line,
- signature=f"function {name}(...)",
- docstring="",
- visibility="private" if name.startswith("_") else "public",
- )
- )
-
- # Arrow functions: const name = (...) => {
- for match in self.JS_ARROW_NAMED.finditer(content):
- name = match.group(1)
- if not name.startswith("_"):
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(
- name=name,
- line_number=line,
- signature=f"const {name} = (...) => {{...}}",
- docstring="",
- visibility="private" if name.startswith("_") else "public",
- )
- )
-
- # Async arrow functions: const name = async (...) => {
- for match in self.JS_ARROW_ASYNC.finditer(content):
- name = match.group(1)
- if not name.startswith("_"):
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(
- name=name,
- line_number=line,
- signature=f"const {name} = async (...) => {{...}}",
- docstring="",
- visibility="private" if name.startswith("_") else "public",
- )
- )
-
- info.feature_tags = self._detect_features(info)
- return info
-
- def _extract_js_methods(self, content: str, start: int) -> list[FunctionInfo]:
- methods = []
- method_re = re.compile(r"(\w+)\s*\([^)]*\)\s*\{", re.MULTILINE)
- depth = 1
- pos = start
- while pos < len(content) and depth > 0:
- char = content[pos]
- if char == "{":
- depth += 1
- elif char == "}":
- depth -= 1
- if depth == 0:
- break
- pos += 1
- class_body = content[start:pos]
- for m in method_re.finditer(class_body):
- name = m.group(1)
- if name not in {"if", "else", "for", "while", "switch", "try", "catch"}:
- line = class_body[: m.start()].count("\n") + start
- methods.append(
- FunctionInfo(
- name=name,
- line_number=line,
- signature=f"{name}(...)",
- docstring="",
- visibility="private" if name.startswith("_") else "public",
- )
- )
- return methods
-
- def _detect_features(self, info: FileInfo) -> list[str]:
- tags = []
- content = str(info.relative_path)
- imports_str = " ".join(" ".join(i.names) for i in info.imports)
-
- if "react" in imports_str.lower() or "React" in imports_str:
- tags.append("React")
- if "vue" in imports_str.lower():
- tags.append("Vue")
- if "angular" in imports_str.lower():
- tags.append("Angular")
- if "next" in imports_str.lower() or "next" in content.lower():
- tags.append("Next.js")
- if "nuxt" in imports_str.lower():
- tags.append("Nuxt")
- if "express" in imports_str.lower():
- tags.append("Express")
- if "jest" in imports_str.lower():
- tags.append("Jest")
- if "vitest" in imports_str.lower():
- tags.append("Vitest")
- if "axios" in imports_str.lower():
- tags.append("Axios")
- if "tailwind" in imports_str.lower():
- tags.append("Tailwind CSS")
- if "node" in imports_str.lower():
- tags.append("Node.js")
- if "redux" in imports_str.lower() or "zustand" in imports_str.lower():
- tags.append("State Management")
- if "@tanstack" in imports_str or "tanstack" in imports_str:
- tags.append("TanStack")
-
- return tags
-
-
-# ---------------------------------------------------------------------------
-# Rust scanner
-# ---------------------------------------------------------------------------
-
-class RustScanner:
- """Extracts structured information from Rust source files."""
-
- FN_RE = re.compile(r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)", re.MULTILINE)
- IMPL_RE = re.compile(r"(?:pub\s+)?impl(?:<[^>]+>)?\s+(?:\w+::)?(\w+)", re.MULTILINE)
- STRUCT_RE = re.compile(r"(?:pub\s+)?struct\s+(\w+)(?:<[^>]+>)?", re.MULTILINE)
- ENUM_RE = re.compile(r"(?:pub\s+)?enum\s+(\w+)", re.MULTILINE)
- MOD_RE = re.compile(r"(?:pub\s+)?mod\s+(\w+)", re.MULTILINE)
- USE_RE = re.compile(r"use\s+([\w:]+)", re.MULTILINE)
- PUB_RE = re.compile(r"(pub)\s+", re.MULTILINE)
-
- def scan(self, content: str, file_path: Path, relative_path: str) -> FileInfo:
- lines = content.splitlines()
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language="Rust",
- total_lines=len(lines),
- code_lines=len([l for l in lines if l.strip() and not l.strip().startswith("//")]),
- blank_lines=sum(1 for l in lines if not l.strip()),
- comment_lines=sum(1 for l in lines if l.strip().startswith("//")),
- docstring="",
- )
-
- # Module docstring
- doc_lines = []
- for line in lines[:10]:
- stripped = line.strip()
- if stripped.startswith("///") or stripped.startswith("/*"):
- doc_lines.append(strip(stripped, "#/*"))
- elif stripped.startswith("//!") or stripped.startswith("/*!"):
- doc_lines.append(strip(stripped, "//!/*!"))
- elif doc_lines:
- break
- if doc_lines:
- info.docstring = " ".join(doc_lines)
-
- # Imports
- for match in self.USE_RE.finditer(content):
- module = match.group(1).split("::")[0]
- line = content[: match.start()].count("\n") + 1
- info.imports.append(ImportInfo(module=module, names=[match.group(1)], line_number=line))
-
- # Functions
- for match in self.FN_RE.finditer(content):
- name = match.group(1)
- if not name.startswith("_") and name not in {"if", "match", "while"}:
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(name=name, line_number=line, signature=f"fn {name}(...)", docstring="", visibility="private" if name.startswith("_") else "public")
- )
-
- # Structs
- info.feature_tags.append("Struct")
- return info
-
-
-# ---------------------------------------------------------------------------
-# Java / C / C++ scanner
-# ---------------------------------------------------------------------------
-
-class JavaLikeScanner:
- """Scanner for Java, C, C#, and similar brace-delimited languages."""
-
- CLASS_RE = re.compile(r"(?:public|private|protected|abstract|final|\s)+(?:class|interface|enum|struct)\s+(\w+)", re.MULTILINE)
- METHOD_RE = re.compile(
- r"(?:public|private|protected|static|\s)+"
- r"(?:[\w<>,\s]+\s+)?(\w+)\s*\(",
- re.MULTILINE,
- )
- IMPORT_RE = re.compile(r"import\s+([\w.]+)", re.MULTILINE)
-
- def scan(self, content: str, file_path: Path, relative_path: str, language: str) -> FileInfo:
- lines = content.splitlines()
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language=language,
- total_lines=len(lines),
- code_lines=len([l for l in lines if l.strip() and not l.strip().startswith("//")]),
- blank_lines=sum(1 for l in lines if not l.strip()),
- comment_lines=sum(1 for l in lines if l.strip().startswith("//")),
- docstring="",
- )
-
- # Imports
- for match in self.IMPORT_RE.finditer(content):
- module = match.group(1).split(".")[-1]
- line = content[: match.start()].count("\n") + 1
- info.imports.append(ImportInfo(module=module, names=[match.group(1)], line_number=line))
-
- # Classes
- for match in self.CLASS_RE.finditer(content):
- name = match.group(1)
- line = content[: match.start()].count("\n") + 1
- methods = self._extract_methods(content, match.end())
- info.classes.append(
- ClassInfo(
- name=name,
- line_number=line,
- docstring="",
- bases=[],
- methods=methods,
- visibility="private" if name[0].islower() else "public",
- )
- )
-
- # Standalone functions/methods
- for match in self.METHOD_RE.finditer(content):
- name = match.group(1)
- if name not in {"if", "else", "for", "while", "switch", "try", "catch", "class", "interface", "enum"}:
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(name=name, line_number=line, signature=f"{name}(...)", docstring="", visibility="private" if name[0].islower() else "public")
- )
-
- return info
-
- def _extract_methods(self, content: str, start: int) -> list[FunctionInfo]:
- methods = []
- depth = 0
- for i, char in enumerate(content[start:], start=start):
- if char == "{":
- depth += 1
- elif char == "}":
- depth -= 1
- if depth < 0:
- break
- return []
-
-
-# ---------------------------------------------------------------------------
-# Go scanner
-# ---------------------------------------------------------------------------
-
-class GoScanner:
- """Scanner for Go source files."""
-
- FUNC_RE = re.compile(r"func\s+(\w+)\s*\(", re.MULTILINE)
- FUNC_RECEIVER_RE = re.compile(r"func\s+\(([^)]+)\)\s+(\w+)\s*\(", re.MULTILINE)
- IMPORT_RE = re.compile(r"import\s+(?:\(([^)]+)\)|\"([^\"]+)\")", re.MULTILINE)
- PACKAGE_RE = re.compile(r"package\s+(\w+)", re.MULTILINE)
-
- def scan(self, content: str, file_path: Path, relative_path: str) -> FileInfo:
- lines = content.splitlines()
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language="Go",
- total_lines=len(lines),
- code_lines=len([l for l in lines if l.strip() and not l.strip().startswith("//")]),
- blank_lines=sum(1 for l in lines if not l.strip()),
- comment_lines=sum(1 for l in lines if l.strip().startswith("//")),
- docstring="",
- )
-
- # Package docstring
- doc_lines = []
- for line in lines:
- if line.strip().startswith("//"):
- doc_lines.append(line.strip().lstrip("//").strip())
- elif doc_lines:
- break
- if doc_lines:
- info.docstring = " ".join(doc_lines)
-
- # Imports
- for match in self.IMPORT_RE.finditer(content):
- group = match.group(1) or match.group(2) or ""
- for imp in re.findall(r'"([^"]+)"', group):
- info.imports.append(ImportInfo(module=imp.split("/")[-1], names=[imp], line_number=content[: match.start()].count("\n") + 1))
-
- # Functions with receiver (methods)
- for match in self.FUNC_RECEIVER_RE.finditer(content):
- receiver = match.group(1)
- name = match.group(2)
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(
- name=f"{receiver}.{name}",
- line_number=line,
- signature=f"func ({receiver}) {name}(...)",
- docstring="",
- visibility="private" if receiver[0].islower() else "public",
- )
- )
-
- # Standalone functions
- for match in self.FUNC_RE.finditer(content):
- if ")" not in match.group(0)[:-1]:
- name = match.group(1)
- if name not in {"if", "for", "switch"}:
- line = content[: match.start()].count("\n") + 1
- info.raw_functions.append(
- FunctionInfo(name=name, line_number=line, signature=f"func {name}(...)", docstring="", visibility="private" if name[0].islower() else "public")
- )
-
- return info
-
-
-# ---------------------------------------------------------------------------
-# HTML / Markup scanner
-# ---------------------------------------------------------------------------
-
-class MarkupScanner:
- """Scanner for HTML, Vue, Svelte, XML markup files."""
-
- TAG_RE = re.compile(r"<([a-zA-Z][a-zA-Z0-9.-]*)(?:\s[^>]*)?>", re.MULTILINE)
- ID_CLASS_RE = re.compile(r'\s(?:id|class)="([^"]+)"', re.MULTILINE)
- SCRIPT_RE = re.compile(r"", re.MULTILINE)
- STYLE_RE = re.compile(r"", re.MULTILINE)
- VUE_COMP_RE = re.compile(r"export\s+default\s+\{", re.MULTILINE)
- SVELTE_EXPORT_RE = re.compile(r"", re.MULTILINE)
-
- def scan(self, content: str, file_path: Path, relative_path: str) -> FileInfo:
- ext = file_path.suffix
- language = LANGUAGE_MAP.get(ext, "HTML")
- lines = content.splitlines()
-
- info = FileInfo(
- path=file_path,
- relative_path=relative_path,
- language=language,
- total_lines=len(lines),
- code_lines=len([l for l in lines if l.strip() and not l.strip().startswith("\n"
- # Remove old marker and trailing newline
- content = re.sub(r"\s*" + re.escape(OBSIDIAN_SYNC_MARKER) + r".*?-->\n?", "\n", content).rstrip()
- return content + marker
-
-
-def _get_last_sync(content: str) -> str | None:
- m = re.search(re.escape(OBSIDIAN_SYNC_MARKER) + r"([^>]+) --", content)
- return m.group(1) if m else None
-
-
-def _render_project_brief(store: StateStore) -> str:
- brief = store.get_project_brief()
- lines = [
- "# Project Brief",
- "",
- "> Generated from obsmcp structured state. Edit through obsmcp tools or CLI to keep state consistent.",
- "",
- ]
- for section, content in brief.items():
- lines.extend([f"## {section}", "", content or "No content yet.", ""])
- return "\n".join(lines)
-
-
-def _render_current_task(store: StateStore) -> str:
- task = store.get_current_task()
- if not task:
- return "# Current Task\n\nNo current task is set.\n"
- progress = store.get_checkpoint_progress(task["id"])
- checkpoints = store.get_checkpoints_for_task(task["id"], limit=store.config.checkpoints.render_limit)
- lines = [
- "# Current Task",
- "",
- f"- ID: {task['id']}",
- f"- Title: {task['title']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- f"- Owner: {task.get('owner') or 'unassigned'}",
- "",
- "## Description",
- "",
- task["description"],
- "",
- "## Relevant Files",
- "",
- ]
- if task["relevant_files"]:
- lines.extend(f"- {path}" for path in task["relevant_files"])
- else:
- lines.append("- None recorded")
- lines.extend(
- [
- "",
- "## Checkpoint Progress",
- "",
- (
- f"- Progress: {progress['completed_count']}/{progress['total_count']}"
- if progress.get("total_count") is not None
- else f"- Completed checkpoints: {progress['completed_count']}"
- ),
- f"- Latest checkpoint: {progress.get('latest_completed_at') or 'none'}",
- "",
- "## Phase Rollup",
- "",
- ]
- )
- if progress.get("phase_rollups"):
- for item in progress["phase_rollups"]:
- if item.get("total_count") is not None:
- lines.append(f"- {item['phase_key']}: {item['completed_count']}/{item['total_count']} complete")
- else:
- lines.append(f"- {item['phase_key']}: {item['completed_count']} complete")
- else:
- lines.append("- None recorded")
- lines.extend(
- [
- "",
- "## Recent Checkpoints",
- "",
- ]
- )
- if checkpoints:
- for item in checkpoints:
- lines.append(f"- {item['checkpoint_id']}: {item['title']} ({item['created_at']})")
- else:
- lines.append("- None recorded")
- lines.append("")
- return "\n".join(lines)
-
-
-def _render_status_snapshot(store: StateStore) -> str:
- snapshot = store.get_project_status_snapshot()
- current_task = snapshot["current_task"]
- lines = [
- "# Status Snapshot",
- "",
- f"- App: {snapshot['app_name']}",
- f"- Current Task: {current_task['id'] if current_task else 'none'}",
- f"- Active Tasks: {len(snapshot['active_tasks'])}",
- f"- Open Blockers: {len(snapshot['blockers'])}",
- f"- Recent Decisions: {len(snapshot['decisions'])}",
- f"- Recent Checkpoints: {len(snapshot.get('recent_checkpoints', []))}",
- (
- f"- Current Task Progress: {snapshot['current_task_progress']['completed_count']}/{snapshot['current_task_progress']['total_count']}"
- if snapshot.get("current_task_progress") and snapshot["current_task_progress"].get("total_count") is not None
- else (
- f"- Current Task Completed Checkpoints: {snapshot['current_task_progress']['completed_count']}"
- if snapshot.get("current_task_progress")
- else "- Current Task Completed Checkpoints: none"
- )
- ),
- "",
- "## Relevant Files",
- "",
- ]
- if snapshot["relevant_files"]:
- lines.extend(f"- {path}" for path in snapshot["relevant_files"])
- else:
- lines.append("- None recorded")
- lines.extend(["", "## Recent Checkpoints", ""])
- if snapshot.get("recent_checkpoints"):
- for item in snapshot["recent_checkpoints"]:
- lines.append(f"- {item['checkpoint_id']}: {item['title']} ({item['created_at']})")
- else:
- lines.append("- None recorded")
- lines.append("")
- return "\n".join(lines)
-
-
-def _render_latest_handoff(store: StateStore) -> str:
- handoff = store.get_latest_handoff()
- if not handoff:
- return "# Latest Handoff\n\nNo handoff recorded yet.\n"
- lines = [
- "# Latest Handoff",
- "",
- f"- ID: {handoff['id']}",
- f"- Task: {handoff['task_id'] or 'unassigned'}",
- f"- From: {handoff['from_actor']}",
- f"- To: {handoff['to_actor']}",
- f"- Created: {handoff['created_at']}",
- "",
- "## Summary",
- "",
- handoff["summary"],
- "",
- "## Next Steps",
- "",
- handoff["next_steps"] or "None recorded.",
- "",
- "## Open Questions",
- "",
- handoff["open_questions"] or "None recorded.",
- "",
- "## Notes",
- "",
- handoff["note"] or "None recorded.",
- "",
- ]
- return "\n".join(lines)
-
-
-def _render_decision_index(store: StateStore) -> str:
- decisions = store.get_decisions(limit=50)
- lines = [
- "# Decision Log",
- "",
- "> Generated from obsmcp structured state. Individual ADR-style notes are generated below in the same folder.",
- "",
- ]
- if not decisions:
- lines.extend(["No decisions recorded yet.", ""])
- return "\n".join(lines)
-
- for item in decisions:
- lines.append(f"- ADR-{item['id']:04d}: {item['title']} ({item['created_at']})")
- lines.append("")
- return "\n".join(lines)
-
-
-def _render_decision_note(item: dict[str, str]) -> str:
- return "\n".join(
- [
- f"# ADR-{item['id']:04d} {item['title']}",
- "",
- f"- Created: {item['created_at']}",
- f"- Actor: {item['actor']}",
- f"- Task: {item['task_id'] or 'unassigned'}",
- "",
- "## Decision",
- "",
- item["decision"] or "Not recorded.",
- "",
- "## Rationale",
- "",
- item["rationale"] or "Not recorded.",
- "",
- "## Impact",
- "",
- item["impact"] or "Not recorded.",
- "",
- ]
- )
-
-
-def _render_architecture_map(store: StateStore) -> str:
- stats = store.get_symbol_index_stats()
- modules = store.get_cached_semantic_descriptions(entity_type="module", fresh_only=True, limit=8)
- lines = [
- "# Architecture Map",
- "",
- "> Machine-generated from the semantic knowledge cache. Use semantic MCP tools or `ctx describe ...` to refresh details.",
- "",
- "## Semantic Coverage",
- "",
- ]
- for key, value in stats.get("entity_counts", {}).items():
- lines.append(f"- {key.title()}: {value}")
- lines.extend(["", f"- Tracked files: {stats.get('tracked_files', 0)}", "", "## Key Modules", ""])
- if modules:
- for item in modules:
- lines.append(f"- `{item['file_path']}`: {item['purpose']}")
- else:
- lines.append("- No semantic module summaries cached yet.")
- lines.append("")
- return "\n".join(lines)
-
-
-def _render_module_summaries(store: StateStore) -> str:
- modules = store.get_cached_semantic_descriptions(entity_type="module", fresh_only=True, limit=20)
- lines = ["# Module Summaries", ""]
- if not modules:
- lines.extend(["No module summaries cached yet.", ""])
- return "\n".join(lines)
- for item in modules:
- lines.extend(
- [
- f"## `{item['file_path']}`",
- "",
- item["purpose"],
- "",
- f"- Why: {item['why_it_exists']}",
- f"- Inputs/Outputs: {item['inputs_outputs']}",
- f"- Risks: {item['risks']}",
- "",
- ]
- )
- return "\n".join(lines)
-
-
-def _render_feature_map(store: StateStore) -> str:
- features = store.get_cached_semantic_descriptions(entity_type="feature", fresh_only=True, limit=20)
- lines = ["# Feature Map", ""]
- if not features:
- lines.extend(["No feature summaries cached yet.", ""])
- return "\n".join(lines)
- for item in features:
- lines.extend(
- [
- f"## {item['name']}",
- "",
- item["purpose"],
- "",
- f"- Why: {item['why_it_exists']}",
- f"- Related Files: {', '.join(item['related_files']) or 'None'}",
- "",
- ]
- )
- return "\n".join(lines)
-
-
-def sync_obsidian(config: AppConfig, store: StateStore, project_config: ProjectConfig) -> None:
- vault = project_config.vault_path
- for relative_dir in ["Projects", "Handoffs", "Decisions", "Daily", "Research", "Research/Symbol Knowledge", "Debug", "Sessions"]:
- (vault / relative_dir).mkdir(parents=True, exist_ok=True)
-
- # Check if Code Atlas exists and add its status to Research index
- atlas_path = vault / config.obsidian.code_atlas_note
- atlas_status_path = vault / "Research" / "Code Atlas Status.md"
- if atlas_path.exists():
- mtime = datetime.fromtimestamp(atlas_path.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
- atlas_content = _inject_sync_marker(f"""# Code Atlas
-
-> The Code Atlas is a project-wide map of all source files, functions, classes, and features.
-
-- **Atlas file:** `{config.obsidian.code_atlas_note}`
-- **Last updated:** {mtime}
-
-## Quick Reference
-
-The Code Atlas documents:
-- Every file in the project (scanned on demand)
-- All functions and classes with signatures
-- Imports and dependencies
-- Language distribution
-- Feature tags (e.g., "FastAPI", "React", "Redis")
-- Largest files and code statistics
-
-## Generating / Refreshing
-
-Run `scan_codebase()` via MCP or `ctx.bat atlas refresh` to regenerate.
-
-The atlas uses a **hybrid refresh strategy**: it only regenerates if a source file was modified after the atlas was last built, or if you force a refresh.
-
----
-*Auto-generated by obsmcp*
-""")
- write_text_atomic(atlas_status_path, atlas_content)
-
- write_text_atomic(vault / config.obsidian.project_brief_note, _inject_sync_marker(_render_project_brief(store)))
- write_text_atomic(vault / config.obsidian.current_task_note, _inject_sync_marker(_render_current_task(store)))
- write_text_atomic(vault / config.obsidian.status_snapshot_note, _inject_sync_marker(_render_status_snapshot(store)))
- write_text_atomic(vault / config.obsidian.latest_handoff_note, _inject_sync_marker(_render_latest_handoff(store)))
- write_text_atomic(vault / config.obsidian.decision_index_note, _inject_sync_marker(_render_decision_index(store)))
-
- for decision in store.get_decisions(limit=100):
- decision_path = vault / "Decisions" / f"ADR-{decision['id']:04d}.md"
- write_text_atomic(decision_path, _inject_sync_marker(_render_decision_note(decision)))
-
- write_text_atomic(vault / "Research" / "Architecture Map.md", _inject_sync_marker(_render_architecture_map(store)))
- write_text_atomic(vault / "Research" / "Module Summaries.md", _inject_sync_marker(_render_module_summaries(store)))
- write_text_atomic(vault / "Research" / "Feature Map.md", _inject_sync_marker(_render_feature_map(store)))
-
- for item in store.get_cached_semantic_descriptions(fresh_only=True, limit=40):
- name = item["entity_key"].replace(":", "__").replace("/", "_")
- note_path = vault / "Research" / "Symbol Knowledge" / f"{name}.md"
- write_text_atomic(note_path, _inject_sync_marker(render_semantic_note(item)))
-
- latest_summary = store.get_latest_session_summary()
- session_content = "# Latest Session Summary\n\nNo session summary recorded yet.\n"
- if latest_summary:
- session_content = _inject_sync_marker(
- "# Latest Session Summary\n\n"
- f"- Label: {latest_summary['session_label']}\n"
- f"- Actor: {latest_summary['actor']}\n"
- f"- Created: {latest_summary['created_at']}\n\n"
- f"{latest_summary['summary']}\n"
- )
- else:
- session_content = _inject_sync_marker("# Latest Session Summary\n\nNo session summary recorded yet.\n")
- write_text_atomic(vault / config.obsidian.session_note, session_content)
-
- for entry in store.get_daily_entries(limit=200):
- path = vault / config.obsidian.daily_notes_dir / f"{entry['note_date']}.md"
- existing = path.read_text(encoding="utf-8") if path.exists() else f"# {entry['note_date']}\n\n"
- marker = f"- {entry['created_at']} [{entry['actor']}] {entry['entry']}"
- if marker not in existing:
- content = existing.rstrip() + "\n" + marker + "\n"
- write_text_atomic(path, _inject_sync_marker(content))
-
-
-# ------------------------------------------------------------------------------------------------
-# Bidirectional: Pull changes from Obsidian back into obsmcp state
-# ------------------------------------------------------------------------------------------------
-
-
-def _parse_daily_entries_since(content: str, last_sync: str | None) -> list[dict[str, str]]:
- """
- Parse new daily entries appended after last_sync timestamp.
- Looks for lines like: `- 2026-04-12T10:30:00Z [actor] entry text`
- """
- entries: list[dict[str, str]] = []
- # Match bullet lines with ISO timestamp and actor tag
- pattern = re.compile(r"^\s*-\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?)\s*\[([^\]]+)\]\s*(.+)$")
- for line in content.splitlines():
- m = pattern.match(line)
- if not m:
- continue
- ts, actor, entry_text = m.group(1), m.group(2).strip(), m.group(3).strip()
- if last_sync and ts <= last_sync:
- continue
- entries.append({"entry": entry_text, "actor": actor, "created_at": ts})
- return entries
-
-
-def _parse_project_brief_changes(content: str) -> dict[str, str]:
- """
- Parse the Project Brief note for manually-edited task descriptions.
- Extracts ## Task title lines into a dict for upserting into task state.
- """
- changes: dict[str, str] = {}
- current_section = ""
- current_body: list[str] = []
- for line in content.splitlines():
- if line.startswith("## "):
- if current_section:
- changes[current_section] = "\n".join(current_body).strip()
- current_section = line[3:].strip()
- current_body = []
- elif current_section:
- current_body.append(line)
- if current_section:
- changes[current_section] = "\n".join(current_body).strip()
- return changes
-
-
-def pull_obsidian_changes(
- config: AppConfig,
- store: StateStore,
- project_config: ProjectConfig,
-) -> dict[str, Any]:
- """
- Scan the Obsidian vault for externally-made changes since the last sync.
- Returns a dict describing what was pulled and applied.
-
- Conflict resolution:
- - Agent-written notes have marker
- - Notes without a marker or modified after the marker are treated as external edits
- - Daily entries appended after the sync marker are parsed and upserted
- """
- vault = project_config.vault_path
- pulled: dict[str, Any] = {
- "daily_entries": [],
- "decision_notes_found": 0,
- "project_brief_sections": {},
- "notes_scanned": 0,
- }
-
- # Pull daily entries from daily notes directory
- daily_dir = vault / config.obsidian.daily_notes_dir
- if daily_dir.is_dir():
- for note_path in sorted(daily_dir.glob("*.md")):
- try:
- content = read_text_with_retry(note_path)
- except OSError:
- continue
- pulled["notes_scanned"] += 1
- last_sync = _get_last_sync(content)
- new_entries = _parse_daily_entries_since(content, last_sync)
- for entry_data in new_entries:
- # Avoid duplicates by checking store
- existing = store.get_daily_entries(limit=500)
- already_there = any(
- e["actor"] == entry_data["actor"] and e["entry"] == entry_data["entry"] and e.get("note_date", "").startswith(note_path.stem)
- for e in existing
- )
- if not already_there:
- date_str = note_path.stem # YYYY-MM-DD
- store.create_daily_note_entry(
- entry=entry_data["entry"],
- actor=entry_data["actor"],
- note_date=date_str,
- )
- pulled["daily_entries"].append({**entry_data, "note_date": date_str})
-
- # Pull project brief section edits — check for externally-edited sections
- brief_path = vault / config.obsidian.project_brief_note
- if brief_path.exists():
- try:
- content = read_text_with_retry(brief_path)
- pulled["notes_scanned"] += 1
- last_sync = _get_last_sync(content)
- # Only pull if note was modified externally (no marker or marker older than file mtime)
- if last_sync is None:
- # Never synced — user created it externally
- section_changes = _parse_project_brief_changes(content)
- if section_changes:
- pulled["project_brief_sections"] = section_changes
- except OSError:
- pass
-
- # Count new decision notes (ADR-*.md) not yet in store
- decisions_dir = vault / "Decisions"
- if decisions_dir.is_dir():
- for note_path in sorted(decisions_dir.glob("ADR-*.md")):
- try:
- content = read_text_with_retry(note_path)
- except OSError:
- continue
- pulled["notes_scanned"] += 1
- # Check if this ADR is already in the store
- id_str = note_path.stem # e.g., ADR-0001
- existing_decisions = store.get_decisions(limit=500)
- already_exists = any(d.get("title", "").startswith(id_str.replace("-", " ")) for d in existing_decisions)
- if not already_exists:
- # New decision note created externally — flag for review
- pulled["decision_notes_found"] += 1
-
- return pulled
diff --git a/server/obsmcp_server/__init__.py b/server/obsmcp_server/__init__.py
new file mode 100644
index 0000000..81cf960
--- /dev/null
+++ b/server/obsmcp_server/__init__.py
@@ -0,0 +1,3 @@
+"""OBSMCP backend — FastAPI + SQLite + SSE."""
+
+__version__ = "0.1.0"
diff --git a/server/obsmcp_server/auth.py b/server/obsmcp_server/auth.py
new file mode 100644
index 0000000..45151f2
--- /dev/null
+++ b/server/obsmcp_server/auth.py
@@ -0,0 +1,49 @@
+"""Bearer-token auth middleware.
+
+If ``OBSMCP_API_TOKEN`` is unset, auth is skipped (local mode).
+"""
+
+from __future__ import annotations
+
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.types import ASGIApp
+
+from .config import get_config
+
+_PUBLIC_PATHS: set[str] = {
+ "/healthz",
+ "/readyz",
+ "/runtime-discovery",
+ "/mode",
+ "/",
+}
+
+
+class BearerAuthMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app: ASGIApp) -> None:
+ super().__init__(app)
+
+ async def dispatch(self, request: Request, call_next): # type: ignore[override]
+ cfg = get_config()
+ path = request.url.path
+
+ # Static asset paths under the frontend mount are always public
+ if path in _PUBLIC_PATHS or path.startswith("/assets") or not path.startswith("/api") and not path.startswith("/ws"):
+ return await call_next(request)
+
+ if not cfg.api_token:
+ # Local / no-auth mode.
+ return await call_next(request)
+
+ # SSE & WebSocket may send the token via query param since EventSource
+ # does not support custom headers.
+ header = request.headers.get("authorization", "")
+ expected = f"Bearer {cfg.api_token}"
+ if header == expected:
+ return await call_next(request)
+ query_token = request.query_params.get("token")
+ if query_token == cfg.api_token:
+ return await call_next(request)
+ return JSONResponse(status_code=401, content={"detail": "Invalid or missing token"})
diff --git a/server/obsmcp_server/config.py b/server/obsmcp_server/config.py
new file mode 100644
index 0000000..4010dca
--- /dev/null
+++ b/server/obsmcp_server/config.py
@@ -0,0 +1,52 @@
+"""Server configuration loaded from environment variables."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+def _default_db_path() -> str:
+ return str(Path.home() / ".obsmcp" / "data" / "obsmcp.db")
+
+
+@dataclass(frozen=True)
+class ServerConfig:
+ api_token: str
+ db_path: str
+ host: str
+ port: int
+ mode: str # "cloud" if api_token is set, else "local"
+
+ @classmethod
+ def from_env(cls) -> ServerConfig:
+ token = os.environ.get("OBSMCP_API_TOKEN", "").strip()
+ db_path = os.path.expanduser(
+ os.path.expandvars(os.environ.get("OBSMCP_DB_PATH") or _default_db_path())
+ )
+ host = os.environ.get("OBSMCP_HOST", "0.0.0.0")
+ port = int(os.environ.get("OBSMCP_PORT", "8000"))
+ return cls(
+ api_token=token,
+ db_path=db_path,
+ host=host,
+ port=port,
+ mode="cloud" if token else "local",
+ )
+
+
+_cached: ServerConfig | None = None
+
+
+def get_config() -> ServerConfig:
+ global _cached
+ if _cached is None:
+ _cached = ServerConfig.from_env()
+ return _cached
+
+
+def reset_config_cache() -> None:
+ """For tests."""
+ global _cached
+ _cached = None
diff --git a/server/obsmcp_server/db.py b/server/obsmcp_server/db.py
new file mode 100644
index 0000000..245c29e
--- /dev/null
+++ b/server/obsmcp_server/db.py
@@ -0,0 +1,79 @@
+"""SQLite connection management + schema migrations.
+
+Raw sqlite3 by design — no ORM. Each thread gets its own connection via
+``threading.local`` to avoid cross-thread issues.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import sqlite3
+import threading
+from pathlib import Path
+from typing import Any
+
+_SCHEMA_FILE = Path(__file__).parent / "schema.sql"
+
+_state: dict[str, Any] = {"db_path": None}
+_tls = threading.local()
+
+
+def init_db(db_path: str) -> None:
+ """Initialize the DB file and run migrations. Idempotent."""
+ _state["db_path"] = db_path
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
+ conn = get_connection()
+ run_migrations(conn)
+
+
+def get_connection() -> sqlite3.Connection:
+ """Return a thread-local SQLite connection."""
+ conn = getattr(_tls, "conn", None)
+ if conn is None:
+ db_path = _state.get("db_path")
+ if not db_path:
+ raise RuntimeError("DB not initialized; call init_db() first")
+ conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA synchronous=NORMAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ _tls.conn = conn
+ return conn
+
+
+def get_db() -> sqlite3.Connection:
+ return get_connection()
+
+
+def run_migrations(conn: sqlite3.Connection) -> None:
+ sql = _SCHEMA_FILE.read_text(encoding="utf-8")
+ cur = conn.cursor()
+ cur.executescript(sql)
+ cur.execute(
+ "INSERT OR REPLACE INTO schema_meta(key, value) VALUES('version', '1')"
+ )
+
+
+def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
+ if row is None:
+ return None
+ d = dict(row)
+ for key in ("tags", "imports", "exports", "metadata"):
+ if key in d and isinstance(d[key], str) and d[key]:
+ with contextlib.suppress(json.JSONDecodeError):
+ d[key] = json.loads(d[key])
+ return d
+
+
+def rows_to_list(rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
+ return [row_to_dict(r) or {} for r in rows]
+
+
+def encode_json_columns(data: dict[str, Any], columns: tuple[str, ...]) -> dict[str, Any]:
+ out = dict(data)
+ for col in columns:
+ if col in out and not isinstance(out[col], str) and out[col] is not None:
+ out[col] = json.dumps(out[col])
+ return out
diff --git a/server/obsmcp_server/main.py b/server/obsmcp_server/main.py
new file mode 100644
index 0000000..cad303e
--- /dev/null
+++ b/server/obsmcp_server/main.py
@@ -0,0 +1,147 @@
+"""OBSMCP FastAPI application."""
+
+from __future__ import annotations
+
+import logging
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from fastapi.staticfiles import StaticFiles
+
+from .auth import BearerAuthMiddleware
+from .config import get_config
+from .db import get_db, init_db
+from .routers import (
+ agents,
+ blockers,
+ code_atlas,
+ decisions,
+ events,
+ knowledge_graph,
+ performance_logs,
+ projects,
+ sessions,
+ tasks,
+ work_logs,
+)
+from .ws import router as ws_router
+
+logger = logging.getLogger("obsmcp.server")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI): # noqa: ARG001
+ cfg = get_config()
+ init_db(cfg.db_path)
+ logger.info("OBSMCP backend ready at db=%s mode=%s", cfg.db_path, cfg.mode)
+ yield
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(title="OBSMCP", version="0.1.0", lifespan=lifespan)
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+ app.add_middleware(BearerAuthMiddleware)
+
+ @app.middleware("http")
+ async def error_handler(request: Request, call_next): # noqa: ANN001
+ try:
+ return await call_next(request)
+ except Exception: # noqa: BLE001
+ logger.exception("Unhandled error on %s %s", request.method, request.url.path)
+ return JSONResponse(
+ status_code=500, content={"error": "internal server error"}
+ )
+
+ app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
+ app.include_router(sessions.router, prefix="/api/sessions", tags=["sessions"])
+ app.include_router(blockers.router, prefix="/api/blockers", tags=["blockers"])
+ app.include_router(decisions.router, prefix="/api/decisions", tags=["decisions"])
+ app.include_router(work_logs.router, prefix="/api/work-logs", tags=["work-logs"])
+ app.include_router(code_atlas.router, prefix="/api/code-atlas", tags=["code-atlas"])
+ app.include_router(
+ knowledge_graph.router, prefix="/api/knowledge-graph", tags=["knowledge-graph"]
+ )
+ app.include_router(
+ performance_logs.router, prefix="/api/performance-logs", tags=["performance-logs"]
+ )
+ app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
+ app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
+ app.include_router(events.router, prefix="/api", tags=["events"])
+ app.include_router(ws_router)
+
+ @app.get("/healthz")
+ def healthz() -> dict[str, str]:
+ return {"status": "ok"}
+
+ @app.get("/readyz")
+ def readyz() -> dict[str, str]:
+ get_db().execute("SELECT 1")
+ return {"status": "ready"}
+
+ @app.get("/runtime-discovery")
+ def runtime_discovery() -> dict[str, object]:
+ cfg = get_config()
+ return {
+ "version": "0.1.0",
+ "mode": cfg.mode,
+ "features": [
+ "tasks",
+ "sessions",
+ "blockers",
+ "decisions",
+ "work_logs",
+ "code_atlas",
+ "knowledge_graph",
+ "performance_logs",
+ ],
+ "db_schema_version": 1,
+ }
+
+ @app.get("/mode")
+ def get_mode() -> dict[str, str]:
+ cfg = get_config()
+ return {"mode": cfg.mode}
+
+ dist = Path(__file__).parent / "frontend_dist"
+ if dist.exists():
+ app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
+ else:
+
+ @app.get("/")
+ def root() -> dict[str, str]:
+ return {
+ "service": "obsmcp",
+ "version": "0.1.0",
+ "note": "Frontend not built. Run `npm run build` in frontend/.",
+ }
+
+ return app
+
+
+app = create_app()
+
+
+def run() -> None:
+ import uvicorn
+
+ cfg = get_config()
+ uvicorn.run(
+ "obsmcp_server.main:app",
+ host=cfg.host,
+ port=cfg.port,
+ reload=False,
+ )
+
+
+if __name__ == "__main__": # pragma: no cover
+ run()
diff --git a/server/obsmcp_server/routers/__init__.py b/server/obsmcp_server/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/server/obsmcp_server/routers/_helpers.py b/server/obsmcp_server/routers/_helpers.py
new file mode 100644
index 0000000..bf4e656
--- /dev/null
+++ b/server/obsmcp_server/routers/_helpers.py
@@ -0,0 +1,94 @@
+"""Shared helpers for CRUD routers."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import HTTPException
+
+from ..db import encode_json_columns, get_db, row_to_dict, rows_to_list
+
+
+def now_iso() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def new_id() -> str:
+ return str(uuid.uuid4())
+
+
+def insert_row(
+ table: str,
+ data: dict[str, Any],
+ json_columns: tuple[str, ...] = (),
+) -> dict[str, Any]:
+ data = encode_json_columns(data, json_columns)
+ cols = list(data.keys())
+ placeholders = ",".join(["?"] * len(cols))
+ sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})"
+ db = get_db()
+ db.execute(sql, list(data.values()))
+ return get_row(table, data["id"])
+
+
+def update_row(
+ table: str,
+ row_id: str,
+ updates: dict[str, Any],
+ json_columns: tuple[str, ...] = (),
+) -> dict[str, Any]:
+ if not updates:
+ return get_row(table, row_id)
+ updates = encode_json_columns(updates, json_columns)
+ sets = ",".join(f"{k}=?" for k in updates)
+ sql = f"UPDATE {table} SET {sets} WHERE id=?"
+ db = get_db()
+ cur = db.execute(sql, [*updates.values(), row_id])
+ if cur.rowcount == 0:
+ raise HTTPException(status_code=404, detail=f"{table[:-1]} not found")
+ return get_row(table, row_id)
+
+
+def delete_row(table: str, row_id: str) -> None:
+ db = get_db()
+ cur = db.execute(f"DELETE FROM {table} WHERE id=?", (row_id,))
+ if cur.rowcount == 0:
+ raise HTTPException(status_code=404, detail=f"{table[:-1]} not found")
+
+
+def get_row(table: str, row_id: str) -> dict[str, Any]:
+ db = get_db()
+ row = db.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
+ result = row_to_dict(row)
+ if result is None:
+ raise HTTPException(status_code=404, detail=f"{table[:-1]} not found")
+ return result
+
+
+def list_rows(table: str, where: str = "", params: tuple = ()) -> list[dict[str, Any]]:
+ sql = f"SELECT * FROM {table}"
+ if where:
+ sql += f" WHERE {where}"
+ sql += " ORDER BY created_at DESC" if _has_created_at(table) else ""
+ db = get_db()
+ rows = db.execute(sql, params).fetchall()
+ return rows_to_list(rows)
+
+
+def _has_created_at(table: str) -> bool:
+ return table not in {"agent_configs", "code_atlas_files", "performance_logs", "sessions"}
+
+
+def coerce_tags(value: Any) -> Any:
+ """Accept a list from JSON bodies; serialize to string when writing."""
+ if isinstance(value, list):
+ return value
+ if isinstance(value, str):
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError:
+ return value
+ return value
diff --git a/server/obsmcp_server/routers/agents.py b/server/obsmcp_server/routers/agents.py
new file mode 100644
index 0000000..e102dce
--- /dev/null
+++ b/server/obsmcp_server/routers/agents.py
@@ -0,0 +1,65 @@
+"""Agent registration and heartbeat endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..db import get_db, row_to_dict, rows_to_list
+from ..sse import broadcast_event
+from ._helpers import now_iso
+
+router = APIRouter()
+
+
+class AgentRegister(BaseModel):
+ agent_id: str
+ project_id: str | None = None
+ machine_name: str | None = None
+ os_type: str | None = None
+ display_name: str | None = None
+
+
+@router.post("/register")
+def register(body: AgentRegister) -> dict[str, Any]:
+ db = get_db()
+ db.execute(
+ """INSERT INTO agent_configs(agent_id, project_id, machine_name, os_type, display_name, last_seen_at, created_at)
+ VALUES(?,?,?,?,?,?,?)
+ ON CONFLICT(agent_id) DO UPDATE SET
+ project_id=excluded.project_id,
+ machine_name=excluded.machine_name,
+ os_type=excluded.os_type,
+ display_name=excluded.display_name,
+ last_seen_at=excluded.last_seen_at""",
+ (
+ body.agent_id,
+ body.project_id,
+ body.machine_name,
+ body.os_type,
+ body.display_name,
+ now_iso(),
+ now_iso(),
+ ),
+ )
+ row = db.execute("SELECT * FROM agent_configs WHERE agent_id=?", (body.agent_id,)).fetchone()
+ broadcast_event("agent_connected", row_to_dict(row) or {})
+ return row_to_dict(row) or {}
+
+
+@router.put("/{agent_id}/heartbeat")
+def heartbeat(agent_id: str) -> dict[str, Any]:
+ db = get_db()
+ db.execute(
+ "UPDATE agent_configs SET last_seen_at=? WHERE agent_id=?", (now_iso(), agent_id)
+ )
+ return {"ok": True}
+
+
+@router.get("")
+def list_agents() -> list[dict[str, Any]]:
+ db = get_db()
+ rows = db.execute("SELECT * FROM agent_configs ORDER BY last_seen_at DESC").fetchall()
+ return rows_to_list(rows)
diff --git a/server/obsmcp_server/routers/blockers.py b/server/obsmcp_server/routers/blockers.py
new file mode 100644
index 0000000..6b5adf8
--- /dev/null
+++ b/server/obsmcp_server/routers/blockers.py
@@ -0,0 +1,81 @@
+"""Blocker endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..sse import broadcast_event
+from ._helpers import delete_row, get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class BlockerCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ agent_id: str | None = None
+ description: str
+ severity: str = "medium"
+
+
+class BlockerResolve(BaseModel):
+ resolution: str
+
+
+@router.post("")
+def log_blocker(body: BlockerCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ "project_id": body.project_id,
+ "agent_id": body.agent_id,
+ "description": body.description,
+ "severity": body.severity,
+ "status": "active",
+ "created_at": now_iso(),
+ }
+ row = insert_row("blockers", data)
+ broadcast_event("blocker_logged", row)
+ return row
+
+
+@router.get("")
+def list_blockers(status: str | None = None, project_id: str | None = None) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if status:
+ wheres.append("status=?")
+ params.append(status)
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ return list_rows("blockers", " AND ".join(wheres), tuple(params))
+
+
+@router.get("/{blocker_id}")
+def get_blocker(blocker_id: str) -> dict[str, Any]:
+ return get_row("blockers", blocker_id)
+
+
+@router.put("/{blocker_id}/resolve")
+def resolve_blocker(blocker_id: str, body: BlockerResolve) -> dict[str, Any]:
+ row = update_row(
+ "blockers",
+ blocker_id,
+ {
+ "status": "resolved",
+ "resolved_at": now_iso(),
+ "resolution": body.resolution,
+ },
+ )
+ broadcast_event("blocker_resolved", row)
+ return row
+
+
+@router.delete("/{blocker_id}")
+def delete_blocker(blocker_id: str) -> dict[str, Any]:
+ delete_row("blockers", blocker_id)
+ broadcast_event("blocker_deleted", {"id": blocker_id})
+ return {"ok": True}
diff --git a/server/obsmcp_server/routers/code_atlas.py b/server/obsmcp_server/routers/code_atlas.py
new file mode 100644
index 0000000..0febbad
--- /dev/null
+++ b/server/obsmcp_server/routers/code_atlas.py
@@ -0,0 +1,121 @@
+"""Code Atlas scan endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..db import get_db, rows_to_list
+from ..sse import broadcast_event
+from ._helpers import get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class ScanCreate(BaseModel):
+ project_id: str | None = None
+ agent_id: str | None = None
+ force_refresh: bool = False
+
+
+class FileCreate(BaseModel):
+ scan_id: str
+ project_id: str | None = None
+ file_path: str
+ language: str | None = None
+ functions_count: int = 0
+ imports: list[str] | None = None
+ exports: list[str] | None = None
+ semantic_description: str | None = None
+
+
+class FileBatch(BaseModel):
+ files: list[FileCreate]
+
+
+class ScanProgress(BaseModel):
+ status: str | None = None
+ total_files: int | None = None
+ scanned_files: int | None = None
+ error_message: str | None = None
+
+
+@router.post("/scan")
+def start_scan(body: ScanCreate) -> dict[str, Any]:
+ data = {
+ "id": new_id(),
+ "project_id": body.project_id,
+ "agent_id": body.agent_id,
+ "status": "pending",
+ "total_files": 0,
+ "scanned_files": 0,
+ "started_at": now_iso(),
+ }
+ row = insert_row("code_atlas_scans", data)
+ broadcast_event("scan_started", row)
+ return row
+
+
+@router.get("/scan/{scan_id}")
+def get_scan(scan_id: str) -> dict[str, Any]:
+ return get_row("code_atlas_scans", scan_id)
+
+
+@router.put("/scan/{scan_id}")
+def update_scan(scan_id: str, body: ScanProgress) -> dict[str, Any]:
+ updates = body.model_dump(exclude_unset=True)
+ if body.status == "completed":
+ updates["completed_at"] = now_iso()
+ row = update_row("code_atlas_scans", scan_id, updates)
+ broadcast_event(f"scan_{body.status or 'progress'}", row)
+ return row
+
+
+@router.get("/scan/{scan_id}/files")
+def get_scan_files(
+ scan_id: str,
+ page: int = 1,
+ per_page: int = 100,
+) -> dict[str, Any]:
+ db = get_db()
+ total = db.execute(
+ "SELECT COUNT(*) FROM code_atlas_files WHERE scan_id=?", (scan_id,)
+ ).fetchone()[0]
+ rows = db.execute(
+ "SELECT * FROM code_atlas_files WHERE scan_id=? ORDER BY file_path LIMIT ? OFFSET ?",
+ (scan_id, per_page, (page - 1) * per_page),
+ ).fetchall()
+ return {"total": total, "page": page, "per_page": per_page, "files": rows_to_list(rows)}
+
+
+@router.post("/files")
+def add_file(body: FileCreate) -> dict[str, Any]:
+ data = {
+ "id": new_id(),
+ **body.model_dump(),
+ "scanned_at": now_iso(),
+ }
+ row = insert_row("code_atlas_files", data, json_columns=("imports", "exports"))
+ return row
+
+
+@router.post("/files/bulk")
+def add_files_bulk(body: FileBatch) -> dict[str, Any]:
+ results = []
+ for f in body.files:
+ data = {
+ "id": new_id(),
+ **f.model_dump(),
+ "scanned_at": now_iso(),
+ }
+ results.append(insert_row("code_atlas_files", data, json_columns=("imports", "exports")))
+ return {"count": len(results)}
+
+
+@router.get("")
+def list_scans(project_id: str | None = None) -> list[dict[str, Any]]:
+ if project_id:
+ return list_rows("code_atlas_scans", "project_id=?", (project_id,))
+ return list_rows("code_atlas_scans")
diff --git a/server/obsmcp_server/routers/decisions.py b/server/obsmcp_server/routers/decisions.py
new file mode 100644
index 0000000..e474ed3
--- /dev/null
+++ b/server/obsmcp_server/routers/decisions.py
@@ -0,0 +1,69 @@
+"""Decision endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..sse import broadcast_event
+from ._helpers import delete_row, get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class DecisionCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ agent_id: str | None = None
+ decision: str
+ context: str | None = None
+ outcome: str | None = None
+ tags: list[str] | None = None
+
+
+class DecisionUpdate(BaseModel):
+ decision: str | None = None
+ context: str | None = None
+ outcome: str | None = None
+ tags: list[str] | None = None
+
+
+@router.post("")
+def log_decision(body: DecisionCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ **body.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ row = insert_row("decisions", data, json_columns=("tags",))
+ broadcast_event("decision_logged", row)
+ return row
+
+
+@router.get("")
+def list_decisions(project_id: str | None = None) -> list[dict[str, Any]]:
+ if project_id:
+ return list_rows("decisions", "project_id=?", (project_id,))
+ return list_rows("decisions")
+
+
+@router.get("/{decision_id}")
+def get_decision(decision_id: str) -> dict[str, Any]:
+ return get_row("decisions", decision_id)
+
+
+@router.put("/{decision_id}")
+def update_decision(decision_id: str, body: DecisionUpdate) -> dict[str, Any]:
+ updates = body.model_dump(exclude_unset=True)
+ row = update_row("decisions", decision_id, updates, json_columns=("tags",))
+ broadcast_event("decision_updated", row)
+ return row
+
+
+@router.delete("/{decision_id}")
+def delete_decision(decision_id: str) -> dict[str, Any]:
+ delete_row("decisions", decision_id)
+ broadcast_event("decision_deleted", {"id": decision_id})
+ return {"ok": True}
diff --git a/server/obsmcp_server/routers/events.py b/server/obsmcp_server/routers/events.py
new file mode 100644
index 0000000..4a7041d
--- /dev/null
+++ b/server/obsmcp_server/routers/events.py
@@ -0,0 +1,77 @@
+"""SSE events + stats endpoints."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from fastapi import APIRouter, Request
+from fastapi.responses import StreamingResponse
+
+from ..db import get_db
+from ..sse import format_sse, register_listener, unregister_listener
+
+router = APIRouter()
+
+
+@router.get("/events")
+async def sse_events(request: Request) -> StreamingResponse:
+ queue = await register_listener()
+
+ async def event_stream():
+ try:
+ # Initial "connected" event
+ yield format_sse({"type": "connected", "payload": {}, "timestamp": ""})
+ while True:
+ if await request.is_disconnected():
+ break
+ try:
+ event = await asyncio.wait_for(queue.get(), timeout=15.0)
+ yield format_sse(event)
+ except TimeoutError:
+ # Heartbeat to keep connection alive
+ yield ": heartbeat\n\n"
+ finally:
+ await unregister_listener(queue)
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@router.get("/stats")
+def stats() -> dict[str, Any]:
+ db = get_db()
+
+ def scalar(sql: str, params: tuple = ()) -> int:
+ row = db.execute(sql, params).fetchone()
+ return int(row[0]) if row else 0
+
+ return {
+ "tasks": {
+ "total": scalar("SELECT COUNT(*) FROM tasks"),
+ "open": scalar("SELECT COUNT(*) FROM tasks WHERE status='open'"),
+ "in_progress": scalar("SELECT COUNT(*) FROM tasks WHERE status='in_progress'"),
+ "blocked": scalar("SELECT COUNT(*) FROM tasks WHERE status='blocked'"),
+ "done": scalar("SELECT COUNT(*) FROM tasks WHERE status='done'"),
+ },
+ "sessions": {
+ "total": scalar("SELECT COUNT(*) FROM sessions"),
+ "active": scalar("SELECT COUNT(*) FROM sessions WHERE ended_at IS NULL"),
+ },
+ "blockers": {
+ "active": scalar("SELECT COUNT(*) FROM blockers WHERE status='active'"),
+ "resolved": scalar("SELECT COUNT(*) FROM blockers WHERE status='resolved'"),
+ },
+ "decisions": scalar("SELECT COUNT(*) FROM decisions"),
+ "work_logs": scalar("SELECT COUNT(*) FROM work_logs"),
+ "nodes": scalar("SELECT COUNT(*) FROM knowledge_nodes"),
+ "edges": scalar("SELECT COUNT(*) FROM knowledge_edges"),
+ "agents": scalar("SELECT COUNT(*) FROM agent_configs"),
+ }
diff --git a/server/obsmcp_server/routers/knowledge_graph.py b/server/obsmcp_server/routers/knowledge_graph.py
new file mode 100644
index 0000000..d6dfa4f
--- /dev/null
+++ b/server/obsmcp_server/routers/knowledge_graph.py
@@ -0,0 +1,239 @@
+"""Knowledge graph endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..db import get_db, rows_to_list
+from ..sse import broadcast_event
+from ._helpers import delete_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class NodeCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ agent_id: str | None = None
+ node_type: str
+ name: str
+ description: str | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class NodeUpdate(BaseModel):
+ node_type: str | None = None
+ name: str | None = None
+ description: str | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class EdgeCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ from_node_id: str
+ to_node_id: str
+ edge_type: str
+ weight: float = 1.0
+ metadata: dict[str, Any] | None = None
+
+
+class NodesBulk(BaseModel):
+ nodes: list[NodeCreate]
+
+
+class EdgesBulk(BaseModel):
+ edges: list[EdgeCreate]
+
+
+@router.get("")
+def get_graph() -> dict[str, Any]:
+ return {
+ "nodes": list_rows("knowledge_nodes"),
+ "edges": list_rows("knowledge_edges"),
+ }
+
+
+@router.post("/nodes")
+def add_node(body: NodeCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ **body.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ row = insert_row("knowledge_nodes", data, json_columns=("metadata",))
+ broadcast_event("node_created", row)
+ return row
+
+
+@router.post("/nodes/bulk")
+def add_nodes_bulk(body: NodesBulk) -> dict[str, Any]:
+ rows = []
+ for n in body.nodes:
+ data = {
+ "id": n.id or new_id(),
+ **n.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ rows.append(insert_row("knowledge_nodes", data, json_columns=("metadata",)))
+ broadcast_event("nodes_bulk_created", {"count": len(rows)})
+ return {"count": len(rows), "nodes": rows}
+
+
+@router.get("/nodes")
+def list_nodes(type: str | None = None, project_id: str | None = None) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if type:
+ wheres.append("node_type=?")
+ params.append(type)
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ return list_rows("knowledge_nodes", " AND ".join(wheres), tuple(params))
+
+
+@router.put("/nodes/{node_id}")
+def update_node(node_id: str, body: NodeUpdate) -> dict[str, Any]:
+ row = update_row(
+ "knowledge_nodes", node_id, body.model_dump(exclude_unset=True), json_columns=("metadata",)
+ )
+ broadcast_event("node_updated", row)
+ return row
+
+
+@router.delete("/nodes/{node_id}")
+def delete_node(node_id: str) -> dict[str, Any]:
+ delete_row("knowledge_nodes", node_id)
+ broadcast_event("node_deleted", {"id": node_id})
+ return {"ok": True}
+
+
+@router.post("/edges")
+def add_edge(body: EdgeCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ **body.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ row = insert_row("knowledge_edges", data, json_columns=("metadata",))
+ broadcast_event("edge_created", row)
+ return row
+
+
+@router.post("/edges/bulk")
+def add_edges_bulk(body: EdgesBulk) -> dict[str, Any]:
+ rows = []
+ for e in body.edges:
+ data = {
+ "id": e.id or new_id(),
+ **e.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ rows.append(insert_row("knowledge_edges", data, json_columns=("metadata",)))
+ broadcast_event("edges_bulk_created", {"count": len(rows)})
+ return {"count": len(rows), "edges": rows}
+
+
+@router.get("/edges")
+def list_edges(
+ type: str | None = None,
+ from_id: str | None = None,
+ to_id: str | None = None,
+) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if type:
+ wheres.append("edge_type=?")
+ params.append(type)
+ if from_id:
+ wheres.append("from_node_id=?")
+ params.append(from_id)
+ if to_id:
+ wheres.append("to_node_id=?")
+ params.append(to_id)
+ return list_rows("knowledge_edges", " AND ".join(wheres), tuple(params))
+
+
+@router.delete("/edges/{edge_id}")
+def delete_edge(edge_id: str) -> dict[str, Any]:
+ delete_row("knowledge_edges", edge_id)
+ broadcast_event("edge_deleted", {"id": edge_id})
+ return {"ok": True}
+
+
+@router.get("/query")
+def query_graph(
+ node_id: str | None = None,
+ edge_type: str | None = None,
+ depth: int = 1,
+ q: str | None = None,
+) -> dict[str, Any]:
+ db = get_db()
+ if q:
+ rows = db.execute(
+ "SELECT * FROM knowledge_nodes WHERE name LIKE ? OR description LIKE ? LIMIT 500",
+ (f"%{q}%", f"%{q}%"),
+ ).fetchall()
+ return {"nodes": rows_to_list(rows), "edges": []}
+ if node_id:
+ visited: set[str] = {node_id}
+ frontier = {node_id}
+ edges_out: list[dict[str, Any]] = []
+ for _ in range(max(1, depth)):
+ placeholders = ",".join(["?"] * len(frontier))
+ if edge_type:
+ rows = db.execute(
+ f"SELECT * FROM knowledge_edges WHERE edge_type=? AND (from_node_id IN ({placeholders}) OR to_node_id IN ({placeholders}))",
+ (edge_type, *frontier, *frontier),
+ ).fetchall()
+ else:
+ rows = db.execute(
+ f"SELECT * FROM knowledge_edges WHERE from_node_id IN ({placeholders}) OR to_node_id IN ({placeholders})",
+ (*frontier, *frontier),
+ ).fetchall()
+ edges_list = rows_to_list(rows)
+ edges_out.extend(edges_list)
+ next_frontier: set[str] = set()
+ for e in edges_list:
+ for k in ("from_node_id", "to_node_id"):
+ if e[k] not in visited:
+ next_frontier.add(e[k])
+ visited.update(next_frontier)
+ frontier = next_frontier
+ if not frontier:
+ break
+ if visited:
+ placeholders = ",".join(["?"] * len(visited))
+ node_rows = db.execute(
+ f"SELECT * FROM knowledge_nodes WHERE id IN ({placeholders})",
+ tuple(visited),
+ ).fetchall()
+ else:
+ node_rows = []
+ return {"nodes": rows_to_list(node_rows), "edges": edges_out}
+ return {"nodes": list_rows("knowledge_nodes"), "edges": list_rows("knowledge_edges")}
+
+
+@router.get("/stats")
+def graph_stats() -> dict[str, Any]:
+ db = get_db()
+ node_counts = {
+ row["node_type"]: row["c"]
+ for row in db.execute(
+ "SELECT node_type, COUNT(*) as c FROM knowledge_nodes GROUP BY node_type"
+ ).fetchall()
+ }
+ edge_counts = {
+ row["edge_type"]: row["c"]
+ for row in db.execute(
+ "SELECT edge_type, COUNT(*) as c FROM knowledge_edges GROUP BY edge_type"
+ ).fetchall()
+ }
+ return {
+ "nodes": {"total": sum(node_counts.values()), "by_type": node_counts},
+ "edges": {"total": sum(edge_counts.values()), "by_type": edge_counts},
+ }
diff --git a/server/obsmcp_server/routers/performance_logs.py b/server/obsmcp_server/routers/performance_logs.py
new file mode 100644
index 0000000..ff83f8b
--- /dev/null
+++ b/server/obsmcp_server/routers/performance_logs.py
@@ -0,0 +1,117 @@
+"""Performance log endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..db import get_db, rows_to_list
+from ..sse import broadcast_event
+from ._helpers import insert_row, new_id, now_iso
+
+router = APIRouter()
+
+
+class PerfLog(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ agent_id: str | None = None
+ session_id: str | None = None
+ metric_name: str
+ metric_value: float
+ unit: str | None = None
+ tags: dict[str, Any] | None = None
+
+
+class PerfLogBatch(BaseModel):
+ logs: list[PerfLog]
+
+
+@router.post("")
+def ingest_logs(body: PerfLogBatch) -> dict[str, Any]:
+ rows = []
+ for log in body.logs:
+ data = {
+ "id": log.id or new_id(),
+ **log.model_dump(exclude_unset=False, exclude={"id"}),
+ "logged_at": now_iso(),
+ }
+ rows.append(insert_row("performance_logs", data, json_columns=("tags",)))
+ broadcast_event("perf_log_received", {"count": len(rows)})
+ return {"count": len(rows)}
+
+
+@router.get("")
+def list_logs(
+ metric_name: str | None = None,
+ session_id: str | None = None,
+ project_id: str | None = None,
+ from_: str | None = None,
+ to: str | None = None,
+ limit: int = 1000,
+) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if metric_name:
+ wheres.append("metric_name=?")
+ params.append(metric_name)
+ if session_id:
+ wheres.append("session_id=?")
+ params.append(session_id)
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ if from_:
+ wheres.append("logged_at>=?")
+ params.append(from_)
+ if to:
+ wheres.append("logged_at<=?")
+ params.append(to)
+ sql = "SELECT * FROM performance_logs"
+ if wheres:
+ sql += " WHERE " + " AND ".join(wheres)
+ sql += " ORDER BY logged_at DESC LIMIT ?"
+ params.append(limit)
+ rows = get_db().execute(sql, tuple(params)).fetchall()
+ return rows_to_list(rows)
+
+
+@router.get("/summary")
+def summary(
+ metric_name: str | None = None,
+ session_id: str | None = None,
+ from_: str | None = None,
+ to: str | None = None,
+) -> dict[str, Any]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if metric_name:
+ wheres.append("metric_name=?")
+ params.append(metric_name)
+ if session_id:
+ wheres.append("session_id=?")
+ params.append(session_id)
+ if from_:
+ wheres.append("logged_at>=?")
+ params.append(from_)
+ if to:
+ wheres.append("logged_at<=?")
+ params.append(to)
+ where_sql = (" WHERE " + " AND ".join(wheres)) if wheres else ""
+ db = get_db()
+ rows = db.execute(
+ f"""
+ SELECT metric_name,
+ COUNT(*) as count,
+ AVG(metric_value) as avg,
+ MIN(metric_value) as min,
+ MAX(metric_value) as max
+ FROM performance_logs
+ {where_sql}
+ GROUP BY metric_name
+ """,
+ tuple(params),
+ ).fetchall()
+ return {"metrics": rows_to_list(rows)}
diff --git a/server/obsmcp_server/routers/projects.py b/server/obsmcp_server/routers/projects.py
new file mode 100644
index 0000000..18c0fd5
--- /dev/null
+++ b/server/obsmcp_server/routers/projects.py
@@ -0,0 +1,62 @@
+"""Project endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ._helpers import delete_row, get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class ProjectCreate(BaseModel):
+ id: str | None = None
+ name: str
+ path: str
+ repo_url: str | None = None
+
+
+class ProjectUpdate(BaseModel):
+ name: str | None = None
+ path: str | None = None
+ repo_url: str | None = None
+
+
+@router.post("")
+def create_project(body: ProjectCreate) -> dict[str, Any]:
+ now = now_iso()
+ data = {
+ "id": body.id or new_id(),
+ "name": body.name,
+ "path": body.path,
+ "repo_url": body.repo_url,
+ "created_at": now,
+ "updated_at": now,
+ }
+ return insert_row("projects", data)
+
+
+@router.get("")
+def list_projects() -> list[dict[str, Any]]:
+ return list_rows("projects")
+
+
+@router.get("/{project_id}")
+def get_project(project_id: str) -> dict[str, Any]:
+ return get_row("projects", project_id)
+
+
+@router.put("/{project_id}")
+def update_project(project_id: str, body: ProjectUpdate) -> dict[str, Any]:
+ updates = body.model_dump(exclude_unset=True)
+ updates["updated_at"] = now_iso()
+ return update_row("projects", project_id, updates)
+
+
+@router.delete("/{project_id}")
+def delete_project(project_id: str) -> dict[str, Any]:
+ delete_row("projects", project_id)
+ return {"ok": True}
diff --git a/server/obsmcp_server/routers/sessions.py b/server/obsmcp_server/routers/sessions.py
new file mode 100644
index 0000000..b01cdd4
--- /dev/null
+++ b/server/obsmcp_server/routers/sessions.py
@@ -0,0 +1,95 @@
+"""Session endpoints."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..db import get_db
+from ..sse import broadcast_event
+from ._helpers import get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class SessionCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ agent_id: str
+ context: str | None = None
+
+
+class SessionHeartbeat(BaseModel):
+ context: str | None = None
+
+
+@router.post("")
+def open_session(body: SessionCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ "project_id": body.project_id,
+ "agent_id": body.agent_id,
+ "started_at": now_iso(),
+ "ended_at": None,
+ "duration_seconds": None,
+ "context": body.context,
+ }
+ row = insert_row("sessions", data)
+ broadcast_event("session_opened", row)
+ return row
+
+
+@router.get("")
+def list_sessions(active: bool | None = None, project_id: str | None = None) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if active is True:
+ wheres.append("ended_at IS NULL")
+ elif active is False:
+ wheres.append("ended_at IS NOT NULL")
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ return list_rows("sessions", " AND ".join(wheres), tuple(params))
+
+
+@router.get("/{session_id}")
+def get_session(session_id: str) -> dict[str, Any]:
+ return get_row("sessions", session_id)
+
+
+@router.put("/{session_id}/heartbeat")
+def heartbeat(session_id: str, body: SessionHeartbeat) -> dict[str, Any]:
+ updates: dict[str, Any] = {}
+ if body.context is not None:
+ updates["context"] = body.context
+ row = update_row("sessions", session_id, updates) if updates else get_row("sessions", session_id)
+ broadcast_event("session_heartbeat", {"id": session_id, "context": body.context})
+ return row
+
+
+@router.put("/{session_id}/close")
+def close_session(session_id: str) -> dict[str, Any]:
+ session = get_row("sessions", session_id)
+ started_at = session.get("started_at")
+ ended_at = now_iso()
+ duration = 0
+ if started_at:
+ try:
+ started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
+ if started.tzinfo is None:
+ started = started.replace(tzinfo=UTC)
+ duration = int((datetime.now(UTC) - started).total_seconds())
+ except ValueError:
+ duration = 0
+ db = get_db()
+ db.execute(
+ "UPDATE sessions SET ended_at=?, duration_seconds=? WHERE id=?",
+ (ended_at, duration, session_id),
+ )
+ row = get_row("sessions", session_id)
+ broadcast_event("session_closed", row)
+ return row
diff --git a/server/obsmcp_server/routers/tasks.py b/server/obsmcp_server/routers/tasks.py
new file mode 100644
index 0000000..08ad618
--- /dev/null
+++ b/server/obsmcp_server/routers/tasks.py
@@ -0,0 +1,125 @@
+"""Task CRUD endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from ..sse import broadcast_event
+from ._helpers import (
+ delete_row,
+ get_row,
+ insert_row,
+ list_rows,
+ new_id,
+ now_iso,
+ update_row,
+)
+
+router = APIRouter()
+
+
+class TaskCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ title: str
+ description: str | None = None
+ status: str = "open"
+ priority: str = "medium"
+ tags: list[str] | None = None
+
+
+class TaskUpdate(BaseModel):
+ project_id: str | None = None
+ title: str | None = None
+ description: str | None = None
+ status: str | None = None
+ priority: str | None = None
+ tags: list[str] | None = None
+
+
+class BulkOperation(BaseModel):
+ id: str
+ action: str = Field(pattern="^(update|delete)$")
+ data: dict[str, Any] | None = None
+
+
+class BulkRequest(BaseModel):
+ operations: list[BulkOperation]
+
+
+@router.post("")
+def create_task(body: TaskCreate) -> dict[str, Any]:
+ now = now_iso()
+ data = {
+ "id": body.id or new_id(),
+ "project_id": body.project_id,
+ "title": body.title,
+ "description": body.description,
+ "status": body.status,
+ "priority": body.priority,
+ "tags": body.tags,
+ "created_at": now,
+ "updated_at": now,
+ }
+ row = insert_row("tasks", data, json_columns=("tags",))
+ broadcast_event("task_created", row)
+ return row
+
+
+@router.get("")
+def list_tasks(
+ status: str | None = None,
+ project_id: str | None = None,
+) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if status:
+ wheres.append("status=?")
+ params.append(status)
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ return list_rows("tasks", " AND ".join(wheres), tuple(params))
+
+
+@router.get("/{task_id}")
+def get_task(task_id: str) -> dict[str, Any]:
+ return get_row("tasks", task_id)
+
+
+@router.put("/{task_id}")
+def update_task(task_id: str, body: TaskUpdate) -> dict[str, Any]:
+ updates = dict(body.model_dump(exclude_unset=True).items())
+ updates["updated_at"] = now_iso()
+ row = update_row("tasks", task_id, updates, json_columns=("tags",))
+ broadcast_event("task_updated", row)
+ return row
+
+
+@router.delete("/{task_id}")
+def delete_task(task_id: str) -> dict[str, Any]:
+ delete_row("tasks", task_id)
+ broadcast_event("task_deleted", {"id": task_id})
+ return {"ok": True}
+
+
+@router.post("/bulk")
+def bulk_tasks(body: BulkRequest) -> dict[str, Any]:
+ results: list[dict[str, Any]] = []
+ for op in body.operations:
+ if op.action == "update":
+ data = op.data or {}
+ data["updated_at"] = now_iso()
+ row = update_row("tasks", op.id, data, json_columns=("tags",))
+ broadcast_event("task_updated", row)
+ results.append(row)
+ elif op.action == "delete":
+ delete_row("tasks", op.id)
+ broadcast_event("task_deleted", {"id": op.id})
+ results.append({"id": op.id, "deleted": True})
+ else: # pragma: no cover
+ raise HTTPException(status_code=400, detail=f"unknown action: {op.action}")
+ return {"results": results}
diff --git a/server/obsmcp_server/routers/work_logs.py b/server/obsmcp_server/routers/work_logs.py
new file mode 100644
index 0000000..b2a388d
--- /dev/null
+++ b/server/obsmcp_server/routers/work_logs.py
@@ -0,0 +1,80 @@
+"""Work log endpoints."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from ..sse import broadcast_event
+from ._helpers import delete_row, get_row, insert_row, list_rows, new_id, now_iso, update_row
+
+router = APIRouter()
+
+
+class WorkLogCreate(BaseModel):
+ id: str | None = None
+ project_id: str | None = None
+ session_id: str | None = None
+ agent_id: str | None = None
+ description: str
+ hours: float | None = None
+ tags: list[str] | None = None
+
+
+class WorkLogUpdate(BaseModel):
+ description: str | None = None
+ hours: float | None = None
+ tags: list[str] | None = None
+
+
+@router.post("")
+def log_work(body: WorkLogCreate) -> dict[str, Any]:
+ data = {
+ "id": body.id or new_id(),
+ **body.model_dump(exclude_unset=False, exclude={"id"}),
+ "created_at": now_iso(),
+ }
+ row = insert_row("work_logs", data, json_columns=("tags",))
+ broadcast_event("work_logged", row)
+ return row
+
+
+@router.get("")
+def list_work_logs(
+ session_id: str | None = None,
+ project_id: str | None = None,
+ date: str | None = None,
+) -> list[dict[str, Any]]:
+ wheres: list[str] = []
+ params: list[Any] = []
+ if session_id:
+ wheres.append("session_id=?")
+ params.append(session_id)
+ if project_id:
+ wheres.append("project_id=?")
+ params.append(project_id)
+ if date:
+ wheres.append("date(created_at)=date(?)")
+ params.append(date)
+ return list_rows("work_logs", " AND ".join(wheres), tuple(params))
+
+
+@router.put("/{log_id}")
+def update_work_log(log_id: str, body: WorkLogUpdate) -> dict[str, Any]:
+ row = update_row("work_logs", log_id, body.model_dump(exclude_unset=True), json_columns=("tags",))
+ broadcast_event("work_log_updated", row)
+ return row
+
+
+@router.delete("/{log_id}")
+def delete_work_log(log_id: str) -> dict[str, Any]:
+ delete_row("work_logs", log_id)
+ broadcast_event("work_log_deleted", {"id": log_id})
+ return {"ok": True}
+
+
+@router.get("/{log_id}")
+def get_work_log(log_id: str) -> dict[str, Any]:
+ return get_row("work_logs", log_id)
diff --git a/server/obsmcp_server/schema.sql b/server/obsmcp_server/schema.sql
new file mode 100644
index 0000000..79affb8
--- /dev/null
+++ b/server/obsmcp_server/schema.sql
@@ -0,0 +1,155 @@
+-- OBSMCP SQLite schema. All CREATE TABLE statements are idempotent.
+
+CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ repo_url TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ status TEXT DEFAULT 'open',
+ priority TEXT DEFAULT 'medium',
+ tags TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT NOT NULL,
+ started_at TEXT DEFAULT (datetime('now')),
+ ended_at TEXT,
+ duration_seconds INTEGER,
+ context TEXT
+);
+
+CREATE TABLE IF NOT EXISTS blockers (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ description TEXT NOT NULL,
+ severity TEXT DEFAULT 'medium',
+ status TEXT DEFAULT 'active',
+ resolved_at TEXT,
+ resolution TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS decisions (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ decision TEXT NOT NULL,
+ context TEXT,
+ outcome TEXT,
+ tags TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS work_logs (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ session_id TEXT,
+ agent_id TEXT,
+ description TEXT NOT NULL,
+ hours REAL,
+ tags TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS code_atlas_scans (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ status TEXT DEFAULT 'pending',
+ total_files INTEGER DEFAULT 0,
+ scanned_files INTEGER DEFAULT 0,
+ started_at TEXT DEFAULT (datetime('now')),
+ completed_at TEXT,
+ error_message TEXT
+);
+
+CREATE TABLE IF NOT EXISTS code_atlas_files (
+ id TEXT PRIMARY KEY,
+ scan_id TEXT,
+ project_id TEXT,
+ file_path TEXT NOT NULL,
+ language TEXT,
+ functions_count INTEGER DEFAULT 0,
+ imports TEXT,
+ exports TEXT,
+ semantic_description TEXT,
+ tokens_used INTEGER DEFAULT 0,
+ scanned_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS knowledge_nodes (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ node_type TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ metadata TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS knowledge_edges (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ from_node_id TEXT NOT NULL,
+ to_node_id TEXT NOT NULL,
+ edge_type TEXT NOT NULL,
+ weight REAL DEFAULT 1.0,
+ metadata TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS performance_logs (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ session_id TEXT,
+ metric_name TEXT NOT NULL,
+ metric_value REAL,
+ unit TEXT,
+ tags TEXT,
+ logged_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS agent_configs (
+ agent_id TEXT PRIMARY KEY,
+ project_id TEXT,
+ display_name TEXT,
+ machine_name TEXT,
+ os_type TEXT,
+ last_seen_at TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS schema_meta (
+ key TEXT PRIMARY KEY,
+ value TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
+CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
+CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
+CREATE INDEX IF NOT EXISTS idx_blockers_project ON blockers(project_id);
+CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id);
+CREATE INDEX IF NOT EXISTS idx_work_logs_project ON work_logs(project_id);
+CREATE INDEX IF NOT EXISTS idx_code_atlas_scans_project ON code_atlas_scans(project_id);
+CREATE INDEX IF NOT EXISTS idx_code_atlas_files_scan ON code_atlas_files(scan_id);
+CREATE INDEX IF NOT EXISTS idx_nodes_project ON knowledge_nodes(project_id);
+CREATE INDEX IF NOT EXISTS idx_edges_from ON knowledge_edges(from_node_id);
+CREATE INDEX IF NOT EXISTS idx_edges_to ON knowledge_edges(to_node_id);
+CREATE INDEX IF NOT EXISTS idx_perf_logs_project ON performance_logs(project_id);
+CREATE INDEX IF NOT EXISTS idx_perf_logs_logged_at ON performance_logs(logged_at);
diff --git a/server/obsmcp_server/sse.py b/server/obsmcp_server/sse.py
new file mode 100644
index 0000000..c4e2c24
--- /dev/null
+++ b/server/obsmcp_server/sse.py
@@ -0,0 +1,67 @@
+"""Server-Sent Events broadcaster.
+
+Every mutation in a router calls :func:`broadcast_event` which puts an event
+into every connected listener's queue. The SSE generator yields it as a
+``text/event-stream`` payload.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+from datetime import UTC, datetime
+from typing import Any
+
+_listeners: list[asyncio.Queue[dict[str, Any]]] = []
+_lock = asyncio.Lock()
+
+
+async def register_listener() -> asyncio.Queue[dict[str, Any]]:
+ q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1024)
+ async with _lock:
+ _listeners.append(q)
+ return q
+
+
+async def unregister_listener(q: asyncio.Queue[dict[str, Any]]) -> None:
+ async with _lock:
+ if q in _listeners:
+ _listeners.remove(q)
+
+
+def listener_count() -> int:
+ return len(_listeners)
+
+
+def broadcast_event(event_type: str, payload: dict[str, Any] | None = None) -> None:
+ """Non-blocking broadcast to all connected SSE listeners.
+
+ Safe to call from anywhere (sync request handlers). Uses
+ ``call_soon_threadsafe`` when invoked from a different thread.
+ """
+ event = {
+ "type": event_type,
+ "payload": payload or {},
+ "timestamp": datetime.now(UTC).isoformat(),
+ }
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ # No loop in this thread; silently drop (not fatal).
+ return
+
+ for q in list(_listeners):
+ try:
+ loop.call_soon_threadsafe(_put_nowait, q, event)
+ except RuntimeError:
+ continue
+
+
+def _put_nowait(q: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None:
+ with contextlib.suppress(asyncio.QueueFull):
+ q.put_nowait(event)
+
+
+def format_sse(event: dict[str, Any]) -> str:
+ return f"event: {event['type']}\ndata: {json.dumps(event)}\n\n"
diff --git a/server/obsmcp_server/ws.py b/server/obsmcp_server/ws.py
new file mode 100644
index 0000000..24d2c1b
--- /dev/null
+++ b/server/obsmcp_server/ws.py
@@ -0,0 +1,34 @@
+"""Dashboard WebSocket endpoint.
+
+Mirrors the SSE stream so clients that prefer bidirectional transport
+can subscribe to the same event bus.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+from .sse import register_listener, unregister_listener
+
+router = APIRouter()
+
+
+@router.websocket("/ws/dashboard")
+async def dashboard_ws(ws: WebSocket) -> None:
+ await ws.accept()
+ queue = await register_listener()
+ try:
+ await ws.send_text(json.dumps({"type": "connected"}))
+ while True:
+ try:
+ event = await asyncio.wait_for(queue.get(), timeout=15.0)
+ await ws.send_text(json.dumps(event))
+ except TimeoutError:
+ await ws.send_text(json.dumps({"type": "heartbeat"}))
+ except WebSocketDisconnect:
+ pass
+ finally:
+ await unregister_listener(queue)
diff --git a/server/opusmax_provider.py b/server/opusmax_provider.py
deleted file mode 100644
index 4a167c4..0000000
--- a/server/opusmax_provider.py
+++ /dev/null
@@ -1,389 +0,0 @@
-from __future__ import annotations
-
-import base64
-import functools
-import json
-import mimetypes
-import os
-import time
-import uuid
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any
-
-import requests
-
-
-DEFAULT_OPUSMAX_BASE_URL = "https://api.opusmax.pro"
-DEFAULT_TIMEOUT = 30.0
-IMAGE_ANALYSIS_TIMEOUT = 60.0
-MAX_IMAGE_BYTES = 15 * 1024 * 1024
-MAX_QUERY_CHARS = 1000
-MAX_PROMPT_CHARS = 4000
-MAX_RESULTS_LIMIT = 10
-SUPPORTED_IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"}
-
-
-@functools.lru_cache(maxsize=1)
-def _read_claude_settings_env() -> dict[str, str]:
- claude_dir = Path.home() / ".claude"
- merged: dict[str, str] = {}
- for candidate in (claude_dir / "settings.json", claude_dir / "settings.local.json"):
- try:
- payload = json.loads(candidate.read_text(encoding="utf-8"))
- except (FileNotFoundError, OSError, json.JSONDecodeError):
- continue
- env = payload.get("env")
- if not isinstance(env, dict):
- continue
- for key, value in env.items():
- if isinstance(key, str) and isinstance(value, str):
- merged[key] = value
- return merged
-
-
-def _resolve_setting(*names: str) -> str:
- for name in names:
- value = os.environ.get(name, "").strip()
- if value:
- return value
- settings_env = _read_claude_settings_env()
- for name in names:
- value = str(settings_env.get(name, "")).strip()
- if value:
- return value
- return ""
-
-
-@dataclass
-class ProviderCallResult:
- data: dict[str, Any]
- latency_ms: float
- raw: Any = None
-
-
-@dataclass
-class LLMResponse:
- text: str
- model: str
- usage: dict[str, int]
- latency_ms: float
- raw: Any = None
-
-
-class OpusMaxBaseProvider:
- def __init__(
- self,
- api_key: str | None = None,
- base_url: str | None = None,
- default_timeout: float = DEFAULT_TIMEOUT,
- ) -> None:
- configured_base = base_url or _resolve_setting("OPUS_MAX_BASE_URL", "ANTHROPIC_BASE_URL") or DEFAULT_OPUSMAX_BASE_URL
- self.api_key = api_key or _resolve_setting("OPUS_MAX_API_KEY", "ANTHROPIC_AUTH_TOKEN")
- self.base_url = configured_base.rstrip("/")
- self.default_timeout = default_timeout
- self._session = requests.Session()
- self._session.headers.update(
- {
- "Authorization": f"Bearer {self.api_key}",
- "Content-Type": "application/json",
- "x-api-key": self.api_key,
- }
- )
-
- @property
- def api_root(self) -> str:
- if self.base_url.endswith("/v1"):
- return self.base_url[:-3]
- return self.base_url
-
- @property
- def compat_v1_root(self) -> str:
- if self.base_url.endswith("/v1"):
- return self.base_url
- return f"{self.base_url}/v1"
-
- def _require_api_key(self) -> None:
- if not self.api_key:
- raise ValueError("OpusMax API key is not configured. Set OPUS_MAX_API_KEY or ANTHROPIC_AUTH_TOKEN.")
-
- def _post_json(self, url: str, payload: dict[str, Any], timeout: float | None = None) -> ProviderCallResult:
- self._require_api_key()
- start = time.perf_counter()
- response = self._session.post(url, json=payload, timeout=timeout or self.default_timeout)
- latency_ms = round((time.perf_counter() - start) * 1000, 1)
- if not 200 <= response.status_code < 300:
- body = response.text.strip()
- snippet = body[:400] if body else ""
- raise RuntimeError(f"OpusMax request failed ({response.status_code}): {snippet}")
- data = response.json()
- if not isinstance(data, dict):
- raise RuntimeError("OpusMax response was not a JSON object.")
- base_resp = data.get("base_resp")
- if isinstance(base_resp, dict):
- status_code = int(base_resp.get("status_code", 0) or 0)
- status_msg = str(base_resp.get("status_msg", "") or "").strip()
- if status_code and status_code not in {200, 201}:
- raise RuntimeError(f"OpusMax tool error ({status_code}): {status_msg or 'unknown error'}")
- return ProviderCallResult(data=data, latency_ms=latency_ms, raw=data)
-
-
-class OpusMaxTextProvider(OpusMaxBaseProvider):
- MODEL_HAIKU = "haiku-4.5"
- MODEL_SONNET = "sonnet-4.6"
-
- SYSTEM_PROMPT = """You are a code analyst. Given a code entity, produce a detailed but concise semantic description with these exact fields:
-
-purpose: One-sentence purpose of this entity (be specific, not generic).
-why_it_exists: One sentence explaining the specific problem or gap this entity fills.
-how_it_is_used: One sentence on how callers or other code uses this entity.
-inputs_outputs: One sentence on key inputs and outputs / side effects.
-side_effects: One sentence on any I/O, filesystem, network, or state-mutating behavior.
-risks: One sentence on any reliability, security, or coordination risks.
-language: One word (Python, TypeScript, Rust, Go, Java, etc.)
-
-Respond ONLY with a valid JSON object with these exact keys: purpose, why_it_exists, how_it_is_used, inputs_outputs, side_effects, risks, language. No markdown, no code fences, no extra text."""
-
- def _call_api(
- self,
- model: str,
- user_message: str,
- timeout: float | None = None,
- response_contract: str | None = None,
- ) -> LLMResponse | None:
- system_prompt = self.SYSTEM_PROMPT
- if response_contract:
- system_prompt = f"{system_prompt}\n\n{response_contract.strip()}"
- payload = {
- "model": model,
- "messages": [
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": user_message},
- ],
- "max_tokens": 512,
- "temperature": 0.2,
- }
- try:
- result = self._post_json(f"{self.compat_v1_root}/chat/completions", payload, timeout=timeout)
- except Exception:
- return None
- choices = result.data.get("choices") or []
- if not choices:
- return None
- message = choices[0].get("message", {})
- content = message.get("content", "")
- usage = result.data.get("usage", {})
- return LLMResponse(
- text=str(content).strip(),
- model=model,
- usage={
- "input_tokens": int(usage.get("prompt_tokens", 0) or 0),
- "output_tokens": int(usage.get("completion_tokens", 0) or 0),
- },
- latency_ms=result.latency_ms,
- raw=result.raw,
- )
-
- def generate_description(
- self,
- entity: dict[str, Any],
- snippet: str,
- context: str | None = None,
- response_contract: str | None = None,
- ) -> dict[str, Any] | None:
- lines = [
- f"Entity type: {entity.get('entity_type', 'unknown')}",
- f"Name: {entity.get('name', 'unknown')}",
- f"File: {entity.get('file_path', 'unknown')}",
- ]
- sig = entity.get("signature", "")
- if sig:
- lines.append(f"Signature: {sig}")
- tags = entity.get("feature_tags", [])
- if tags:
- lines.append(f"Feature tags: {', '.join(tags)}")
- lines.append("")
- lines.append("Source snippet:")
- lines.append(snippet[:800])
- if context:
- lines.append("")
- lines.append(f"Context: {context[:400]}")
- user_message = "\n".join(lines)
-
- resp = self._call_api(self.MODEL_HAIKU, user_message, response_contract=response_contract)
- if not resp:
- resp = self._call_api(self.MODEL_SONNET, user_message, response_contract=response_contract)
- if not resp:
- return None
-
- try:
- parsed = json.loads(resp.text)
- except json.JSONDecodeError:
- return None
-
- return {
- "purpose": parsed.get("purpose", ""),
- "why_it_exists": parsed.get("why_it_exists", ""),
- "how_it_is_used": parsed.get("how_it_is_used", ""),
- "inputs_outputs": parsed.get("inputs_outputs", ""),
- "side_effects": parsed.get("side_effects", ""),
- "risks": parsed.get("risks", ""),
- "language": parsed.get("language", entity.get("metadata", {}).get("language", "unknown")),
- "llm_model": resp.model,
- "llm_latency_ms": resp.latency_ms,
- "llm_input_tokens": resp.usage["input_tokens"],
- "llm_output_tokens": resp.usage["output_tokens"],
- "llm_generated": True,
- }
-
-
-class OpusMaxToolProvider(OpusMaxBaseProvider):
- def _extract_results(self, payload: dict[str, Any]) -> list[Any]:
- for key in ("results", "items", "data", "organic"):
- value = payload.get(key)
- if isinstance(value, list):
- return value
- return []
-
- def _extract_summary(self, payload: dict[str, Any]) -> str:
- for key in ("summary", "answer", "result", "analysis", "message"):
- value = payload.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- return ""
-
- def web_search(self, query: str, max_results: int | None = None) -> dict[str, Any]:
- cleaned_query = query.strip()
- if not cleaned_query:
- raise ValueError("query is required")
- if len(cleaned_query) > MAX_QUERY_CHARS:
- raise ValueError(f"query must be {MAX_QUERY_CHARS} characters or fewer.")
- if max_results is not None and not 1 <= int(max_results) <= MAX_RESULTS_LIMIT:
- raise ValueError(f"max_results must be between 1 and {MAX_RESULTS_LIMIT}.")
- request_id = f"ws_{uuid.uuid4().hex[:10]}"
- payload: dict[str, Any] = {"query": cleaned_query}
- if max_results is not None:
- payload["max_results"] = int(max_results)
- result = self._post_json(f"{self.api_root}/tools/web_search", payload)
- return {
- "request_id": request_id,
- "provider": "opusmax",
- "endpoint": "/tools/web_search",
- "query": cleaned_query,
- "latency_ms": result.latency_ms,
- "results": self._extract_results(result.data),
- "summary": self._extract_summary(result.data),
- "raw": result.data,
- }
-
- def _guess_mime_type(self, path: Path, mime_type: str | None = None) -> str:
- if mime_type:
- guessed = mime_type.strip().lower()
- else:
- guessed = (mimetypes.guess_type(path.name)[0] or "").lower()
- if guessed not in SUPPORTED_IMAGE_MIME_TYPES:
- raise ValueError("Only JPEG, PNG, and WebP image inputs are supported.")
- return guessed
-
- def _file_to_data_url(self, image_path: str, mime_type: str | None = None) -> tuple[str, dict[str, Any]]:
- path = Path(image_path).expanduser().resolve()
- if not path.exists() or not path.is_file():
- raise ValueError(f"Image path does not exist: {image_path}")
- size_bytes = path.stat().st_size
- if size_bytes > MAX_IMAGE_BYTES:
- raise ValueError(f"Image file exceeds the {MAX_IMAGE_BYTES // (1024 * 1024)} MB limit.")
- detected_mime = self._guess_mime_type(path, mime_type=mime_type)
- encoded = base64.b64encode(path.read_bytes()).decode("ascii")
- return (
- f"data:{detected_mime};base64,{encoded}",
- {"kind": "path", "path": str(path), "mime_type": detected_mime, "size_bytes": size_bytes},
- )
-
- def _coerce_image_url(
- self,
- *,
- image_url: str | None = None,
- image_path: str | None = None,
- image_base64: str | None = None,
- mime_type: str | None = None,
- ) -> tuple[str, dict[str, Any]]:
- if image_path:
- return self._file_to_data_url(image_path, mime_type=mime_type)
- if image_url:
- candidate = image_url.strip()
- if not candidate:
- raise ValueError("image_url cannot be empty")
- if candidate.startswith(("http://", "https://", "data:")):
- return candidate, {"kind": "url" if candidate.startswith("http") else "data_url"}
- path_candidate = Path(candidate).expanduser()
- if path_candidate.exists():
- return self._file_to_data_url(str(path_candidate), mime_type=mime_type)
- raise ValueError("image_url must be an http(s) URL, a data URL, or an existing local file path.")
- if image_base64:
- encoded = image_base64.strip()
- if not encoded:
- raise ValueError("image_base64 cannot be empty")
- if len(encoded) > ((MAX_IMAGE_BYTES * 4) // 3) + 1024:
- raise ValueError("image_base64 payload exceeds the configured image size limit.")
- if encoded.startswith("data:"):
- return encoded, {"kind": "data_url"}
- normalized_mime = (mime_type or "image/png").strip().lower()
- if normalized_mime not in SUPPORTED_IMAGE_MIME_TYPES:
- raise ValueError("mime_type must be image/jpeg, image/png, or image/webp when image_base64 is provided.")
- return f"data:{normalized_mime};base64,{encoded}", {"kind": "base64", "mime_type": normalized_mime}
- raise ValueError("Provide one of: image_url, image_path, or image_base64.")
-
- def understand_image(
- self,
- prompt: str,
- *,
- image_url: str | None = None,
- image_path: str | None = None,
- image_base64: str | None = None,
- mime_type: str | None = None,
- ) -> dict[str, Any]:
- cleaned_prompt = prompt.strip()
- if not cleaned_prompt:
- raise ValueError("prompt is required")
- if len(cleaned_prompt) > MAX_PROMPT_CHARS:
- raise ValueError(f"prompt must be {MAX_PROMPT_CHARS} characters or fewer.")
- request_id = f"img_{uuid.uuid4().hex[:10]}"
- resolved_image_url, source_meta = self._coerce_image_url(
- image_url=image_url,
- image_path=image_path,
- image_base64=image_base64,
- mime_type=mime_type,
- )
- payload = {
- "prompt": cleaned_prompt,
- "image_url": resolved_image_url,
- }
- result = self._post_json(f"{self.api_root}/tools/understand_image", payload, timeout=max(self.default_timeout, IMAGE_ANALYSIS_TIMEOUT))
- return {
- "request_id": request_id,
- "provider": "opusmax",
- "endpoint": "/tools/understand_image",
- "prompt": cleaned_prompt,
- "latency_ms": result.latency_ms,
- "image_source": source_meta,
- "analysis": self._extract_summary(result.data) or result.data,
- "raw": result.data,
- }
-
-
-_text_provider: OpusMaxTextProvider | None = None
-_tool_provider: OpusMaxToolProvider | None = None
-
-
-def get_opusmax_text_provider() -> OpusMaxTextProvider:
- global _text_provider
- if _text_provider is None:
- _text_provider = OpusMaxTextProvider()
- return _text_provider
-
-
-def get_opusmax_tool_provider() -> OpusMaxToolProvider:
- global _tool_provider
- if _tool_provider is None:
- _tool_provider = OpusMaxToolProvider()
- return _tool_provider
diff --git a/server/output_policy.py b/server/output_policy.py
deleted file mode 100644
index 5084aef..0000000
--- a/server/output_policy.py
+++ /dev/null
@@ -1,209 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import asdict, dataclass, field
-from typing import Any
-
-from .config import OutputCompressionConfig
-
-
-DESTRUCTIVE_MARKERS = ("rm ", "del ", "remove-item", "git reset", "git checkout --", "drop ", "truncate ", "shutdown", "reboot")
-SECURITY_MARKERS = ("secret", "token", "credential", "password", "auth", "jwt", "oauth", "api key", "private key")
-LEGAL_MEDICAL_FINANCIAL_MARKERS = ("legal", "medical", "financial", "compliance", "diagnosis", "tax", "regulation")
-AMBIGUITY_MARKERS = ("unclear", "ambiguous", "not sure", "unsure", "which one", "clarify")
-STEP_BY_STEP_MARKERS = ("step by step", "step-by-step", "exact steps", "walk me through")
-
-
-@dataclass
-class EffectiveOutputPolicy:
- enabled: bool
- mode: str
- style: str
- level: str
- task_type: str
- operation_kind: str
- detail_requested: bool = False
- bypassed: bool = False
- bypass_reason: str | None = None
- prompt_contract: str = ""
- metadata: dict[str, Any] = field(default_factory=dict)
-
- def to_dict(self) -> dict[str, Any]:
- return asdict(self)
-
-
-def _task_text(task: dict[str, Any] | None) -> str:
- if not task:
- return ""
- title = str(task.get("title", "") or "")
- description = str(task.get("description", "") or "")
- tags = " ".join(str(item) for item in (task.get("tags") or []))
- return f"{title} {description} {tags}".strip().lower()
-
-
-def infer_task_type(
- task: dict[str, Any] | None,
- *,
- operation_kind: str = "general",
- command: str | None = None,
-) -> str:
- normalized_operation = (operation_kind or "general").strip().lower()
- if normalized_operation in {"review", "debugging", "architecture", "dangerous_actions"}:
- return normalized_operation
- command_text = (command or "").strip().lower()
- if any(marker in command_text for marker in DESTRUCTIVE_MARKERS):
- return "dangerous_actions"
-
- text = _task_text(task)
- if any(term in text for term in ("review", "finding", "regression", "code review")):
- return "review"
- if any(term in text for term in ("bug", "debug", "failure", "error", "investigate")):
- return "debugging"
- if any(term in text for term in ("architecture", "design", "system", "refactor")):
- return "architecture"
- return "general"
-
-
-def detect_safety_bypass(
- config: OutputCompressionConfig,
- *,
- task: dict[str, Any] | None,
- operation_kind: str = "general",
- command: str | None = None,
- detail_requested: bool = False,
-) -> str | None:
- if detail_requested and config.expand_on_request:
- return "detail_requested"
- if not config.safety_bypass.enabled:
- return None
-
- normalized_operation = (operation_kind or "general").strip().lower()
- command_text = (command or "").strip().lower()
- combined_text = f"{normalized_operation} {command_text} {_task_text(task)}".strip()
-
- if config.safety_bypass.destructive_actions:
- if normalized_operation == "dangerous_actions" or any(marker in command_text for marker in DESTRUCTIVE_MARKERS):
- return "destructive_actions"
- if config.safety_bypass.security_sensitive:
- if normalized_operation == "security_sensitive" or any(marker in combined_text for marker in SECURITY_MARKERS):
- return "security_sensitive"
- if config.safety_bypass.legal_medical_financial:
- if normalized_operation in {"legal_medical_financial", "legal", "medical", "financial"} or any(
- marker in combined_text for marker in LEGAL_MEDICAL_FINANCIAL_MARKERS
- ):
- return "legal_medical_financial"
- if config.safety_bypass.ambiguity_clarification:
- if normalized_operation == "ambiguity_clarification" or any(marker in combined_text for marker in AMBIGUITY_MARKERS):
- return "ambiguity_clarification"
- if config.safety_bypass.step_by_step_sensitive:
- if normalized_operation == "step_by_step_sensitive" or any(marker in combined_text for marker in STEP_BY_STEP_MARKERS):
- return "step_by_step_sensitive"
- return None
-
-
-def build_prompt_only_contract(
- config: OutputCompressionConfig,
- *,
- task_type: str,
-) -> str:
- prompt_only = config.prompt_only
- lines = ["## Response Style Contract", ""]
- if prompt_only.direct_answer_first:
- lines.append("- Start with the answer, result, or recommendation.")
- if prompt_only.no_greetings:
- lines.append("- Skip greetings and conversational filler.")
- if prompt_only.no_recap:
- lines.append("- Do not restate the question or add a closing recap unless it changes meaning.")
- if prompt_only.short_paragraphs:
- lines.append(f"- Keep paragraphs short, usually at most {prompt_only.max_paragraph_sentences} sentences.")
- if prompt_only.prefer_bullets_for_lists:
- lines.append("- Use bullets only when listing distinct items or steps.")
- lines.append("- Preserve code, commands, paths, numbers, errors, and concrete technical facts.")
- if task_type == "review" and prompt_only.findings_first_for_reviews:
- lines.append("- For reviews, lead with findings and risks before summary.")
- elif task_type == "debugging":
- lines.append("- For debugging, state root cause, fix, and risk without extra narration.")
- elif task_type == "architecture":
- lines.append("- For architecture questions, keep the recommendation compact but include the key tradeoff.")
- if config.style == "terse_technical":
- lines.append("- Prefer dense technical phrasing over conversational explanation.")
- elif config.style == "ultra_terse":
- lines.append("- Be extremely brief unless precision would suffer.")
- return "\n".join(lines).strip() + "\n"
-
-
-def build_gateway_contract(
- config: OutputCompressionConfig,
- *,
- task_type: str,
-) -> str:
- gateway = config.gateway_enforced
- lines = [
- "## Enforced Response Contract",
- "",
- f"- Keep the response within roughly {gateway.max_output_tokens_soft} output tokens when possible.",
- f"- Use no more than {gateway.max_output_sections} major sections.",
- f"- Keep paragraphs to about {gateway.max_paragraph_lines} lines.",
- ]
- if gateway.enforce_direct_answer_first:
- lines.append("- Put the answer first.")
- if task_type == "review" and gateway.enforce_findings_first_for_reviews:
- lines.append("- For reviews, findings must come before any overview.")
- lines.append("- Preserve critical technical detail even when shortening phrasing.")
- return "\n".join(lines).strip() + "\n"
-
-
-def resolve_output_policy(
- config: OutputCompressionConfig,
- *,
- task: dict[str, Any] | None = None,
- operation_kind: str = "general",
- detail_requested: bool = False,
- command: str | None = None,
-) -> EffectiveOutputPolicy:
- task_type = infer_task_type(task, operation_kind=operation_kind, command=command)
- mode = config.mode if config.enabled else "off"
- style = config.style
- level = config.level
-
- override = config.task_overrides.get(task_type)
- if override:
- if override.mode is not None:
- mode = override.mode
- if override.style is not None:
- style = override.style
- if override.level is not None:
- level = override.level
-
- bypass_reason = detect_safety_bypass(
- config,
- task=task,
- operation_kind=operation_kind,
- command=command,
- detail_requested=detail_requested,
- )
- bypassed = bypass_reason is not None
- effective_mode = "off" if bypassed else mode
-
- contract = ""
- if effective_mode == "prompt_only":
- contract = build_prompt_only_contract(config, task_type=task_type)
- elif effective_mode == "gateway_enforced":
- contract = build_gateway_contract(config, task_type=task_type)
-
- return EffectiveOutputPolicy(
- enabled=effective_mode != "off",
- mode=effective_mode,
- style=style,
- level=level,
- task_type=task_type,
- operation_kind=(operation_kind or "general").strip().lower(),
- detail_requested=detail_requested,
- bypassed=bypassed,
- bypass_reason=bypass_reason,
- prompt_contract=contract,
- metadata={
- "configured_mode": config.mode,
- "configured_style": config.style,
- "configured_level": config.level,
- },
- )
diff --git a/server/projects.py b/server/projects.py
deleted file mode 100644
index 0461295..0000000
--- a/server/projects.py
+++ /dev/null
@@ -1,103 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-from dataclasses import asdict, dataclass, field
-from pathlib import Path
-from typing import Any
-
-from .utils import read_json_with_retry, slugify, utc_now, write_json_atomic
-
-
-def project_slug_for_path(project_path: str | Path) -> str:
- path = Path(project_path).resolve()
- base_slug = slugify(path.name or "project", max_length=32)
- digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:8]
- return f"{base_slug}-{digest}"
-
-
-@dataclass
-class ProjectRecord:
- slug: str
- name: str
- repo_path: str
- workspace_path: str
- vault_path: str
- context_path: str
- db_path: str
- created_at: str
- last_active_at: str
- tags: list[str] = field(default_factory=list)
- active_session_count: int = 0
-
-
-class ProjectRegistry:
- def __init__(self, registry_path: Path) -> None:
- self.registry_path = registry_path
- self.registry_path.parent.mkdir(parents=True, exist_ok=True)
-
- def _load_payload(self) -> dict[str, Any]:
- return read_json_with_retry(self.registry_path, {"projects": []})
-
- def _save_payload(self, payload: dict[str, Any]) -> None:
- write_json_atomic(self.registry_path, payload)
-
- def list_projects(self) -> list[dict[str, Any]]:
- payload = self._load_payload()
- return sorted(payload.get("projects", []), key=lambda item: item.get("last_active_at", ""), reverse=True)
-
- def get_by_slug(self, project_slug: str) -> dict[str, Any] | None:
- for item in self.list_projects():
- if item.get("slug") == project_slug:
- return item
- return None
-
- def get_by_repo_path(self, repo_path: str | Path) -> dict[str, Any] | None:
- normalized = str(Path(repo_path).resolve())
- for item in self.list_projects():
- if item.get("repo_path") == normalized:
- return item
- return None
-
- def register(self, record: ProjectRecord) -> dict[str, Any]:
- payload = self._load_payload()
- projects = payload.setdefault("projects", [])
- existing_index = next((idx for idx, item in enumerate(projects) if item.get("slug") == record.slug), None)
- row = asdict(record)
- if existing_index is None:
- projects.append(row)
- else:
- projects[existing_index] = row
- self._save_payload(payload)
- return row
-
- def touch(
- self,
- project_slug: str,
- *,
- active_session_count: int | None = None,
- name: str | None = None,
- tags: list[str] | None = None,
- ) -> dict[str, Any] | None:
- payload = self._load_payload()
- projects = payload.setdefault("projects", [])
- now = utc_now()
- for item in projects:
- if item.get("slug") != project_slug:
- continue
- item["last_active_at"] = now
- if active_session_count is not None:
- item["active_session_count"] = active_session_count
- if name:
- item["name"] = name
- if tags is not None:
- item["tags"] = tags
- self._save_payload(payload)
- return item
- return None
-
- def resolve(self, project_slug: str | None = None, repo_path: str | Path | None = None) -> dict[str, Any] | None:
- if project_slug:
- return self.get_by_slug(project_slug)
- if repo_path:
- return self.get_by_repo_path(repo_path)
- return None
diff --git a/server/semantic.py b/server/semantic.py
deleted file mode 100644
index 85e7c42..0000000
--- a/server/semantic.py
+++ /dev/null
@@ -1,583 +0,0 @@
-from __future__ import annotations
-
-import re
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any
-
-from .code_atlas import AtlasResult, ClassInfo, FileInfo, FunctionInfo
-from .utils import file_fingerprint, read_text_with_retry, slugify, utc_now
-from .llm_client import generate_llm_description
-
-
-def _normalize_relpath(value: str) -> str:
- return value.replace("\\", "/")
-
-
-def _first_sentence(value: str) -> str:
- text = " ".join(value.split()).strip()
- if not text:
- return ""
- for sep in [". ", "\n", "; "]:
- if sep in text:
- return text.split(sep, 1)[0].strip().rstrip(".")
- return text[:180].rstrip(".")
-
-
-def _name_to_phrase(name: str) -> str:
- words = [item for item in re.split(r"[._\-]+", name) if item]
- if not words:
- words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+", name) or [name]
- return " ".join(word.lower() for word in words)
-
-
-def _stable_entity_key(entity_type: str, file_path: str, name: str, line_number: int = 0) -> str:
- if entity_type == "module":
- return f"module:{file_path}"
- if entity_type == "feature":
- return f"feature:{slugify(name, max_length=64)}"
- return f"{entity_type}:{file_path}:{name}:{line_number}"
-
-
-def _symbol_path(file_path: str, name: str | None = None) -> str:
- return f"{file_path}::{name}" if name else file_path
-
-
-def _make_summary_hint(entity_type: str, name: str, docstring: str, file_path: str, feature_tags: list[str]) -> str:
- if docstring:
- return _first_sentence(docstring)
- if entity_type == "module":
- tag_text = ", ".join(feature_tags[:3]) or "project"
- return f"Module `{file_path}` provides {tag_text.lower()} behavior."
- if entity_type == "class":
- return f"Class `{name}` groups {_name_to_phrase(name)} behavior."
- if entity_type == "function":
- return f"Function `{name}` handles {_name_to_phrase(name)}."
- return f"Feature `{name}` groups related project behavior."
-
-
-def _read_snippet(root_path: Path, file_path: str, line_number: int = 0, radius: int = 18) -> str:
- absolute = root_path / file_path
- if not absolute.exists():
- return ""
- try:
- lines = read_text_with_retry(absolute, encoding="utf-8", errors="ignore").splitlines()
- except OSError:
- return ""
- if line_number <= 0:
- return "\n".join(lines[: min(len(lines), radius * 2)])
- start = max(0, line_number - radius - 1)
- end = min(len(lines), line_number + radius)
- return "\n".join(lines[start:end])
-
-
-def _infer_side_effects(snippet: str) -> str:
- lowered = snippet.lower()
- effects: list[str] = []
- if any(token in lowered for token in ["write_text", "write_json", "unlink(", "remove-item", "delete", "replace("]):
- effects.append("writes or deletes local files")
- if any(token in lowered for token in ["subprocess", "popen(", "run(", "start-process", "taskkill"]):
- effects.append("launches or manages subprocesses")
- if any(token in lowered for token in ["sqlite", "execute(", "insert into", "update ", "delete from"]):
- effects.append("reads or mutates SQLite-backed state")
- if any(token in lowered for token in ["fastapi", "uvicorn", "@app.", "http", "request", "response"]):
- effects.append("handles HTTP or MCP traffic")
- if any(token in lowered for token in ["obsidian", "vault", ".md", "markdown"]):
- effects.append("updates Obsidian or Markdown artifacts")
- if any(token in lowered for token in ["os.walk", "rglob(", "scan("]):
- effects.append("scans the project filesystem")
- return "; ".join(dict.fromkeys(effects)) if effects else "No important side effects inferred from the current code slice."
-
-
-def _infer_risks(snippet: str, related_files: list[str], signature: str) -> str:
- lowered = snippet.lower()
- risks: list[str] = []
- if "delete" in lowered or "remove-item" in lowered:
- risks.append("destructive filesystem behavior requires path safety")
- if any(token in lowered for token in ["execute(", "insert into", "update ", "delete from"]):
- risks.append("schema or data changes can affect continuity state")
- if any(token in lowered for token in ["subprocess", "popen(", "start-process"]):
- risks.append("process-launch behavior can vary across Windows shells")
- if len(related_files) > 4:
- risks.append("multiple related files raise coordination risk during handoff")
- if signature and len(signature) > 120:
- risks.append("large signature suggests broad responsibilities")
- return "; ".join(dict.fromkeys(risks)) if risks else "Low risk based on current structural signals."
-
-
-@dataclass
-class SemanticEntity:
- entity_key: str
- entity_type: str
- name: str
- file_path: str
- symbol_path: str
- signature: str
- line_number: int
- feature_tags: list[str]
- source_files: list[str]
- source_fingerprint: str
- summary_hint: str
- metadata: dict[str, Any]
-
- def to_index_row(self) -> dict[str, Any]:
- return {
- "entity_key": self.entity_key,
- "entity_type": self.entity_type,
- "name": self.name,
- "file_path": self.file_path,
- "symbol_path": self.symbol_path,
- "signature": self.signature,
- "line_number": self.line_number,
- "feature_tags": self.feature_tags,
- "source_files": self.source_files,
- "source_fingerprint": self.source_fingerprint,
- "summary_hint": self.summary_hint,
- "metadata": self.metadata,
- "updated_at": utc_now(),
- }
-
-
-class SemanticIndex:
- def __init__(self, root_path: Path, result: AtlasResult) -> None:
- self.root_path = root_path
- self.result = result
- self.file_map: dict[str, FileInfo] = {_normalize_relpath(item.relative_path): item for item in result.files}
- self.entities = self._build_entities()
- self.entity_map = {item.entity_key: item for item in self.entities}
-
- def _fingerprint_for(self, relative_path: str) -> str:
- absolute = self.root_path / relative_path
- if not absolute.exists():
- return "missing"
- return file_fingerprint(absolute)["fingerprint"]
-
- def _module_entity(self, file_info: FileInfo) -> SemanticEntity:
- relpath = _normalize_relpath(file_info.relative_path)
- return SemanticEntity(
- entity_key=_stable_entity_key("module", relpath, relpath),
- entity_type="module",
- name=Path(relpath).name,
- file_path=relpath,
- symbol_path=_symbol_path(relpath),
- signature="",
- line_number=1,
- feature_tags=list(file_info.feature_tags),
- source_files=[relpath],
- source_fingerprint=self._fingerprint_for(relpath),
- summary_hint=_make_summary_hint("module", relpath, file_info.docstring, relpath, file_info.feature_tags),
- metadata={
- "docstring": file_info.docstring,
- "language": file_info.language,
- "imports": [item.to_dict() for item in file_info.imports],
- "class_count": len(file_info.classes),
- "function_count": len(file_info.functions) + len(file_info.raw_functions),
- "structure": file_info.structure,
- },
- )
-
- def _class_entity(self, file_info: FileInfo, class_info: ClassInfo) -> SemanticEntity:
- relpath = _normalize_relpath(file_info.relative_path)
- signature = f"class {class_info.name}({', '.join(class_info.bases)})" if class_info.bases else f"class {class_info.name}"
- return SemanticEntity(
- entity_key=_stable_entity_key("class", relpath, class_info.name, class_info.line_number),
- entity_type="class",
- name=class_info.name,
- file_path=relpath,
- symbol_path=_symbol_path(relpath, class_info.name),
- signature=signature,
- line_number=class_info.line_number,
- feature_tags=list(dict.fromkeys(file_info.feature_tags + ["Class"])),
- source_files=[relpath],
- source_fingerprint=self._fingerprint_for(relpath),
- summary_hint=_make_summary_hint("class", class_info.name, class_info.docstring, relpath, file_info.feature_tags),
- metadata={
- "docstring": class_info.docstring,
- "bases": class_info.bases,
- "methods": [item.to_dict() for item in class_info.methods],
- },
- )
-
- def _method_entity(self, file_info: FileInfo, class_info: ClassInfo, method_info: FunctionInfo) -> SemanticEntity:
- relpath = _normalize_relpath(file_info.relative_path)
- method_name = f"{class_info.name}.{method_info.name}"
- if method_info.signature:
- signature_tail = method_info.signature[4:] if method_info.signature.startswith("def ") else method_info.signature
- signature = f"{class_info.name}.{signature_tail}"
- else:
- signature = method_name
- return SemanticEntity(
- entity_key=_stable_entity_key("function", relpath, method_name, method_info.line_number),
- entity_type="function",
- name=method_info.name,
- file_path=relpath,
- symbol_path=_symbol_path(relpath, method_name),
- signature=signature,
- line_number=method_info.line_number,
- feature_tags=list(dict.fromkeys(file_info.feature_tags + ["Function", "Method"])),
- source_files=[relpath],
- source_fingerprint=self._fingerprint_for(relpath),
- summary_hint=_make_summary_hint("function", method_name, method_info.docstring, relpath, file_info.feature_tags),
- metadata={
- "docstring": method_info.docstring,
- "visibility": method_info.visibility,
- "class_name": class_info.name,
- "method_name": method_info.name,
- "bases": class_info.bases,
- },
- )
-
- def _function_entity(self, file_info: FileInfo, function_info: FunctionInfo) -> SemanticEntity:
- relpath = _normalize_relpath(file_info.relative_path)
- return SemanticEntity(
- entity_key=_stable_entity_key("function", relpath, function_info.name, function_info.line_number),
- entity_type="function",
- name=function_info.name,
- file_path=relpath,
- symbol_path=_symbol_path(relpath, function_info.name),
- signature=function_info.signature or function_info.name,
- line_number=function_info.line_number,
- feature_tags=list(dict.fromkeys(file_info.feature_tags + ["Function"])),
- source_files=[relpath],
- source_fingerprint=self._fingerprint_for(relpath),
- summary_hint=_make_summary_hint("function", function_info.name, function_info.docstring, relpath, file_info.feature_tags),
- metadata={
- "docstring": function_info.docstring,
- "visibility": function_info.visibility,
- },
- )
-
- def _feature_entities(self) -> list[SemanticEntity]:
- by_feature: dict[str, list[FileInfo]] = {}
- for file_info in self.result.files:
- for tag in file_info.feature_tags:
- by_feature.setdefault(tag, []).append(file_info)
- entities: list[SemanticEntity] = []
- for tag, files in sorted(by_feature.items()):
- relpaths = sorted({_normalize_relpath(item.relative_path) for item in files})
- combined = "|".join(self._fingerprint_for(path) for path in relpaths)
- entities.append(
- SemanticEntity(
- entity_key=_stable_entity_key("feature", relpaths[0] if relpaths else "feature", tag),
- entity_type="feature",
- name=tag,
- file_path=relpaths[0] if relpaths else "",
- symbol_path=tag,
- signature="",
- line_number=1,
- feature_tags=[tag],
- source_files=relpaths,
- source_fingerprint=combined,
- summary_hint=f"Feature `{tag}` spans {len(relpaths)} file(s).",
- metadata={
- "files": relpaths,
- "language_count": len({item.language for item in files}),
- },
- )
- )
- return entities
-
- def _build_entities(self) -> list[SemanticEntity]:
- entities: list[SemanticEntity] = []
- for file_info in self.result.files:
- entities.append(self._module_entity(file_info))
- for class_info in file_info.classes:
- entities.append(self._class_entity(file_info, class_info))
- for method_info in class_info.methods:
- entities.append(self._method_entity(file_info, class_info, method_info))
- for function_info in [*file_info.functions, *file_info.raw_functions]:
- entities.append(self._function_entity(file_info, function_info))
- entities.extend(self._feature_entities())
- return entities
-
- def build_index_payload(self) -> dict[str, Any]:
- file_fingerprints = []
- for relative_path in sorted(self.file_map.keys()):
- absolute = self.root_path / relative_path
- if absolute.exists():
- payload = file_fingerprint(absolute)
- payload["file_path"] = relative_path
- file_fingerprints.append(payload)
- return {
- "entities": [item.to_index_row() for item in self.entities],
- "file_fingerprints": file_fingerprints,
- "counts": {
- "modules": len([item for item in self.entities if item.entity_type == "module"]),
- "functions": len([item for item in self.entities if item.entity_type == "function"]),
- "classes": len([item for item in self.entities if item.entity_type == "class"]),
- "features": len([item for item in self.entities if item.entity_type == "feature"]),
- },
- }
-
- def get_module(self, module_path: str) -> SemanticEntity | None:
- normalized = _normalize_relpath(module_path)
- key = _stable_entity_key("module", normalized, normalized)
- return self.entity_map.get(key)
-
- def get_symbol_candidates(self, symbol_name: str, module_path: str | None = None, entity_types: list[str] | None = None) -> list[SemanticEntity]:
- normalized_module = _normalize_relpath(module_path) if module_path else None
- allowed = set(entity_types or ["function", "class"])
- candidates = [
- item
- for item in self.entities
- if item.entity_type in allowed and item.name == symbol_name and (normalized_module is None or item.file_path == normalized_module)
- ]
- return sorted(candidates, key=lambda item: (item.file_path, item.line_number))
-
- def get_feature(self, feature_name: str) -> SemanticEntity | None:
- target = feature_name.lower()
- partial: SemanticEntity | None = None
- for item in self.entities:
- if item.entity_type != "feature":
- continue
- lowered = item.name.lower()
- if lowered == target:
- return item
- if partial is None and (target in lowered or lowered in target):
- partial = item
- return partial
-
- def search(self, query: str, limit: int = 10) -> list[SemanticEntity]:
- normalized = query.lower()
- scored: list[tuple[int, SemanticEntity]] = []
- for entity in self.entities:
- haystack = " ".join(
- [
- entity.name,
- entity.file_path,
- entity.symbol_path,
- entity.summary_hint,
- " ".join(entity.feature_tags),
- ]
- ).lower()
- if normalized not in haystack:
- continue
- score = 0
- if entity.name.lower() == normalized:
- score += 10
- if entity.file_path.lower() == normalized:
- score += 9
- if normalized in entity.name.lower():
- score += 5
- if normalized in entity.file_path.lower():
- score += 3
- scored.append((score, entity))
- scored.sort(key=lambda item: (-item[0], item[1].entity_type, item[1].file_path, item[1].line_number))
- return [entity for _, entity in scored[:limit]]
-
- def related_symbols(self, entity: SemanticEntity, limit: int = 8) -> list[SemanticEntity]:
- related: list[SemanticEntity] = []
- for candidate in self.entities:
- if candidate.entity_key == entity.entity_key:
- continue
- if candidate.file_path == entity.file_path and candidate.entity_type in {"module", "function", "class"}:
- related.append(candidate)
- continue
- if set(candidate.feature_tags) & set(entity.feature_tags):
- related.append(candidate)
- unique: dict[str, SemanticEntity] = {}
- for item in related:
- unique.setdefault(item.entity_key, item)
- return list(unique.values())[:limit]
-
- def render_summary_markdown(self) -> str:
- lines = [
- "# Symbol Index Summary",
- "",
- f"- Modules: {len([item for item in self.entities if item.entity_type == 'module'])}",
- f"- Functions: {len([item for item in self.entities if item.entity_type == 'function'])}",
- f"- Classes: {len([item for item in self.entities if item.entity_type == 'class'])}",
- f"- Features: {len([item for item in self.entities if item.entity_type == 'feature'])}",
- "",
- ]
- return "\n".join(lines)
-
-
-def build_semantic_index(root_path: Path | str, result: AtlasResult) -> SemanticIndex:
- return SemanticIndex(Path(root_path), result)
-
-
-def generate_semantic_description(
- entity: dict[str, Any],
- index: SemanticIndex,
- store: Any,
- project_root: Path,
- force_llm: bool = False,
- allow_llm: bool = True,
- response_contract: str | None = None,
-) -> dict[str, Any]:
- file_path = entity["file_path"]
- snippet = _read_snippet(project_root, file_path, entity.get("line_number", 0))
- metadata = entity.get("metadata", {})
- docstring = metadata.get("docstring", "")
- language = metadata.get("language", "")
- related_task_rows = store.get_tasks_for_files([file_path], limit=5) if file_path else []
- related_decision_rows = store.get_related_decisions_for_files([file_path], limit=5) if file_path else []
- related_symbols = index.related_symbols(index.entity_map[entity["entity_key"]], limit=6)
- related_files = list(dict.fromkeys([file_path, *metadata.get("files", []), *store.get_recent_file_activity(limit=8)]))
-
- # Build context string for LLM
- context_parts = []
- if related_decision_rows:
- context_parts.append("Related decisions: " + "; ".join(f"{d['title']}: {d['decision']}" for d in related_decision_rows[:3]))
- if related_task_rows:
- context_parts.append("Related tasks: " + ", ".join(f"{t['id']} {t['title']}" for t in related_task_rows[:3]))
- context_str = " | ".join(context_parts) if context_parts else None
-
- # Try LLM generation first if force_llm or docstring present
- llm_description: dict[str, Any] | None = None
- if allow_llm and (force_llm or docstring):
- llm_description = generate_llm_description(entity, snippet, context_str, response_contract=response_contract)
-
- if entity["entity_type"] == "module":
- purpose = llm_description["purpose"] if llm_description else (docstring or f"Implements {_name_to_phrase(Path(file_path).stem)} behavior in the `{Path(file_path).parent}` area of the project.")
- why = llm_description["why_it_exists"] if llm_description else (f"Exists to provide {', '.join(entity.get('feature_tags') or [language or 'project'])} behavior for the codebase.")
- how = llm_description["how_it_is_used"] if llm_description else (f"Contains {metadata.get('class_count', 0)} class(es), {metadata.get('function_count', 0)} function(s), and is a likely entry point for edits touching `{file_path}`.")
- inputs_outputs = llm_description["inputs_outputs"] if llm_description else "Primary inputs and outputs are exposed through the module's public classes, functions, configuration structure, and imported integrations."
- side_effects = llm_description["side_effects"] if llm_description else _infer_side_effects(snippet)
- risks = llm_description["risks"] if llm_description else _infer_risks(snippet, related_files, "")
- detected_language = llm_description["language"] if llm_description else language
- elif entity["entity_type"] == "class":
- bases = metadata.get("bases", [])
- purpose = llm_description["purpose"] if llm_description else (docstring or f"Encapsulates {_name_to_phrase(entity['name'])} behavior inside `{file_path}`.")
- why = llm_description["why_it_exists"] if llm_description else (f"Exists to centralize state and methods for `{entity['name']}` so related responsibilities stay grouped.")
- how = llm_description["how_it_is_used"] if llm_description else (f"Used through its methods in `{file_path}` and nearby symbols; base classes: {', '.join(bases) if bases else 'none'}.")
- inputs_outputs = llm_description["inputs_outputs"] if llm_description else (f"Constructor and method surface follows `{entity['signature']}` and the class methods recorded in the Code Atlas.")
- side_effects = llm_description["side_effects"] if llm_description else _infer_side_effects(snippet)
- risks = llm_description["risks"] if llm_description else _infer_risks(snippet, related_files, entity.get("signature", ""))
- detected_language = llm_description["language"] if llm_description else language
- elif entity["entity_type"] == "function":
- class_name = metadata.get("class_name")
- callable_name = f"{class_name}.{entity['name']}" if class_name else entity["name"]
- purpose = llm_description["purpose"] if llm_description else (docstring or f"Handles {_name_to_phrase(callable_name)} within `{file_path}`.")
- if llm_description:
- why = llm_description["why_it_exists"]
- how = llm_description["how_it_is_used"]
- elif class_name:
- why = f"Exists to keep `{entity['name']}` behavior attached to the `{class_name}` state and lifecycle."
- how = f"Called through `{class_name}` instances when the project needs `{_name_to_phrase(entity['name'])}` behavior."
- else:
- why = f"Exists to isolate one callable unit of {_name_to_phrase(entity['name'])} behavior."
- how = f"Called from `{file_path}` or related symbols when the project needs `{_name_to_phrase(entity['name'])}` behavior."
- inputs_outputs = llm_description["inputs_outputs"] if llm_description else (f"Signature: `{entity['signature'] or callable_name}`.")
- side_effects = llm_description["side_effects"] if llm_description else _infer_side_effects(snippet)
- risks = llm_description["risks"] if llm_description else _infer_risks(snippet, related_files, entity.get("signature", ""))
- detected_language = llm_description["language"] if llm_description else language
- else:
- files = metadata.get("files", [])
- purpose = llm_description["purpose"] if llm_description else (f"Aggregates the `{entity['name']}` feature across {len(files)} file(s).")
- why = llm_description["why_it_exists"] if llm_description else (f"Exists to give agents a compact feature-level view without rereading every implementation file.")
- how = llm_description["how_it_is_used"] if llm_description else (f"Used as a semantic entry point for files tagged `{entity['name']}`.")
- inputs_outputs = llm_description["inputs_outputs"] if llm_description else "Inputs are the tagged implementation files; outputs are the grouped behaviors and workflows they enable."
- side_effects = llm_description["side_effects"] if llm_description else _infer_side_effects(snippet)
- risks = llm_description["risks"] if llm_description else _infer_risks(snippet, related_files, "")
- detected_language = llm_description["language"] if llm_description else language
-
- description = {
- "entity_type": entity["entity_type"],
- "entity_key": entity["entity_key"],
- "name": entity["name"],
- "file": file_path,
- "signature": entity.get("signature", ""),
- "purpose": _first_sentence(purpose) or purpose,
- "why_it_exists": _first_sentence(why) or why,
- "how_it_is_used": _first_sentence(how) or how,
- "inputs_outputs": _first_sentence(inputs_outputs) or inputs_outputs,
- "side_effects": _first_sentence(side_effects) or side_effects,
- "risks": _first_sentence(risks) or risks,
- "language": detected_language,
- "llm_generated": llm_description is not None,
- "llm_model": llm_description.get("llm_model") if llm_description else None,
- "llm_latency_ms": llm_description.get("llm_latency_ms") if llm_description else None,
- "related_files": [item for item in related_files if item][:8],
- "related_decisions": [
- {"id": item["id"], "title": item["title"], "decision": item["decision"], "created_at": item["created_at"]}
- for item in related_decision_rows
- ],
- "related_tasks": [
- {"id": item["id"], "title": item["title"], "status": item["status"], "priority": item["priority"]}
- for item in related_task_rows
- ],
- "related_symbols": [
- {"entity_key": item.entity_key, "entity_type": item.entity_type, "name": item.name, "file_path": item.file_path}
- for item in related_symbols
- ],
- "source_fingerprint": entity["source_fingerprint"],
- "generated_at": utc_now(),
- "verified_at": utc_now(),
- "freshness": "fresh",
- "metadata": {
- "feature_tags": entity.get("feature_tags", []),
- "summary_hint": entity.get("summary_hint", ""),
- },
- }
- return description
-
-
-def generate_llm_fallback_description(
- entity: dict[str, Any],
- snippet: str,
- response_contract: str | None = None,
-) -> dict[str, Any] | None:
- """
- Standalone LLM description generation that doesn't require the full index/store pipeline.
- Used by the async LLM enrichment path.
- """
- return generate_llm_description(entity, snippet, context=None, response_contract=response_contract)
-
-
-def render_semantic_note(description: dict[str, Any]) -> str:
- file_value = description.get("file") or description.get("file_path", "")
- lines = [
- f"# {description['entity_type'].title()} Knowledge: {description['name']}",
- "",
- f"- Key: `{description['entity_key']}`",
- f"- File: `{file_value}`",
- f"- Generated: {description['generated_at']}",
- f"- Freshness: {description.get('freshness', 'unknown')}",
- "",
- "## Purpose",
- "",
- description["purpose"],
- "",
- "## Why It Exists",
- "",
- description["why_it_exists"],
- "",
- "## How It Is Used",
- "",
- description["how_it_is_used"],
- "",
- "## Inputs and Outputs",
- "",
- description["inputs_outputs"],
- "",
- "## Side Effects",
- "",
- description["side_effects"],
- "",
- "## Risks",
- "",
- description["risks"],
- "",
- "## Related Files",
- "",
- ]
- lines.extend([f"- `{item}`" for item in description.get("related_files", [])] or ["- None"])
- lines.extend(["", "## Related Symbols", ""])
- lines.extend(
- [f"- `{item['entity_type']}` `{item['name']}` in `{item['file_path']}`" for item in description.get("related_symbols", [])]
- or ["- None"]
- )
- lines.extend(["", "## Related Decisions", ""])
- lines.extend(
- [f"- [{item['id']}] {item['title']}: {item['decision']}" for item in description.get("related_decisions", [])]
- or ["- None"]
- )
- lines.extend(["", "## Related Tasks", ""])
- lines.extend(
- [f"- `{item['id']}` {item['title']} [{item['status']}] priority={item['priority']}" for item in description.get("related_tasks", [])]
- or ["- None"]
- )
- lines.append("")
- return "\n".join(lines)
diff --git a/server/service.py b/server/service.py
deleted file mode 100644
index 9426f3f..0000000
--- a/server/service.py
+++ /dev/null
@@ -1,6861 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import re
-import shutil
-import threading
-import time
-import uuid
-from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Callable
-
-from .code_atlas import CodeAtlas, generate_atlas, generate_atlas_markdown
-from .compression import compress, compress_preserve_code
-from .config import AppConfig, ProjectConfig
-from .context_sync import render_handoff_markdown, sync_context_files
-from .hub import sync_hub_vault
-from .obsidian import pull_obsidian_changes, sync_obsidian
-from .projects import ProjectRecord, ProjectRegistry
-from .opusmax_provider import get_opusmax_tool_provider
-from .output_policy import EffectiveOutputPolicy, resolve_output_policy
-from .semantic import build_semantic_index, generate_semantic_description
-from .store import StateStore
-from .utils import is_port_open, read_json_with_retry, slugify, utc_now, write_json_atomic, write_text_atomic
-from .observability import get_logger, span
-
-
-class ObsmcpService:
- API_VERSION = "2026.04.14"
- TOOL_SCHEMA_VERSION = 2
- COMPATIBILITY_RULES_VERSION = 1
- _GLOBAL_TOOLS = {
- "register_project",
- "list_projects",
- "resolve_project",
- "resolve_active_project",
- "get_project_workspace_paths",
- "get_or_create_project",
- "sync_hub",
- "health_check",
- "list_tools",
- "list_resources",
- "generate_startup_prompt_template",
- "get_server_capabilities",
- "check_client_compatibility",
- }
-
- def __init__(self, config: AppConfig) -> None:
- self.config = config
- self.registry = ProjectRegistry(config.registry_path or config.root_dir / "registry" / "projects.json")
- self._stores: dict[str, StateStore] = {}
- self.store: StateStore | None = None
- self._scan_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="obsmcp-scan")
- self._scan_job_futures: dict[str, Future[Any]] = {}
- self._scan_jobs_lock = threading.Lock()
- self._precompute_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="obsmcp-precompute")
- self._precompute_jobs: dict[str, Future[Any]] = {}
- self._precompute_lock = threading.Lock()
- semantic_workers = max(1, int(self.config.semantic_auto_generate.max_concurrent_jobs))
- self._semantic_executor = ThreadPoolExecutor(max_workers=semantic_workers, thread_name_prefix="obsmcp-semantic")
- self._semantic_jobs: dict[str, Future[Any]] = {}
- self._semantic_jobs_lock = threading.Lock()
- self._sync_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="obsmcp-sync")
- self._sync_jobs: dict[str, Future[Any]] = {}
- self._sync_lock = threading.Lock()
- if self.config.bootstrap_default_project_on_startup:
- self.store = self._store_for(None)
-
- def _normalize_client_name(self, client_name: str | None) -> str:
- raw = (client_name or "").strip().lower().replace("_", "-").replace(" ", "-")
- aliases = {
- "claude-code": "claude-code-vscode",
- "vscode-claude-code": "claude-code-vscode",
- "claude-vscode": "claude-code-vscode",
- "codex": "vscode-codex",
- "codex-vscode": "vscode-codex",
- }
- return aliases.get(raw, raw)
-
- def _normalize_model_name(self, model_name: str | None) -> str:
- raw = (model_name or "").strip().lower().replace("_", "-").replace(" ", "-")
- aliases = {
- "opus-4.6": "claude-opus-4-6",
- "claude-opus-4.6": "claude-opus-4-6",
- "gpt5": "gpt-5",
- }
- return aliases.get(raw, raw)
-
- def _tokenize_similarity_text(self, value: str) -> set[str]:
- return {
- token
- for token in re.findall(r"[a-z0-9]{3,}", (value or "").lower())
- if token not in {"task", "work", "session", "project", "please", "create", "open", "resume"}
- }
-
- def _jaccard_similarity(self, left: str, right: str) -> float:
- left_tokens = self._tokenize_similarity_text(left)
- right_tokens = self._tokenize_similarity_text(right)
- if not left_tokens or not right_tokens:
- return 0.0
- union = left_tokens | right_tokens
- if not union:
- return 0.0
- return len(left_tokens & right_tokens) / len(union)
-
- def _derive_session_label(self, initial_request: str, session_goal: str, task: dict[str, Any] | None = None) -> str:
- text = " ".join(part.strip() for part in [initial_request, session_goal] if part and part.strip())
-
- def _humanize_label(candidate: str) -> str:
- normalized = re.sub(r"\s+", " ", candidate).strip(" .:-")
- if not normalized:
- return ""
- normalized = re.sub(r"^(?:the|a|an)\s+", "", normalized, flags=re.IGNORECASE)
- return re.sub(
- r"[A-Za-z][A-Za-z']*",
- lambda match: match.group(0)[:1].upper() + match.group(0)[1:],
- normalized,
- )
-
- patterns = [
- r"\b(?:this is|it is|task is|session is)\s+(?:a\s+)?task\s+for\s+([^.,;\n]{4,80})",
- r"\b(?:task|session|workstream)\s+for\s+([^.,;\n]{4,80})",
- r"\b(?:call|name|label)\s+(?:this\s+)?(?:task|session|workstream)\s+([^.,;\n]{4,80})",
- ]
- for pattern in patterns:
- match = re.search(pattern, text, flags=re.IGNORECASE)
- if match:
- candidate = _humanize_label(match.group(1))
- if candidate:
- return candidate
- if task and task.get("title"):
- return str(task["title"]).strip()[:96]
- summary_source = initial_request.strip() or session_goal.strip()
- if not summary_source:
- return "Untitled Session"
- summary = summary_source.split(".")[0].split("\n")[0].strip()
- summary = re.sub(r"^(please|kindly|now|today)\s+", "", summary, flags=re.IGNORECASE)
- return summary[:96] or "Untitled Session"
-
- def _derive_session_identity(
- self,
- *,
- initial_request: str,
- session_goal: str,
- task: dict[str, Any] | None,
- session_label: str,
- workstream_key: str,
- workstream_title: str,
- ) -> dict[str, str]:
- resolved_label = (session_label or "").strip() or self._derive_session_label(initial_request, session_goal, task=task)
- resolved_workstream_title = (workstream_title or "").strip() or (task.get("title", "").strip() if task else "") or resolved_label
- workstream_source = re.sub(r"[']", "", resolved_workstream_title or resolved_label)
- resolved_workstream_key = (workstream_key or "").strip() or slugify(workstream_source, max_length=48) or "default-workstream"
- return {
- "session_label": resolved_label,
- "workstream_key": resolved_workstream_key,
- "workstream_title": resolved_workstream_title,
- }
-
- def _request_needs_task_anchor(self, initial_request: str, session_goal: str) -> bool:
- combined = " ".join(part.strip() for part in [initial_request, session_goal] if part and part.strip())
- if len(combined) >= 60:
- return True
- verbs = {"implement", "create", "build", "write", "refactor", "debug", "fix", "analyze", "design"}
- return any(token in combined.lower() for token in verbs)
-
- def _session_resume_mismatch_reason(
- self,
- candidate: dict[str, Any],
- *,
- task_id: str | None,
- initial_request: str,
- session_goal: str,
- session_label: str,
- workstream_key: str,
- ) -> str | None:
- if task_id and candidate.get("task_id") and candidate.get("task_id") != task_id:
- return "candidate session belongs to a different task"
- if workstream_key and candidate.get("workstream_key") and candidate.get("workstream_key") != workstream_key:
- return "candidate session belongs to a different workstream"
- incoming_label = (session_label or "").strip().lower()
- candidate_label = str(candidate.get("session_label") or "").strip().lower()
- if incoming_label and candidate_label and incoming_label != candidate_label and workstream_key:
- return "candidate session label does not match the requested workstream"
- incoming_text = " ".join(part.strip() for part in [initial_request, session_goal, session_label] if part and part.strip())
- candidate_text = " ".join(
- str(candidate.get(key, "")).strip()
- for key in ("initial_request", "session_goal", "session_label", "workstream_title")
- if candidate.get(key)
- )
- if not incoming_text or not candidate_text:
- return None
- if self._request_needs_task_anchor(initial_request, session_goal) and self._jaccard_similarity(incoming_text, candidate_text) < 0.15:
- return "candidate session goal conflicts with the incoming request"
- return None
-
- def _build_session_open_warnings(
- self,
- *,
- task: dict[str, Any] | None,
- task_id: str | None,
- initial_request: str,
- session_goal: str,
- latest_handoff: dict[str, Any] | None,
- ) -> list[str]:
- warnings: list[str] = []
- if not task_id and self._request_needs_task_anchor(initial_request, session_goal):
- warnings.append("No task is attached to this substantial session. Create or select a task to keep continuity clean.")
- if task and task.get("status") == "done":
- warnings.append("The attached current task is already marked done.")
- if latest_handoff and task_id and latest_handoff.get("task_id") and latest_handoff.get("task_id") != task_id:
- warnings.append("The latest handoff belongs to a different task than the requested session.")
- return warnings
-
- def _resolve_project(self, project_path: str | None = None, project_slug: str | None = None) -> ProjectConfig:
- if project_slug:
- record = self.registry.get_by_slug(project_slug)
- if not record:
- raise ValueError(f"Unknown project slug: {project_slug}")
- project_path = record["repo_path"]
- pconfig = self.config.get_project_config(project_path, project_slug=project_slug)
- self._register_project_config(pconfig)
- return pconfig
-
- def _register_project_config(self, project_config: ProjectConfig) -> dict[str, Any]:
- now = utc_now()
- payload = {
- "project_slug": project_config.project_slug,
- "project_name": project_config.project_name,
- "repo_path": str(project_config.project_path),
- "workspace_path": str(project_config.workspace_root),
- "vault_path": str(project_config.vault_path),
- "context_path": str(project_config.context_path),
- "db_path": str(project_config.db_path),
- "created_at": now,
- "last_active_at": now,
- }
- existing = self.registry.get_by_slug(project_config.project_slug)
- if existing:
- payload["created_at"] = existing.get("created_at", now)
- payload["active_session_count"] = existing.get("active_session_count", 0)
- payload["tags"] = existing.get("tags", [])
- write_json_atomic(project_config.manifest_path, payload)
- return self.registry.register(
- record=ProjectRecord(
- slug=project_config.project_slug,
- name=project_config.project_name,
- repo_path=str(project_config.project_path),
- workspace_path=str(project_config.workspace_root),
- vault_path=str(project_config.vault_path),
- context_path=str(project_config.context_path),
- db_path=str(project_config.db_path),
- created_at=payload["created_at"],
- last_active_at=payload["last_active_at"],
- tags=payload.get("tags", []),
- active_session_count=payload.get("active_session_count", 0),
- )
- )
-
- def _resolve_project_config(self, project_path: str | None, project_slug: str | None = None) -> ProjectConfig:
- """Resolve project config: explicit path/slug, env var, or default."""
- return self._resolve_project(project_path=project_path, project_slug=project_slug)
-
- def _store_for(self, project_path: str | None, project_slug: str | None = None) -> StateStore:
- """Get or create a StateStore for the given project path."""
- pconfig = self._resolve_project_config(project_path, project_slug=project_slug)
- key = str(pconfig.project_path.resolve())
- if key not in self._stores:
- self._stores[key] = StateStore(self.config, pconfig)
- self.registry.touch(pconfig.project_slug, active_session_count=len(self._stores[key].get_active_sessions(limit=100)))
- return self._stores[key]
-
- def _store(self, project_path: str | None = None, project_slug: str | None = None) -> StateStore:
- """Get the store for a project, lazily creating the default store only when needed."""
- if project_path or project_slug:
- return self._store_for(project_path, project_slug=project_slug)
- if self.store is None:
- self.store = self._store_for(None)
- return self.store
-
- def _project_config_for(self, project_path: str | None, project_slug: str | None = None) -> ProjectConfig:
- return self._resolve_project_config(project_path, project_slug=project_slug)
-
- def _known_project_paths(self) -> list[str]:
- known = set(self._stores.keys())
- for project in self.registry.list_projects():
- if project.get("repo_path"):
- known.add(project["repo_path"])
- return sorted(known)
-
- def _project_path_from_bridge(self, candidate: Path) -> str | None:
- bridge_path = candidate / ".obsmcp-link.json"
- if not bridge_path.exists():
- return None
- payload = read_json_with_retry(bridge_path, {})
- target = payload.get("project_path") or payload.get("repo_path")
- if not target:
- return None
- return str(Path(target).resolve())
-
- def _registered_project_for_path_hint(self, path_hint: str | Path | None) -> str | None:
- if not path_hint:
- return None
- candidate = Path(path_hint).expanduser()
- resolved = candidate.resolve(strict=False)
- if not resolved.exists() and resolved.suffix:
- resolved = resolved.parent
- elif resolved.exists() and resolved.is_file():
- resolved = resolved.parent
-
- for parent in [resolved, *resolved.parents]:
- bridge_target = self._project_path_from_bridge(parent)
- if bridge_target:
- return bridge_target
-
- best_match: str | None = None
- best_depth = -1
- for project in self.registry.list_projects():
- repo_path = project.get("repo_path")
- if not repo_path:
- continue
- repo = Path(repo_path).resolve(strict=False)
- try:
- resolved.relative_to(repo)
- except ValueError:
- continue
- depth = len(repo.parts)
- if depth > best_depth:
- best_depth = depth
- best_match = str(repo)
- if best_match:
- return best_match
-
- for parent in [resolved, *resolved.parents]:
- if (parent / ".git").exists():
- return str(parent)
- if resolved.exists():
- return str(resolved)
- return None
-
- def _extract_project_path_hints(self, arguments: dict[str, Any]) -> list[str]:
- hints: list[str] = []
- for key in ("repo_path", "cwd", "workspace_path", "file_path", "path", "repo_root", "project_root"):
- value = arguments.get(key)
- if isinstance(value, str) and value.strip():
- hints.append(value)
- for key in ("files", "relevant_files"):
- value = arguments.get(key)
- if isinstance(value, list):
- hints.extend([item for item in value if isinstance(item, str) and item.strip()])
- return hints
-
- def _find_project_path_for_session(self, session_id: str | None) -> str | None:
- if not session_id:
- return None
- for project_path in self._known_project_paths():
- session = self._store_for(project_path).get_session(session_id)
- if session:
- return project_path
- return None
-
- def _find_project_path_for_task(self, task_id: str | None) -> str | None:
- if not task_id:
- return None
- for project_path in self._known_project_paths():
- task = self._store_for(project_path).get_task(task_id)
- if task:
- return project_path
- return None
-
- def _resolve_nearest_git_root(self, path_hint: str | None, max_depth: int = 5) -> str | None:
- """Walk upward from path_hint up to max_depth levels looking for a .git directory or file."""
- if not path_hint:
- return None
- candidate = Path(path_hint).expanduser().resolve(strict=False)
- if candidate.is_file():
- candidate = candidate.parent
- for i, parent in enumerate([candidate, *candidate.parents]):
- if i > max_depth:
- break
- git_dir = parent / ".git"
- if git_dir.exists() and git_dir.is_dir():
- return str(parent)
- try:
- content = git_dir.read_text(encoding="utf-8").strip()
- if content.startswith("gitdir:"):
- gitdir = content.split(":", 1)[1].strip()
- resolved = (parent / gitdir).resolve(strict=False)
- return str(resolved.parent)
- except (OSError, ValueError):
- pass
- return None
-
- def _infer_project_path(self, arguments: dict[str, Any]) -> str | None:
- explicit_slug = arguments.get("project_slug")
- if explicit_slug:
- record = self.registry.get_by_slug(explicit_slug)
- if record:
- return record["repo_path"]
-
- explicit = arguments.get("project_path")
- if explicit:
- return explicit
-
- repo_path = arguments.get("repo_path")
- if isinstance(repo_path, str) and repo_path.strip():
- return self._registered_project_for_path_hint(repo_path) or repo_path
-
- session_project = self._find_project_path_for_session(arguments.get("session_id"))
- if session_project:
- return session_project
-
- task_project = self._find_project_path_for_task(arguments.get("task_id"))
- if task_project:
- return task_project
-
- for hint in self._extract_project_path_hints(arguments):
- inferred = self._registered_project_for_path_hint(hint)
- if inferred:
- return inferred
-
- env_project = os.environ.get("OBSMCP_PROJECT")
- if env_project:
- return self._registered_project_for_path_hint(env_project) or env_project
-
- cwd = os.getcwd()
- if cwd:
- git_root = self._resolve_nearest_git_root(cwd)
- if git_root:
- registered = self._registered_project_for_path_hint(git_root)
- return registered or git_root
-
- return None
-
- def _tool_requires_project_context(self, name: str) -> bool:
- return name not in self._GLOBAL_TOOLS
-
- def _missing_project_context_error(self, name: str) -> ValueError:
- return ValueError(
- "Project context is required for "
- f"'{name}'. Pass one of: project_path, project_slug, session_id, task_id, "
- "repo_path, cwd, file_path, path, or files/relevant_files. "
- "Alternatively, call session_open with a project hint first, or call "
- "get_or_create_project/resolve_project to resolve the project explicitly."
- )
-
- def sync_hub(self) -> dict[str, Any]:
- files = sync_hub_vault(self.config.hub_vault_dir or self.config.root_dir / "hub" / "vault", self.registry.list_projects())
- return {"synced": True, "files": files}
-
- def sync_all(self, project_path: str | None = None, project_slug: str | None = None) -> dict[str, Any]:
- with span("sync_all", project_path=project_path, project_slug=project_slug):
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- store = self._store(project_path, project_slug=project_slug)
- files = sync_context_files(self.config, store, pcfg)
- pulled = pull_obsidian_changes(self.config, store, pcfg)
- sync_obsidian(self.config, store, pcfg)
- resume = self.generate_resume_packet(project_path=str(pcfg.project_path), write_files=True)
- artifact_files = self._sync_context_artifacts(project_path=str(pcfg.project_path))
- self.registry.touch(pcfg.project_slug, active_session_count=len(store.get_active_sessions(limit=100)))
- hub_result = self.sync_hub()
- self._submit_precompute(str(pcfg.project_path))
- return {
- "synced": True,
- "files": {**files, **artifact_files},
- "resume_packet": resume["path"],
- "hub_files": hub_result["files"],
- "pulled": pulled,
- }
-
- def _run_deferred_sync(self, project_path: str) -> dict[str, Any]:
- try:
- return self.sync_all(project_path=project_path)
- finally:
- with self._sync_lock:
- self._sync_jobs.pop(f"sync:{project_path}", None)
-
- def _submit_deferred_sync(self, project_path: str) -> None:
- with self._sync_lock:
- job_key = f"sync:{project_path}"
- if job_key in self._sync_jobs and not self._sync_jobs[job_key].done():
- return
- self._sync_jobs[job_key] = self._sync_executor.submit(self._run_deferred_sync, project_path)
-
- def _sync_after_write(self, project_path: str | None, sync_mode: str = "full") -> Any:
- effective_project_path = str(self._project_config_for(project_path).project_path)
- normalized = (sync_mode or "full").lower()
- if normalized == "none":
- return {"synced": False, "mode": "none", "project_path": effective_project_path}
- if normalized == "deferred":
- self._submit_deferred_sync(effective_project_path)
- return {"synced": False, "mode": "deferred", "project_path": effective_project_path}
- result = self.sync_all(effective_project_path)
- if isinstance(result, dict):
- result["mode"] = "full"
- return result
-
- def _atlas_excluded_roots(self, project_path: str | None = None) -> list[Path]:
- pcfg = self._project_config_for(project_path)
- root = pcfg.project_path.resolve()
- candidates = [
- pcfg.workspace_root,
- pcfg.context_path,
- pcfg.vault_path,
- pcfg.log_dir,
- pcfg.data_root,
- pcfg.json_export_dir,
- pcfg.backup_dir,
- pcfg.export_dir,
- root / ".obsmcp",
- root / ".context",
- root / "obsidian",
- ]
- if root == self.config.root_dir.resolve():
- candidates.extend(
- [
- self.config.root_dir / "projects",
- self.config.root_dir / "registry",
- self.config.root_dir / "hub",
- self.config.root_dir / "logs",
- self.config.root_dir / "data",
- self.config.root_dir / "obsidian",
- ]
- )
- excluded: list[Path] = []
- for candidate in candidates:
- try:
- resolved = candidate.resolve()
- if resolved == root:
- continue
- resolved.relative_to(root)
- excluded.append(resolved)
- except ValueError:
- continue
- except OSError:
- continue
- return excluded
-
- def _build_code_atlas(self, project_path: str | None = None) -> CodeAtlas:
- pcfg = self._project_config_for(project_path)
- return CodeAtlas(pcfg.project_path, excluded_roots=self._atlas_excluded_roots(project_path))
-
- def _current_atlas_metadata(self, project_path: str | None = None) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path)
- atlas_path = pcfg.vault_path / "Research" / "Code Atlas.md"
- semantic_stats = self._store(project_path).get_symbol_index_stats()
- cached = read_json_with_retry(pcfg.json_export_dir / "code_atlas.json", {})
- if cached:
- return {
- "status": "current",
- "message": "Code Atlas is up to date.",
- "total_files": cached.get("total_files", 0),
- "total_lines": cached.get("total_lines", 0),
- "languages": cached.get("languages", {}),
- "generated_at": cached.get("generated_at"),
- "atlas_path": str(atlas_path),
- "semantic_index": semantic_stats,
- }
- atlas = self._build_code_atlas(project_path)
- result = atlas.scan()
- return {
- "status": "current",
- "message": "Code Atlas is up to date.",
- "total_files": result.total_files,
- "total_lines": result.total_lines,
- "languages": result.languages,
- "generated_at": result.generated_at,
- "atlas_path": str(atlas_path),
- "semantic_index": semantic_stats,
- }
-
- def _atlas_needs_refresh(self, project_path: str | None = None, force_refresh: bool = False) -> bool:
- if force_refresh:
- return True
- pcfg = self._project_config_for(project_path)
- atlas_path = pcfg.vault_path / "Research" / "Code Atlas.md"
- if not atlas_path.exists():
- return True
- atlas_mtime = atlas_path.stat().st_mtime
- root = pcfg.project_path
- exclude_dirs = {".venv", "venv", "node_modules", ".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "dist", "build", "target", ".next", ".nuxt", ".cache", "tmp", "temp", ".tmp"}
- excluded_roots = {item.resolve() for item in self._atlas_excluded_roots(project_path)}
- for dirpath, dirnames, filenames in os.walk(root):
- root_path = Path(dirpath).resolve()
- dirnames[:] = [
- d
- for d in dirnames
- if d not in exclude_dirs
- and not d.startswith(".")
- and (root_path / d).resolve() not in excluded_roots
- and not any((root_path / d).resolve().is_relative_to(item) for item in excluded_roots)
- ]
- if any(root_path.is_relative_to(item) for item in excluded_roots):
- continue
- for filename in filenames:
- filepath = root_path / filename
- try:
- if any(filepath.resolve().is_relative_to(item) for item in excluded_roots):
- continue
- if filepath.stat().st_mtime > atlas_mtime:
- return True
- except OSError:
- pass
- return False
-
- def _scan_codebase_sync(self, project_path: str | None = None, force_refresh: bool = False) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path)
- atlas_path = pcfg.vault_path / "Research" / "Code Atlas.md"
- atlas_path.parent.mkdir(parents=True, exist_ok=True)
-
- should_refresh = self._atlas_needs_refresh(project_path, force_refresh=force_refresh)
-
- if not should_refresh:
- return self._current_atlas_metadata(project_path)
-
- atlas = self._build_code_atlas(project_path)
- result = atlas.scan()
- markdown = atlas.generate_markdown(result)
- write_text_atomic(atlas_path, markdown)
- _, _, _, semantic_stats = self._refresh_semantic_index(project_path=str(pcfg.project_path), atlas_result=result)
- semantic_prewarm = self._submit_semantic_prewarm(
- [*semantic_stats.get("changed_files", []), *semantic_stats.get("added_files", [])],
- project_path=str(pcfg.project_path),
- reason="scan_codebase",
- limit=self.config.semantic_auto_generate.max_modules_per_scan,
- )
- write_json_atomic(pcfg.json_export_dir / "code_atlas.json", result.to_dict())
- return {
- "status": "generated",
- "message": "Code Atlas generated successfully.",
- "total_files": result.total_files,
- "total_lines": result.total_lines,
- "languages": result.languages,
- "generated_at": result.generated_at,
- "atlas_path": str(atlas_path),
- "file_count": result.total_files,
- "semantic_index": semantic_stats,
- "semantic_prewarm": semantic_prewarm,
- }
-
- def _run_scan_job(self, job_id: str, project_path: str, force_refresh: bool) -> None:
- store = self._store(project_path)
- started_at = utc_now()
- store.update_scan_job(job_id, status="running", started_at=started_at, progress_message="Scanning codebase and refreshing semantic index.")
- try:
- result = self._scan_codebase_sync(project_path=project_path, force_refresh=force_refresh)
- store.update_scan_job(
- job_id,
- status="completed",
- finished_at=utc_now(),
- progress_message="Scan completed successfully.",
- result=result,
- )
- except Exception as exc:
- store.update_scan_job(
- job_id,
- status="failed",
- finished_at=utc_now(),
- progress_message="Scan failed.",
- error_text=str(exc),
- )
- finally:
- with self._scan_jobs_lock:
- self._scan_job_futures.pop(job_id, None)
-
- def start_scan_job(
- self,
- project_path: str | None = None,
- *,
- force_refresh: bool = False,
- requested_by: str = "unknown",
- ) -> dict[str, Any]:
- resolved_path = str(self._project_config_for(project_path).project_path)
- store = self._store(resolved_path)
- active = store.get_active_scan_job(job_type="code_atlas")
- if active:
- active["poll_hint"] = "Call get_scan_job with this job_id until status is completed or failed."
- return active
- job_id = f"SCAN-{uuid.uuid4().hex[:12].upper()}"
- job = store.create_scan_job(
- job_id,
- job_type="code_atlas",
- project_path=resolved_path,
- requested_by=requested_by,
- force_refresh=force_refresh,
- )
- with self._scan_jobs_lock:
- self._scan_job_futures[job_id] = self._scan_executor.submit(self._run_scan_job, job_id, resolved_path, force_refresh)
- job["message"] = "Scan queued in background."
- job["poll_hint"] = "Call get_scan_job with this job_id until status is completed or failed."
- return job
-
- def get_scan_job(self, job_id: str, project_path: str | None = None) -> dict[str, Any]:
- job = self._store(project_path).get_scan_job(job_id)
- if not job:
- raise ValueError(f"Unknown scan job: {job_id}")
- if job["status"] in {"queued", "running"}:
- job["poll_hint"] = "Poll this job until status is completed or failed."
- return job
-
- def list_scan_jobs(self, project_path: str | None = None, status: str | None = None, limit: int = 20) -> dict[str, Any]:
- jobs = self._store(project_path).list_scan_jobs(status=status, limit=limit)
- return {"jobs": jobs, "count": len(jobs)}
-
- def wait_for_scan_job(self, job_id: str, project_path: str | None = None, wait_seconds: int = 30, poll_interval_seconds: float = 0.5) -> dict[str, Any]:
- deadline = time.time() + max(wait_seconds, 0)
- while True:
- job = self.get_scan_job(job_id, project_path=project_path)
- if job["status"] in {"completed", "failed", "interrupted"}:
- return job
- if time.time() >= deadline:
- job["timed_out"] = True
- return job
- time.sleep(max(poll_interval_seconds, 0.1))
-
- def _refresh_semantic_index(
- self,
- project_path: str | None = None,
- atlas_result: Any | None = None,
- ) -> tuple[ProjectConfig, Any, Any, dict[str, Any]]:
- pcfg = self._project_config_for(project_path)
- result = atlas_result or self._build_code_atlas(project_path).scan()
- index = build_semantic_index(pcfg.project_path, result)
- payload = index.build_index_payload()
- stats = self._store(project_path).replace_semantic_index(payload["entities"], payload["file_fingerprints"])
- write_json_atomic(pcfg.json_export_dir / "semantic_symbol_index.json", payload)
- write_text_atomic(pcfg.json_export_dir / "semantic_symbol_index.md", index.render_summary_markdown())
- return pcfg, result, index, stats
-
- def _describe_entity(
- self,
- entity: dict[str, Any],
- index: Any,
- project_path: str | None = None,
- write_sync: bool = True,
- force_llm: bool = False,
- allow_llm: bool = True,
- force_refresh: bool = False,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- cached = store.get_semantic_description(entity["entity_key"])
- if not force_refresh and cached and not cached.get("stale") and cached.get("source_fingerprint") == entity["source_fingerprint"]:
- cached["freshness"] = "fresh"
- cached["cached"] = True
- return cached
- operation_kind = "architecture" if entity.get("entity_type") in {"module", "feature"} else "general"
- policy = self._resolve_output_policy(
- operation_kind=operation_kind,
- project_path=project_path,
- )
- description = generate_semantic_description(
- entity,
- index,
- store,
- Path(self._project_config_for(project_path).project_path),
- force_llm=force_llm,
- allow_llm=allow_llm,
- response_contract=policy.prompt_contract if policy.mode == "gateway_enforced" else None,
- )
- description["symbol_path"] = entity["symbol_path"]
- saved = store.upsert_semantic_description(description)
- saved["cached"] = False
- self._record_output_policy_metric(
- operation="generate_semantic_description",
- policy=policy,
- rendered_text=json.dumps(saved, ensure_ascii=True),
- project_path=project_path,
- )
- if write_sync:
- self.sync_all(project_path)
- return saved
-
- def _is_low_value_semantic_path(self, file_path: str) -> bool:
- normalized = file_path.replace("\\", "/").lower()
- if any(fragment.lower() in normalized for fragment in self.config.semantic_auto_generate.skip_path_fragments):
- return True
- file_name = normalized.rsplit("/", 1)[-1]
- return any(file_name.endswith(suffix.lower()) for suffix in self.config.semantic_auto_generate.skip_generated_suffixes)
-
- def _collect_semantic_candidate_files(
- self,
- explicit_files: list[str] | None,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- limit: int | None = None,
- ) -> list[str]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- max_candidates = max((limit or self.config.semantic_auto_generate.max_modules_per_write) * 4, 12)
- ordered: list[str] = []
- seen: set[str] = set()
-
- def extend(paths: list[str] | None) -> None:
- for item in paths or []:
- if not isinstance(item, str) or not item.strip():
- continue
- normalized = item.replace("\\", "/")
- if normalized in seen or self._is_low_value_semantic_path(normalized):
- continue
- seen.add(normalized)
- ordered.append(normalized)
- if len(ordered) >= max_candidates:
- return
-
- extend(explicit_files)
- if task:
- extend(task.get("relevant_files", []))
- extend(store.get_relevant_files(task_id=task["id"] if task else task_id, limit=max_candidates))
- if len(ordered) < max_candidates:
- extend(store.get_recent_file_activity(limit=max_candidates))
- return ordered[:max_candidates]
-
- def _is_module_description_fresh(self, module: dict[str, Any], project_path: str | None = None) -> bool:
- store = self._store(project_path)
- cached = store.get_semantic_description(module["entity_key"])
- return bool(cached and not cached.get("stale") and cached.get("source_fingerprint") == module.get("source_fingerprint"))
-
- def _module_candidates_for_files(
- self,
- file_paths: list[str] | None,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- limit: int | None = None,
- refresh_if_missing: bool = True,
- ) -> list[str]:
- candidates = self._collect_semantic_candidate_files(file_paths, task_id=task_id, project_path=project_path, limit=limit)
- if not candidates:
- return []
- store = self._store(project_path)
- selected: list[str] = []
- seen: set[str] = set()
- for file_path in candidates:
- module = store.get_module_index(file_path)
- if not module:
- continue
- normalized = module["file_path"]
- if normalized in seen or self._is_low_value_semantic_path(normalized):
- continue
- if self._is_module_description_fresh(module, project_path=project_path):
- continue
- seen.add(normalized)
- selected.append(normalized)
- if limit is not None and len(selected) >= limit:
- break
- if not selected and refresh_if_missing:
- try:
- self._refresh_semantic_index(project_path)
- except Exception:
- return []
- store = self._store(project_path)
- for file_path in candidates:
- module = store.get_module_index(file_path)
- if not module:
- continue
- normalized = module["file_path"]
- if normalized in seen or self._is_low_value_semantic_path(normalized):
- continue
- if self._is_module_description_fresh(module, project_path=project_path):
- continue
- seen.add(normalized)
- selected.append(normalized)
- if limit is not None and len(selected) >= limit:
- break
- return selected
-
- def _wait_for_semantic_job(self, job_key: str, timeout_ms: int) -> dict[str, Any]:
- if timeout_ms <= 0:
- return {"waited": False, "timed_out": False}
- with self._semantic_jobs_lock:
- future = self._semantic_jobs.get(job_key)
- if future is None:
- return {"waited": False, "timed_out": False}
- try:
- result = future.result(timeout=timeout_ms / 1000.0)
- return {"waited": True, "timed_out": False, "result": result}
- except FuturesTimeoutError:
- return {"waited": True, "timed_out": True}
-
- def _best_effort_semantic_prewarm(
- self,
- file_paths: list[str] | None,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- reason: str = "",
- limit: int | None = None,
- allow_llm: bool | None = None,
- wait_ms: int = 0,
- ) -> dict[str, Any]:
- result = self._submit_semantic_prewarm(
- file_paths,
- task_id=task_id,
- project_path=project_path,
- reason=reason,
- limit=limit,
- allow_llm=allow_llm,
- )
- job_key = result.get("job_key")
- if job_key:
- result.update(self._wait_for_semantic_job(job_key, timeout_ms=wait_ms))
- return result
-
- def _prewarm_module_descriptions(
- self,
- file_paths: list[str] | None,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- limit: int | None = None,
- allow_llm: bool | None = None,
- sync_after: bool = True,
- ) -> dict[str, Any]:
- limit_value = limit if limit is not None else self.config.semantic_auto_generate.max_modules_per_scan
- module_paths = self._module_candidates_for_files(file_paths, task_id=task_id, project_path=project_path, limit=limit_value)
- if not module_paths:
- return {"requested_files": file_paths or [], "module_paths": [], "generated": 0, "cached": 0}
-
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- generated = 0
- cached = 0
- llm_allowed = self.config.semantic_auto_generate.allow_llm if allow_llm is None else allow_llm
- for module_path in module_paths:
- entity = index.get_module(module_path)
- if not entity:
- continue
- result = self._describe_entity(
- entity.to_index_row(),
- index,
- project_path=str(pcfg.project_path),
- write_sync=False,
- allow_llm=llm_allowed,
- )
- if result.get("cached"):
- cached += 1
- else:
- generated += 1
- if sync_after and (generated or cached):
- self.sync_all(str(pcfg.project_path))
- return {"requested_files": file_paths or [], "module_paths": module_paths, "generated": generated, "cached": cached}
-
- def _run_semantic_prewarm(
- self,
- job_key: str,
- project_path: str,
- file_paths: list[str],
- task_id: str | None,
- limit: int,
- allow_llm: bool,
- ) -> dict[str, Any]:
- try:
- return self._prewarm_module_descriptions(
- file_paths,
- task_id=task_id,
- project_path=project_path,
- limit=limit,
- allow_llm=allow_llm,
- sync_after=True,
- )
- finally:
- with self._semantic_jobs_lock:
- self._semantic_jobs.pop(job_key, None)
-
- def _submit_semantic_prewarm(
- self,
- file_paths: list[str] | None,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- reason: str = "",
- limit: int | None = None,
- allow_llm: bool | None = None,
- ) -> dict[str, Any]:
- if not self.config.semantic_auto_generate.enabled:
- return {"queued": False, "reason": "disabled", "module_paths": []}
- resolved_project = str(self._project_config_for(project_path).project_path)
- limit_value = limit if limit is not None else self.config.semantic_auto_generate.max_modules_per_write
- module_paths = self._module_candidates_for_files(
- file_paths,
- task_id=task_id,
- project_path=resolved_project,
- limit=limit_value,
- )
- if not module_paths:
- return {"queued": False, "reason": "no_modules", "module_paths": []}
- payload = json.dumps(
- {"project_path": resolved_project, "module_paths": module_paths, "task_id": task_id, "reason": reason},
- sort_keys=True,
- )
- job_key = f"semantic:{hashlib.sha1(payload.encode('utf-8')).hexdigest()}"
- llm_allowed = self.config.semantic_auto_generate.allow_llm if allow_llm is None else allow_llm
- with self._semantic_jobs_lock:
- active_jobs = sum(1 for future in self._semantic_jobs.values() if not future.done())
- if active_jobs >= max(1, self.config.semantic_auto_generate.max_queue_size):
- return {"queued": False, "reason": "queue_full", "module_paths": module_paths}
- existing = self._semantic_jobs.get(job_key)
- if existing and not existing.done():
- return {"queued": False, "reason": "already_running", "module_paths": module_paths}
- self._semantic_jobs[job_key] = self._semantic_executor.submit(
- self._run_semantic_prewarm,
- job_key,
- resolved_project,
- module_paths,
- task_id,
- limit_value,
- llm_allowed,
- )
- return {"queued": True, "reason": reason or "unspecified", "job_key": job_key, "module_paths": module_paths}
-
- def _semantic_lookup_suggestions(self, relevant_files: list[str], project_path: str | None = None, limit: int = 6) -> list[dict[str, Any]]:
- store = self._store(project_path)
- suggestions: list[dict[str, Any]] = []
- for file_path in relevant_files:
- module = store.get_module_index(file_path)
- if module:
- suggestions.append(
- {
- "entity_key": module["entity_key"],
- "entity_type": module["entity_type"],
- "name": module["name"],
- "file_path": module["file_path"],
- "summary_hint": module.get("summary_hint", ""),
- }
- )
- seen = set()
- ordered: list[dict[str, Any]] = []
- for item in suggestions:
- if item["entity_key"] in seen:
- continue
- seen.add(item["entity_key"])
- ordered.append(item)
- if len(ordered) >= limit:
- break
- return ordered
-
- def _context_scope_key(self, task: dict[str, Any] | None) -> str:
- if task:
- return f"task:{task['id']}"
- return "project:current"
-
- def _artifact_signature(self, payload: dict[str, Any]) -> str:
- encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
- return hashlib.sha1(encoded.encode("utf-8")).hexdigest()
-
- def _estimated_tokens(self, text: str) -> int:
- return (len(text.encode("utf-8")) + len(text)) // 4
-
- def _query_terms(self, query: str) -> list[str]:
- terms = []
- for term in re.split(r"[^A-Za-z0-9_]+", query.lower()):
- if len(term) >= 2 and term not in {"the", "and", "for", "with", "from", "that", "this"}:
- terms.append(term)
- return terms
-
- def _match_score(self, text: str, terms: list[str]) -> int:
- haystack = text.lower()
- score = 0
- for term in terms:
- if term in haystack:
- score += 1
- return score
-
- def _rank_values(self, values: list[Any], *, terms: list[str], text_getter: Callable[[Any], str], limit: int) -> list[Any]:
- if not terms:
- return values[:limit]
- ranked = sorted(
- values,
- key=lambda item: (
- -self._match_score(text_getter(item), terms),
- text_getter(item),
- ),
- )
- filtered = [item for item in ranked if self._match_score(text_getter(item), terms) > 0]
- return (filtered or ranked)[:limit]
-
- def _task_type_for_context(self, task: dict[str, Any] | None) -> str:
- if not task:
- return "general"
- tags = {str(tag).lower() for tag in task.get("tags", [])}
- for candidate in ("bug", "feature", "research", "refactor", "documentation", "testing"):
- if candidate in tags:
- if candidate == "documentation":
- return "docs"
- if candidate == "testing":
- return "test"
- return candidate
- title = str(task.get("title", "")).lower()
- if title.startswith("bug:"):
- return "bug"
- if title.startswith("feature:"):
- return "feature"
- if title.startswith("research:"):
- return "research"
- return "general"
-
- def _apply_section_order_policy(
- self,
- sections: list[dict[str, Any]],
- *,
- task: dict[str, Any] | None,
- mode: str,
- ) -> list[dict[str, Any]]:
- task_type = self._task_type_for_context(task)
- base_order = {
- "header": 0,
- "mission": 10,
- "current_task": 20,
- "relevant_files": 30,
- "latest_handoff": 40,
- "blockers": 50,
- "recent_work": 60,
- "recent_commands": 65,
- "decisions": 70,
- "semantic": 80,
- "sessions": 90,
- "audit": 100,
- "dependencies": 110,
- "active_tasks": 120,
- "daily_notes": 130,
- "stable_header": 0,
- "success_criteria": 12,
- "architecture": 14,
- "working_agreements": 16,
- "atlas_snapshot": 18,
- "dynamic_header": 0,
- "recent_decisions": 70,
- }
- overrides: dict[str, int] = {}
- if task_type == "bug":
- overrides.update({
- "blockers": 25,
- "recent_work": 28,
- "recent_commands": 29,
- "decisions": 30,
- "relevant_files": 30,
- "latest_handoff": 32,
- "semantic": 40,
- })
- elif task_type == "research":
- overrides.update({
- "decisions": 25,
- "semantic": 28,
- "relevant_files": 30,
- "recent_work": 35,
- "recent_commands": 36,
- })
- elif task_type in {"feature", "refactor"}:
- overrides.update({
- "relevant_files": 25,
- "semantic": 28,
- "recent_work": 32,
- "recent_commands": 33,
- "decisions": 35,
- })
- if mode in {"debug", "recovery"}:
- overrides.update({
- "blockers": min(overrides.get("blockers", 50), 24),
- "recent_work": min(overrides.get("recent_work", 60), 26),
- "recent_commands": min(overrides.get("recent_commands", 65), 27),
- "audit": 28,
- "sessions": 29,
- })
- indexed = list(enumerate(sections))
- ordered = sorted(
- indexed,
- key=lambda item: (
- overrides.get(item[1]["name"], base_order.get(item[1]["name"], 200 + item[0])),
- item[0],
- ),
- )
- return [section for _, section in ordered]
-
- def _split_markdown_sections(self, markdown: str, *, fallback_name: str) -> list[dict[str, Any]]:
- lines = markdown.strip().splitlines()
- if not lines:
- return []
- sections: list[dict[str, Any]] = []
- current_lines: list[str] = []
- current_name = fallback_name
- current_priority = 0
- for line in lines:
- if line.startswith("#"):
- if current_lines:
- sections.append(
- {
- "name": current_name,
- "layer": "derived",
- "priority": max(current_priority, 1),
- "text": "\n".join(current_lines).strip(),
- }
- )
- current_lines = [line]
- normalized = re.sub(r"[^a-z0-9]+", "_", line.lstrip("# ").strip().lower()).strip("_")
- current_name = normalized or fallback_name
- current_priority += 1
- else:
- current_lines.append(line)
- if current_lines:
- sections.append(
- {
- "name": current_name,
- "layer": "derived",
- "priority": max(current_priority, 1),
- "text": "\n".join(current_lines).strip(),
- }
- )
- return sections
-
- def _record_token_usage_metric(
- self,
- *,
- operation: str,
- project_path: str | None = None,
- event_type: str = "local_estimate",
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- model_name: str = "",
- provider: str = "",
- client_name: str = "",
- raw_input_tokens: int = 0,
- raw_output_tokens: int = 0,
- estimated_input_tokens: int = 0,
- estimated_output_tokens: int = 0,
- compact_input_tokens: int = 0,
- compact_output_tokens: int = 0,
- saved_tokens: int = 0,
- cache_creation_input_tokens: int = 0,
- cache_read_input_tokens: int = 0,
- raw_chars: int = 0,
- compact_chars: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- return self._store(project_path).record_token_usage_event(
- event_type=event_type,
- operation=operation,
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- model_name=model_name,
- provider=provider,
- client_name=client_name,
- raw_input_tokens=raw_input_tokens,
- raw_output_tokens=raw_output_tokens,
- estimated_input_tokens=estimated_input_tokens,
- estimated_output_tokens=estimated_output_tokens,
- compact_input_tokens=compact_input_tokens,
- compact_output_tokens=compact_output_tokens,
- saved_tokens=saved_tokens,
- cache_creation_input_tokens=cache_creation_input_tokens,
- cache_read_input_tokens=cache_read_input_tokens,
- raw_chars=raw_chars,
- compact_chars=compact_chars,
- metadata=metadata,
- )
-
- def _strip_ansi(self, text: str) -> str:
- return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
-
- def _dedupe_lines(self, lines: list[str], *, max_repeats: int = 1) -> list[str]:
- deduped: list[str] = []
- last_line: str | None = None
- repeat_count = 0
- for line in lines:
- if line == last_line:
- repeat_count += 1
- if repeat_count >= max_repeats:
- continue
- else:
- repeat_count = 0
- deduped.append(line)
- last_line = line
- return deduped
-
- def _limit_head_tail(self, lines: list[str], *, head: int, tail: int) -> list[str]:
- if len(lines) <= head + tail:
- return lines
- omitted = len(lines) - head - tail
- return lines[:head] + [f"... ({omitted} lines omitted) ..."] + lines[-tail:]
-
- def _focus_lines(self, lines: list[str], keywords: list[str], *, limit: int) -> list[str]:
- matches: list[str] = []
- lowered = [keyword.lower() for keyword in keywords]
- for line in lines:
- candidate = line.lower()
- if any(keyword in candidate for keyword in lowered):
- matches.append(line)
- if len(matches) >= limit:
- break
- return matches
-
- def _classify_tool_output_profile(self, command: str) -> str:
- normalized = " ".join(command.lower().split())
- if normalized.startswith("git status"):
- return "git_status"
- if normalized.startswith("git diff"):
- return "git_diff"
- if normalized.startswith("rg ") or normalized.startswith("ripgrep ") or " grep " in f" {normalized} ":
- return "search"
- if normalized.startswith("cat ") or normalized.startswith("type ") or normalized.startswith("get-content"):
- return "read"
- if any(token in normalized for token in ("pytest", "unittest", "npm test", "cargo test", "go test")):
- return "tests"
- if any(token in normalized for token in ("npm run build", "npm run lint", "eslint", "ruff", "mypy", "cargo build", "tsc", "make ")):
- return "build"
- if any(token in normalized for token in ("docker logs", "journalctl", " tail", "logcat", "logs")):
- return "logs"
- return "generic"
-
- def _compact_generic_output(
- self,
- lines: list[str],
- *,
- head: int,
- tail: int,
- focus_keywords: list[str] | None = None,
- focus_limit: int = 24,
- ) -> tuple[list[str], dict[str, Any]]:
- normalized = self._dedupe_lines([line.rstrip() for line in lines], max_repeats=1)
- non_empty = [line for line in normalized if line.strip()]
- focus: list[str] = []
- if focus_keywords:
- focus = self._focus_lines(non_empty, focus_keywords, limit=focus_limit)
- focus_set = set(focus)
- remaining = [line for line in non_empty if line not in focus_set]
- selected: list[str] = []
- if focus:
- selected.append("[focused lines]")
- selected.extend(focus)
- selected.append("")
- selected.extend(self._limit_head_tail(remaining, head=head, tail=tail))
- return selected, {
- "total_lines": len(lines),
- "normalized_lines": len(non_empty),
- "focused_lines": len(focus),
- }
-
- def _compact_git_status_output(self, lines: list[str]) -> tuple[list[str], dict[str, Any]]:
- normalized = [line.rstrip() for line in self._dedupe_lines(lines, max_repeats=1) if line.strip()]
- summary_lines = [
- line
- for line in normalized
- if line.startswith("On branch")
- or line.startswith("HEAD detached")
- or line.startswith("Your branch")
- or line.startswith("nothing to commit")
- or line.startswith("Changes to be committed")
- or line.startswith("Changes not staged")
- or line.startswith("Untracked files")
- ]
- file_lines = [
- line.strip()
- for line in normalized
- if ":" in line or line.lstrip().startswith("?? ") or line.startswith("\t")
- ]
- selected = summary_lines[:8]
- if file_lines:
- selected.append("")
- selected.append("Files:")
- selected.extend(f"- {line.lstrip('?').strip()}" for line in file_lines[:20])
- if len(file_lines) > 20:
- selected.append(f"- ... {len(file_lines) - 20} more file entries omitted")
- return selected or self._limit_head_tail(normalized, head=20, tail=10), {
- "total_lines": len(lines),
- "file_entries": len(file_lines),
- }
-
- def _compact_git_diff_output(self, lines: list[str]) -> tuple[list[str], dict[str, Any]]:
- normalized = [line.rstrip() for line in self._dedupe_lines(lines, max_repeats=1) if line.strip()]
- file_headers = [line for line in normalized if line.startswith("diff --git")]
- hunks = [line for line in normalized if line.startswith("@@")]
- changes = [line for line in normalized if (line.startswith("+") or line.startswith("-")) and not line.startswith("+++") and not line.startswith("---")]
- selected = file_headers[:12] + hunks[:16]
- if changes:
- if selected:
- selected.append("")
- selected.append("Change preview:")
- selected.extend(changes[:24])
- if len(changes) > 24:
- selected.append(f"... {len(changes) - 24} diff lines omitted ...")
- if not selected:
- selected = self._limit_head_tail(normalized, head=25, tail=15)
- return selected, {
- "total_lines": len(lines),
- "files": len(file_headers),
- "hunks": len(hunks),
- "change_lines": len(changes),
- }
-
- def _compact_search_output(self, lines: list[str]) -> tuple[list[str], dict[str, Any]]:
- normalized = [line.rstrip() for line in self._dedupe_lines(lines, max_repeats=1) if line.strip()]
- selected = normalized[:60]
- if len(normalized) > 60:
- selected.append(f"... {len(normalized) - 60} search results omitted ...")
- return selected, {"total_lines": len(lines), "matches": len(normalized)}
-
- def _compact_read_output(self, lines: list[str]) -> tuple[list[str], dict[str, Any]]:
- normalized = [line.rstrip() for line in lines]
- selected = self._limit_head_tail(normalized, head=50, tail=20)
- return selected, {"total_lines": len(lines)}
-
- def _compact_test_or_log_output(self, lines: list[str], *, exit_code: int, profile: str) -> tuple[list[str], dict[str, Any]]:
- focus_keywords = ["fail", "failed", "error", "exception", "traceback", "assert", "panic"]
- if profile == "logs":
- focus_keywords.extend(["warn", "timeout", "critical"])
- selected, info = self._compact_generic_output(
- lines,
- head=18,
- tail=28 if exit_code else 18,
- focus_keywords=focus_keywords,
- focus_limit=32,
- )
- return selected, info
-
- def _build_optimization_policy(
- self,
- *,
- mode: str,
- task: dict[str, Any] | None,
- command_profile: str | None = None,
- exit_code: int = 0,
- ) -> dict[str, Any]:
- normalized_mode = mode.lower()
- presets: dict[str, dict[str, Any]] = {
- "compact": {
- "mode": "compact",
- "window_scale": 0.7,
- "focus_limit": 16,
- "raw_capture_on_failure": True,
- "raw_capture_on_truncation": False,
- "chunk_count_hint": 2,
- "stable_ratio": 0.6,
- "context_mode": "compact",
- },
- "balanced": {
- "mode": "balanced",
- "window_scale": 1.0,
- "focus_limit": 24,
- "raw_capture_on_failure": True,
- "raw_capture_on_truncation": True,
- "chunk_count_hint": 3,
- "stable_ratio": 0.5,
- "context_mode": "balanced",
- },
- "debug": {
- "mode": "debug",
- "window_scale": 1.5,
- "focus_limit": 40,
- "raw_capture_on_failure": True,
- "raw_capture_on_truncation": True,
- "chunk_count_hint": 4,
- "stable_ratio": 0.45,
- "context_mode": "debug",
- },
- "recovery": {
- "mode": "recovery",
- "window_scale": 1.3,
- "focus_limit": 32,
- "raw_capture_on_failure": True,
- "raw_capture_on_truncation": True,
- "chunk_count_hint": 4,
- "stable_ratio": 0.45,
- "context_mode": "recovery",
- },
- }
- policy = dict(presets.get(normalized_mode, presets["balanced"]))
- task_type = self._task_type_for_context(task)
- if task_type == "bug":
- policy["window_scale"] = max(policy["window_scale"], 1.1)
- policy["focus_limit"] = max(policy["focus_limit"], 28)
- elif task_type == "research":
- policy["stable_ratio"] = min(max(policy["stable_ratio"], 0.55), 0.7)
- if command_profile in {"tests", "build", "logs"} and exit_code != 0:
- policy["window_scale"] = max(policy["window_scale"], 1.4)
- policy["focus_limit"] = max(policy["focus_limit"], 36)
- policy["task_type"] = task_type
- policy["command_profile"] = command_profile
- policy["exit_code"] = exit_code
- return policy
-
- def get_optimization_policy(
- self,
- mode: str = "balanced",
- task_id: str | None = None,
- command: str | None = None,
- exit_code: int = 0,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- command_profile = self._classify_tool_output_profile(command or "") if command else None
- policy = self._build_optimization_policy(
- mode=mode,
- task=task,
- command_profile=command_profile,
- exit_code=exit_code,
- )
- policy["task_id"] = (task or {}).get("id")
- policy["project_path"] = str(self._project_config_for(project_path).project_path)
- return policy
-
- def _classify_command_execution_risk(self, command: str) -> dict[str, Any]:
- normalized = (command or "").strip().lower()
- read_prefixes = ("rg ", "ripgrep ", "grep ", "ls", "dir", "pwd", "cat ", "type ", "git status", "git diff", "git show", "find ", "where ", "which ")
- write_markers = ("write", "apply_patch", "touch ", "echo ", "tee ", "sed -i", "python ", "npm run build", "pytest", "unittest", "cargo test", "make test")
- install_markers = ("npm install", "pip install", "uv sync", "poetry install", "cargo add", "apt ", "brew ", "choco ")
- destructive_markers = ("rm ", "del ", "remove-item", "git reset", "git checkout --", "format ", "drop ", "truncate ", "shutdown", "reboot")
- network_markers = ("curl ", "wget ", "Invoke-WebRequest".lower(), "git clone", "git fetch", "git pull", "npm publish")
-
- action_type = "unknown"
- risk_level = "medium"
- if any(normalized.startswith(prefix) for prefix in read_prefixes):
- action_type = "read"
- risk_level = "low"
- elif any(marker in normalized for marker in destructive_markers):
- action_type = "destructive"
- risk_level = "high"
- elif any(marker in normalized for marker in install_markers):
- action_type = "install"
- risk_level = "high"
- elif any(marker in normalized for marker in network_markers):
- action_type = "network"
- risk_level = "high"
- elif any(marker in normalized for marker in write_markers):
- action_type = "write"
- risk_level = "medium"
-
- can_batch = action_type in {"read"} and risk_level == "low"
- needs_model_review = risk_level == "high" or action_type in {"destructive", "install", "network"}
- return {
- "command": command,
- "action_type": action_type,
- "risk_level": risk_level,
- "can_batch": can_batch,
- "needs_model_review": needs_model_review,
- "recommended_sync_mode": "deferred" if action_type in {"read", "write"} else "full",
- }
-
- def get_command_execution_policy(
- self,
- command: str,
- task_id: str | None = None,
- mode: str = "balanced",
- exit_code: int = 0,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- base = self._classify_command_execution_risk(command)
- optimization = self.get_optimization_policy(
- mode=mode,
- task_id=task_id,
- command=command,
- exit_code=exit_code,
- project_path=project_path,
- )
- output_policy = self.get_output_response_policy(
- task_id=task_id,
- operation_kind="dangerous_actions" if base["action_type"] == "destructive" else "general",
- command=command,
- project_path=project_path,
- )
- return {
- **base,
- "mode": mode,
- "task_id": optimization.get("task_id"),
- "project_path": optimization.get("project_path"),
- "optimization_policy": optimization,
- "output_policy": output_policy,
- }
-
- def _resolve_output_policy(
- self,
- task_id: str | None = None,
- *,
- operation_kind: str = "general",
- detail_requested: bool = False,
- command: str | None = None,
- project_path: str | None = None,
- ) -> EffectiveOutputPolicy:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- return resolve_output_policy(
- self.config.output_compression,
- task=task,
- operation_kind=operation_kind,
- detail_requested=detail_requested,
- command=command,
- )
-
- def get_output_response_policy(
- self,
- task_id: str | None = None,
- operation_kind: str = "general",
- detail_requested: bool = False,
- command: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- policy = self._resolve_output_policy(
- task_id=task_id,
- operation_kind=operation_kind,
- detail_requested=detail_requested,
- command=command,
- project_path=project_path,
- )
- return policy.to_dict()
-
- def _record_output_policy_metric(
- self,
- *,
- operation: str,
- policy: EffectiveOutputPolicy,
- rendered_text: str,
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> None:
- observability = self.config.output_compression.observability
- if not observability.log_metrics or observability.sample_rate <= 0:
- return
- metadata: dict[str, Any] = {
- "detail_requested": policy.detail_requested,
- "bypassed": policy.bypassed,
- "bypass_reason": policy.bypass_reason,
- "operation_kind": policy.operation_kind,
- }
- if observability.record_mode:
- metadata["output_mode"] = policy.mode
- if observability.record_style:
- metadata["output_style"] = policy.style
- metadata["output_level"] = policy.level
- if observability.record_task_type:
- metadata["task_type"] = policy.task_type
- self._record_token_usage_metric(
- operation=operation,
- project_path=project_path,
- event_type="output_policy",
- task_id=task_id,
- estimated_output_tokens=self._estimated_tokens(rendered_text),
- compact_output_tokens=self._estimated_tokens(rendered_text),
- compact_chars=len(rendered_text),
- metadata=metadata,
- )
-
- def _compact_tool_output_lines(
- self,
- *,
- profile: str,
- output: str,
- exit_code: int,
- policy: dict[str, Any] | None = None,
- ) -> tuple[str, dict[str, Any]]:
- stripped = self._strip_ansi(output)
- lines = stripped.splitlines()
- scale = float((policy or {}).get("window_scale", 1.0))
- focus_limit = int((policy or {}).get("focus_limit", 24))
- def scaled(value: int) -> int:
- return max(4, int(round(value * scale)))
- if profile == "git_status":
- selected_lines, profile_info = self._compact_git_status_output(lines)
- elif profile == "git_diff":
- selected_lines, profile_info = self._compact_git_diff_output(lines)
- elif profile == "search":
- selected_lines, profile_info = self._compact_search_output(lines[: max(scaled(60), 20)])
- elif profile == "read":
- selected_lines, profile_info = self._compact_generic_output(
- lines,
- head=scaled(50),
- tail=scaled(20),
- focus_keywords=["todo", "fixme", "error", "warning"] if exit_code else None,
- focus_limit=focus_limit,
- )
- elif profile in {"tests", "build", "logs"}:
- focus_keywords = ["fail", "failed", "error", "exception", "traceback", "assert", "panic"]
- if profile == "logs":
- focus_keywords.extend(["warn", "timeout", "critical"])
- selected_lines, profile_info = self._compact_generic_output(
- lines,
- head=scaled(18),
- tail=scaled(28 if exit_code else 18),
- focus_keywords=focus_keywords,
- focus_limit=focus_limit,
- )
- else:
- focus_keywords = ["error", "exception", "fail", "warning"] if exit_code else ["error", "warning"]
- selected_lines, profile_info = self._compact_generic_output(
- lines,
- head=scaled(20),
- tail=scaled(20),
- focus_keywords=focus_keywords,
- focus_limit=focus_limit,
- )
- compact_text = "\n".join(line for line in selected_lines if line is not None).strip()
- if not compact_text:
- compact_text = stripped.strip()
- return compact_text + ("\n" if compact_text else ""), profile_info
-
- def _store_raw_output_capture(
- self,
- *,
- command: str,
- output: str,
- profile: str,
- reason: str,
- project_path: str | None = None,
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- exit_code: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- pcfg = self._project_config_for(project_path)
- raw_dir = pcfg.export_dir / "raw-output"
- raw_dir.mkdir(parents=True, exist_ok=True)
- capture_id = f"RAW-{uuid.uuid4().hex[:10].upper()}"
- capture_path = raw_dir / f"{capture_id}.log"
- write_text_atomic(capture_path, output)
- preview = self._strip_ansi(output).strip()[:400]
- return self._store(project_path).create_raw_output_capture(
- capture_id=capture_id,
- actor=actor,
- command_text=command,
- profile=profile,
- reason=reason,
- output_path=str(capture_path),
- preview=preview,
- session_id=session_id,
- task_id=task_id,
- exit_code=exit_code,
- raw_chars=len(output),
- raw_tokens_est=self._estimated_tokens(output),
- metadata=metadata,
- )
-
- def _summarize_command_stream(
- self,
- *,
- output: str,
- profile: str,
- exit_code: int,
- policy: dict[str, Any],
- ) -> dict[str, Any]:
- stripped = self._strip_ansi(output).strip()
- if not stripped:
- return {
- "summary": "",
- "raw_chars": 0,
- "compact_chars": 0,
- "raw_tokens_est": 0,
- "compact_tokens_est": 0,
- "raw_lines": 0,
- "compact_lines": 0,
- "was_compacted": False,
- "profile_info": {"total_lines": 0},
- }
- compact_output, profile_info = self._compact_tool_output_lines(
- profile=profile,
- output=output,
- exit_code=exit_code,
- policy=policy,
- )
- raw_text = stripped + "\n"
- compact_text = compact_output if compact_output.endswith("\n") or not compact_output else compact_output + "\n"
- return {
- "summary": compact_text,
- "raw_chars": len(output),
- "compact_chars": len(compact_text),
- "raw_tokens_est": self._estimated_tokens(output),
- "compact_tokens_est": self._estimated_tokens(compact_text),
- "raw_lines": len(raw_text.splitlines()),
- "compact_lines": len(compact_text.splitlines()),
- "was_compacted": compact_text.strip() != raw_text.strip(),
- "profile_info": profile_info,
- }
-
- def _single_line_summary(self, text: str, max_chars: int = 220) -> str:
- collapsed = " ".join(part.strip() for part in self._strip_ansi(text).splitlines() if part.strip())
- if len(collapsed) <= max_chars:
- return collapsed
- return collapsed[: max_chars - 3].rstrip() + "..."
-
- def _build_command_event_summary(
- self,
- *,
- command_text: str,
- status: str,
- exit_code: int,
- duration_ms: int,
- stdout_summary: str,
- stderr_summary: str,
- raw_capture: dict[str, Any] | None,
- ) -> str:
- duration_note = f" in {duration_ms} ms" if duration_ms > 0 else ""
- normalized_status = status or ("failed" if exit_code else "completed")
- headline = f"{normalized_status}: `{command_text}`"
- if exit_code:
- headline += f" exited with code {exit_code}"
- headline += duration_note
- details = self._single_line_summary(stderr_summary or stdout_summary)
- if not details:
- details = "No output summary recorded."
- if raw_capture:
- details += f" Raw output saved as {raw_capture['capture_id']}."
- return f"{headline}. {details}".strip()
-
- def _render_context_sections(
- self,
- sections: list[dict[str, Any]],
- max_tokens: int | None = None,
- chunk_index: int = 0,
- total_chunks: int = 1,
- ) -> tuple[str, int]:
- """Render context sections with optional chunking.
-
- When total_chunks > 1, sections are divided across chunks by priority order.
- Chunk boundaries are kept at section boundaries to avoid mid-section splits.
- """
- if total_chunks > 1:
- chunks = self._chunk_sections(sections, total_chunks)
- target = chunks[chunk_index] if chunk_index < len(chunks) else []
- else:
- target = sections
-
- rendered_parts: list[str] = []
- used_tokens = 0
- budget = max_tokens if max_tokens and max_tokens > 0 else None
- for section in target:
- text = section["text"].rstrip()
- if not text:
- continue
- tokens = self._estimated_tokens(text)
- if budget is None or used_tokens + tokens <= budget:
- rendered_parts.append(text)
- used_tokens += tokens
- continue
- remaining = budget - used_tokens
- if remaining <= 0:
- break
- char_budget = max(remaining * 4, 40)
- truncated = text[:char_budget].rsplit("\n", 1)[0].rstrip()
- if truncated:
- rendered_parts.append(truncated + "\n\n_(truncated due to token budget)_")
- used_tokens = budget
- break
- markdown = "\n\n".join(part for part in rendered_parts if part).strip() + "\n"
- return markdown, used_tokens
-
- def _chunk_sections(
- self,
- sections: list[dict[str, Any]],
- total_chunks: int,
- ) -> list[list[dict[str, Any]]]:
- """Split sections into total_chunks roughly equal chunks, respecting section boundaries."""
- if total_chunks <= 1:
- return [sections]
- total_priority = sum(s["priority"] for s in sections) or 1
- chunks: list[list[dict[str, Any]]] = [[] for _ in range(total_chunks)]
- chunk_weights = [total_priority / total_chunks] * total_chunks
- current_weights = [0.0] * total_chunks
- chunk_idx = 0
- for section in sections:
- chunks[chunk_idx].append(section)
- current_weights[chunk_idx] += section["priority"]
- if chunk_idx < total_chunks - 1 and current_weights[chunk_idx] >= chunk_weights[chunk_idx]:
- chunk_idx += 1
- return [c for c in chunks if c]
-
- def _collect_context_data(
- self,
- task_id: str | None = None,
- project_path: str | None = None,
- *,
- recent_work_limit: int = 6,
- decisions_limit: int = 5,
- blockers_limit: int = 6,
- relevant_files_limit: int = 12,
- semantic_limit: int = 6,
- active_sessions_limit: int = 5,
- active_tasks_limit: int = 10,
- include_dependency_map: bool = False,
- include_daily_notes: bool = False,
- include_session_info: bool = True,
- include_semantic: bool = True,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- brief = store.get_project_brief()
- task = store.get_task(task_id) if task_id else store.get_current_task()
- relevant_files = store.get_relevant_files(task_id=task["id"] if task else task_id, limit=relevant_files_limit) if (task or task_id) else store.get_relevant_files(limit=relevant_files_limit)
- data = {
- "brief": brief,
- "task": task,
- "blockers": store.get_blockers(open_only=True, limit=blockers_limit),
- "decisions": store.get_decisions(limit=decisions_limit),
- "recent_work": store.get_recent_work(limit=recent_work_limit),
- "recent_commands": store.list_command_events(limit=6, task_id=task["id"] if task else task_id),
- "relevant_files": relevant_files,
- "latest_handoff": store.get_latest_handoff(),
- "active_sessions": store.get_active_sessions(limit=active_sessions_limit) if include_session_info else [],
- "active_tasks": store.get_active_tasks(limit=active_tasks_limit),
- "audit": store.detect_missing_writeback(),
- "daily_notes": store.get_daily_entries(limit=5) if include_daily_notes else [],
- "dependencies": store.get_all_dependencies() if include_dependency_map else [],
- "blocked_tasks": store.get_blocked_tasks() if include_dependency_map else [],
- "semantic_suggestions": self._semantic_lookup_suggestions(relevant_files, project_path=project_path, limit=semantic_limit) if include_semantic else [],
- }
- return data
-
- def _build_tiered_sections(
- self,
- profile: str,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- include_daily_notes: bool = False,
- include_dependency_map: bool | None = None,
- include_session_info: bool | None = None,
- include_recent_work: bool = True,
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
- normalized_profile = profile.lower()
- dependency_default = normalized_profile in {"deep", "handoff", "recovery"}
- session_default = normalized_profile != "fast"
- data = self._collect_context_data(
- task_id=task_id,
- project_path=project_path,
- recent_work_limit=10 if normalized_profile in {"deep", "recovery"} else 6,
- decisions_limit=8 if normalized_profile in {"deep", "handoff", "recovery"} else 4,
- blockers_limit=8 if normalized_profile in {"deep", "handoff", "recovery"} else 5,
- relevant_files_limit=16 if normalized_profile in {"deep", "handoff", "recovery"} else 10,
- semantic_limit=8 if normalized_profile in {"deep", "handoff", "recovery"} else 5,
- active_sessions_limit=6,
- active_tasks_limit=12,
- include_dependency_map=dependency_default if include_dependency_map is None else include_dependency_map,
- include_daily_notes=include_daily_notes,
- include_session_info=session_default if include_session_info is None else include_session_info,
- include_semantic=True,
- )
- task = data["task"]
- sections: list[dict[str, Any]] = []
- sections.append(
- {
- "name": "header",
- "layer": "L0",
- "priority": 0,
- "text": "\n".join(
- [
- f"# {normalized_profile.title()} Context",
- f"Generated: {utc_now()}",
- f"Project: {self._project_config_for(project_path).project_name}",
- ]
- ),
- }
- )
-
- mission = data["brief"].get("Mission", "").strip() or "No mission recorded yet."
- current_task_lines = ["## Current Task"]
- if task:
- current_task_lines.extend(
- [
- f"- ID: {task['id']}",
- f"- Title: {task['title']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- "",
- task["description"] or "No description.",
- ]
- )
- else:
- current_task_lines.append("- No current task is set.")
- sections.extend(
- [
- {"name": "mission", "layer": "L0", "priority": 1, "text": f"## Mission\n{mission}"},
- {"name": "current_task", "layer": "L0", "priority": 2, "text": "\n".join(current_task_lines)},
- ]
- )
-
- relevant_lines = ["## Relevant Files"]
- relevant_lines.extend([f"- {item}" for item in data["relevant_files"]] or ["- None"])
- sections.append({"name": "relevant_files", "layer": "L0", "priority": 3, "text": "\n".join(relevant_lines)})
-
- handoff = data["latest_handoff"]
- handoff_lines = ["## Latest Handoff"]
- if handoff:
- handoff_lines.extend(
- [
- f"- From: {handoff['from_actor']}",
- f"- To: {handoff['to_actor']}",
- f"- Created: {handoff['created_at']}",
- "",
- handoff["summary"] or "No summary.",
- "",
- f"Next Steps: {handoff['next_steps'] or 'None recorded.'}",
- ]
- )
- else:
- handoff_lines.append("- No handoff recorded yet.")
- sections.append({"name": "latest_handoff", "layer": "L0", "priority": 4, "text": "\n".join(handoff_lines)})
-
- blocker_lines = ["## Open Blockers"]
- blocker_lines.extend([f"- [{item['id']}] {item['title']}: {item['description']}" for item in data["blockers"]] or ["- None"])
- sections.append({"name": "blockers", "layer": "L0", "priority": 5, "text": "\n".join(blocker_lines)})
-
- if include_recent_work:
- work_lines = ["## Recent Work"]
- work_lines.extend([f"- {item['created_at']} [{item['actor']}] {item['message']}" for item in data["recent_work"]] or ["- None"])
- sections.append({"name": "recent_work", "layer": "L1", "priority": 6, "text": "\n".join(work_lines)})
-
- command_lines = ["## Recent Commands"]
- command_lines.extend(
- [
- f"- {item['created_at']} [{item['status']}] `{item['command_text']}`"
- + (f" -> {item['summary']}" if item.get("summary") else "")
- for item in data["recent_commands"]
- ]
- or ["- None"]
- )
- sections.append({"name": "recent_commands", "layer": "L1", "priority": 7, "text": "\n".join(command_lines)})
-
- decision_lines = ["## Recent Decisions"]
- decision_lines.extend([f"- [{item['id']}] {item['title']}: {item['decision']}" for item in data["decisions"]] or ["- None"])
- sections.append({"name": "decisions", "layer": "L1", "priority": 8, "text": "\n".join(decision_lines)})
-
- semantic_lines = ["## Recommended Semantic Lookups"]
- semantic_lines.extend([f"- {item['entity_key']}: {item['summary_hint'] or item['name']}" for item in data["semantic_suggestions"]] or ["- None"])
- sections.append({"name": "semantic", "layer": "L2", "priority": 9, "text": "\n".join(semantic_lines)})
-
- if data["active_sessions"]:
- session_lines = ["## Active Sessions"]
- session_lines.extend(
- [
- f"- {item['id']} [{item['status']}] {item['actor']} ({item['client_name']}/{item['model_name']})"
- for item in data["active_sessions"]
- ]
- )
- sections.append({"name": "sessions", "layer": "L1", "priority": 10, "text": "\n".join(session_lines)})
-
- if data["audit"] and normalized_profile in {"handoff", "recovery", "deep"}:
- audit_lines = ["## Session Audit"]
- audit_lines.extend([f"- {item['issue']}: {item['details']}" for item in data["audit"]])
- sections.append({"name": "audit", "layer": "L1", "priority": 11, "text": "\n".join(audit_lines)})
-
- if data["dependencies"]:
- dep_lines = ["## Dependency Map"]
- for dep in data["dependencies"][:10]:
- blocked_by = ", ".join(dep.get("blocked_by", [])) or "none"
- blocks = ", ".join(dep.get("blocks", [])) or "none"
- dep_lines.append(f"- {dep['task_id']} | blocked_by: {blocked_by} | blocks: {blocks}")
- if data["blocked_tasks"]:
- dep_lines.append("")
- dep_lines.append("Blocked tasks:")
- dep_lines.extend([f"- {task['id']}: {task['title']}" for task in data["blocked_tasks"][:6]])
- sections.append({"name": "dependencies", "layer": "L2", "priority": 12, "text": "\n".join(dep_lines)})
-
- if data["active_tasks"] and normalized_profile in {"deep", "recovery"}:
- task_lines = ["## Active Tasks Summary"]
- task_lines.extend([f"- {item['id']} [{item['status']}] {item['title']}" for item in data["active_tasks"][:10]])
- sections.append({"name": "active_tasks", "layer": "L3", "priority": 13, "text": "\n".join(task_lines)})
-
- if data["daily_notes"]:
- note_lines = ["## Daily Notes"]
- note_lines.extend([f"- {item['note_date']} [{item['actor']}] {item['entry']}" for item in data["daily_notes"]])
- sections.append({"name": "daily_notes", "layer": "L3", "priority": 14, "text": "\n".join(note_lines)})
-
- sections = self._apply_section_order_policy(sections, task=task, mode=normalized_profile)
- metadata = {
- "profile": normalized_profile,
- "task_id": task["id"] if task else None,
- "relevant_files": data["relevant_files"],
- "semantic_entity_keys": [item["entity_key"] for item in data["semantic_suggestions"]],
- "layers": {section["name"]: section["layer"] for section in sections},
- }
- return sections, metadata
-
- def generate_context_profile(
- self,
- profile: str = "balanced",
- task_id: str | None = None,
- max_tokens: int | None = None,
- include_daily_notes: bool = False,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- params = {
- "profile": profile,
- "task_id": task["id"] if task else task_id,
- "max_tokens": max_tokens,
- "include_daily_notes": include_daily_notes,
- }
- params_signature = self._artifact_signature(params)
- scope_key = self._context_scope_key(task)
- state_version = store.get_context_state_version(include_semantic=True)
- cached = store.get_context_artifact("context_profile", scope_key, params_signature)
- if cached and cached["state_version"] == state_version and not store.is_context_stale(cached):
- metadata = dict(cached.get("metadata", {}))
- metadata["cached"] = True
- result = {
- "profile": profile,
- "scope_key": scope_key,
- "markdown": cached["content"],
- "cached": True,
- "state_version": state_version,
- "metadata": metadata,
- }
- self._record_token_usage_metric(
- operation="generate_context_profile",
- project_path=project_path,
- estimated_output_tokens=int(metadata.get("used_tokens") or self._estimated_tokens(cached["content"])),
- compact_output_tokens=int(metadata.get("used_tokens") or self._estimated_tokens(cached["content"])),
- compact_chars=len(cached["content"]),
- metadata={
- "profile": profile,
- "scope_key": scope_key,
- "cached": True,
- "state_version": state_version,
- "sections": metadata.get("section_order", []),
- },
- )
- return result
-
- sections, metadata = self._build_tiered_sections(
- profile,
- task_id=task["id"] if task else task_id,
- project_path=project_path,
- include_daily_notes=include_daily_notes,
- )
- markdown, used_tokens = self._render_context_sections(sections, max_tokens=max_tokens)
- metadata.update(
- {
- "used_tokens": used_tokens,
- "section_order": [section["name"] for section in sections],
- "max_tokens": max_tokens,
- }
- )
- fingerprints = {fp["file_path"]: fp["fingerprint"] for fp in store.get_file_fingerprints().values()}
- metadata["fingerprint_set"] = fingerprints
- artifact = store.upsert_context_artifact(
- artifact_key=f"context_profile:{scope_key}:{params_signature}",
- artifact_type="context_profile",
- scope_key=scope_key,
- params_signature=params_signature,
- state_version=state_version,
- content=markdown,
- metadata=metadata,
- )
- result = {
- "profile": profile,
- "scope_key": scope_key,
- "markdown": markdown,
- "cached": False,
- "state_version": state_version,
- "metadata": (artifact or {}).get("metadata", metadata),
- }
- self._record_token_usage_metric(
- operation="generate_context_profile",
- project_path=project_path,
- estimated_output_tokens=used_tokens,
- compact_output_tokens=used_tokens,
- compact_chars=len(markdown),
- metadata={
- "profile": profile,
- "scope_key": scope_key,
- "cached": False,
- "state_version": state_version,
- "sections": metadata.get("section_order", []),
- },
- )
- return result
-
- def _resolve_delta_reference(
- self,
- *,
- store: StateStore,
- since_handoff_id: int | None = None,
- since_session_id: str | None = None,
- since_timestamp: str | None = None,
- ) -> tuple[str, str]:
- if since_timestamp:
- return since_timestamp, "timestamp"
- if since_handoff_id is not None:
- handoff = store.get_handoff(since_handoff_id)
- if not handoff:
- raise ValueError(f"Unknown handoff id: {since_handoff_id}")
- return handoff["created_at"], "handoff"
- if since_session_id:
- session = store.get_session(since_session_id)
- if not session:
- raise ValueError(f"Unknown session id: {since_session_id}")
- return session.get("opened_at") or session.get("heartbeat_at") or utc_now(), "session"
- latest_handoff = store.get_latest_handoff()
- if latest_handoff:
- return latest_handoff["created_at"], "latest_handoff"
- sessions = store.get_active_sessions(limit=1)
- if sessions:
- session = sessions[0]
- return session.get("opened_at") or session.get("heartbeat_at") or utc_now(), "active_session"
- return utc_now(), "now"
-
- def generate_delta_context(
- self,
- task_id: str | None = None,
- since_handoff_id: int | None = None,
- since_session_id: str | None = None,
- since_timestamp: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- params = {
- "task_id": task["id"] if task else task_id,
- "since_handoff_id": since_handoff_id,
- "since_session_id": since_session_id,
- "since_timestamp": since_timestamp,
- }
- params_signature = self._artifact_signature(params)
- scope_key = self._context_scope_key(task)
- state_version = store.get_context_state_version(include_semantic=True)
- cached = store.get_context_artifact("delta_context", scope_key, params_signature)
- if cached and cached["state_version"] == state_version and not store.is_context_stale(cached):
- metadata = dict(cached.get("metadata", {}))
- metadata["cached"] = True
- result = {
- "cached": True,
- "markdown": cached["content"],
- "state_version": state_version,
- "metadata": metadata,
- }
- self._record_token_usage_metric(
- operation="generate_delta_context",
- project_path=project_path,
- estimated_output_tokens=self._estimated_tokens(cached["content"]),
- compact_output_tokens=self._estimated_tokens(cached["content"]),
- compact_chars=len(cached["content"]),
- metadata={
- "cached": True,
- "scope_key": scope_key,
- "reference_kind": metadata.get("reference_kind"),
- "state_version": state_version,
- },
- )
- return result
-
- reference_time, reference_kind = self._resolve_delta_reference(
- store=store,
- since_handoff_id=since_handoff_id,
- since_session_id=since_session_id,
- since_timestamp=since_timestamp,
- )
- recent_work = store.get_recent_work_since(reference_time, limit=12)
- command_events = store.get_command_events_since(reference_time, limit=12)
- decisions = store.get_decisions_since(reference_time, limit=10)
- blockers = store.get_blockers_since(reference_time, limit=10)
- handoffs = store.get_handoffs_since(reference_time, limit=6)
- tasks = store.get_tasks_updated_since(reference_time, limit=10)
- file_candidates: list[str] = []
- for row in recent_work:
- for file_path in row.get("files", []):
- if file_path not in file_candidates:
- file_candidates.append(file_path)
- for row in tasks:
- for file_path in row.get("relevant_files", []):
- if file_path not in file_candidates:
- file_candidates.append(file_path)
- for row in command_events:
- for file_path in row.get("files_changed", []):
- if file_path not in file_candidates:
- file_candidates.append(file_path)
- semantic = self._semantic_lookup_suggestions(file_candidates, project_path=project_path, limit=6)
- lines = [
- "# Delta Context",
- "",
- f"- Reference Kind: {reference_kind}",
- f"- Since: {reference_time}",
- f"- Task: {(task or {}).get('id', 'none')}",
- "",
- "## Changed Tasks",
- "",
- ]
- lines.extend([f"- {item['id']} [{item['status']}] {item['title']}" for item in tasks] or ["- None"])
- lines.extend(["", "## New Work", ""])
- lines.extend([f"- {item['created_at']} [{item['actor']}] {item['message']}" for item in recent_work] or ["- None"])
- lines.extend(["", "## Command Activity", ""])
- lines.extend(
- [
- f"- {item['created_at']} [{item['status']}] `{item['command_text']}`"
- + (f": {item['summary']}" if item.get("summary") else "")
- for item in command_events
- ]
- or ["- None"]
- )
- lines.extend(["", "## New Decisions", ""])
- lines.extend([f"- [{item['id']}] {item['title']}: {item['decision']}" for item in decisions] or ["- None"])
- lines.extend(["", "## Blocker Changes", ""])
- lines.extend([f"- [{item['id']}] {item['title']} ({item['status']}): {item['description']}" for item in blockers] or ["- None"])
- lines.extend(["", "## Handoffs Since Reference", ""])
- lines.extend([f"- [{item['id']}] {item['from_actor']} -> {item['to_actor']}: {item['summary']}" for item in handoffs] or ["- None"])
- lines.extend(["", "## Changed Files", ""])
- lines.extend([f"- {item}" for item in file_candidates] or ["- None"])
- lines.extend(["", "## Recommended Semantic Lookups", ""])
- lines.extend([f"- {item['entity_key']}: {item['summary_hint'] or item['name']}" for item in semantic] or ["- None"])
- markdown = "\n".join(lines).strip() + "\n"
- metadata = {
- "reference_kind": reference_kind,
- "reference_time": reference_time,
- "task_id": (task or {}).get("id"),
- "changed_task_ids": [item["id"] for item in tasks],
- "changed_files": file_candidates,
- "semantic_entity_keys": [item["entity_key"] for item in semantic],
- "counts": {
- "tasks": len(tasks),
- "work_logs": len(recent_work),
- "command_events": len(command_events),
- "decisions": len(decisions),
- "blockers": len(blockers),
- "handoffs": len(handoffs),
- },
- }
- fingerprints = {fp["file_path"]: fp["fingerprint"] for fp in store.get_file_fingerprints().values()}
- metadata["fingerprint_set"] = fingerprints
- artifact = store.upsert_context_artifact(
- artifact_key=f"delta_context:{scope_key}:{params_signature}",
- artifact_type="delta_context",
- scope_key=scope_key,
- params_signature=params_signature,
- state_version=state_version,
- content=markdown,
- metadata=metadata,
- )
- result = {
- "cached": False,
- "markdown": markdown,
- "state_version": state_version,
- "metadata": (artifact or {}).get("metadata", metadata),
- }
- self._record_token_usage_metric(
- operation="generate_delta_context",
- project_path=project_path,
- estimated_output_tokens=self._estimated_tokens(markdown),
- compact_output_tokens=self._estimated_tokens(markdown),
- compact_chars=len(markdown),
- metadata={
- "cached": False,
- "scope_key": scope_key,
- "reference_kind": metadata.get("reference_kind"),
- "state_version": state_version,
- "counts": metadata.get("counts", {}),
- },
- )
- return result
-
- def record_token_usage(
- self,
- operation: str,
- project_path: str | None = None,
- event_type: str = "provider_usage",
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- model_name: str = "",
- provider: str = "",
- client_name: str = "",
- raw_input_tokens: int = 0,
- raw_output_tokens: int = 0,
- estimated_input_tokens: int = 0,
- estimated_output_tokens: int = 0,
- compact_input_tokens: int = 0,
- compact_output_tokens: int = 0,
- saved_tokens: int = 0,
- cache_creation_input_tokens: int = 0,
- cache_read_input_tokens: int = 0,
- raw_chars: int = 0,
- compact_chars: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- return self._record_token_usage_metric(
- operation=operation,
- project_path=project_path,
- event_type=event_type,
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- model_name=model_name,
- provider=provider,
- client_name=client_name,
- raw_input_tokens=raw_input_tokens,
- raw_output_tokens=raw_output_tokens,
- estimated_input_tokens=estimated_input_tokens,
- estimated_output_tokens=estimated_output_tokens,
- compact_input_tokens=compact_input_tokens,
- compact_output_tokens=compact_output_tokens,
- saved_tokens=saved_tokens,
- cache_creation_input_tokens=cache_creation_input_tokens,
- cache_read_input_tokens=cache_read_input_tokens,
- raw_chars=raw_chars,
- compact_chars=compact_chars,
- metadata=metadata,
- )
-
- def get_token_usage_stats(
- self,
- limit: int = 200,
- operation: str | None = None,
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- return self._store(project_path).get_token_usage_stats(limit=limit, operation=operation, session_id=session_id)
-
- def web_search(
- self,
- query: str,
- max_results: int | None = None,
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- client_name: str = "",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = get_opusmax_tool_provider().web_search(query=query, max_results=max_results)
- self._record_token_usage_metric(
- operation="web_search",
- project_path=project_path,
- event_type="provider_usage",
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- provider="opusmax",
- client_name=client_name,
- raw_chars=len(query),
- compact_chars=len(result.get("summary", "")),
- metadata={
- "request_id": result.get("request_id"),
- "endpoint": result["endpoint"],
- "result_count": len(result.get("results") or []),
- "latency_ms": result["latency_ms"],
- },
- )
- return result
-
- def understand_image(
- self,
- prompt: str,
- image_url: str | None = None,
- image_path: str | None = None,
- image_base64: str | None = None,
- mime_type: str | None = None,
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- client_name: str = "",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = get_opusmax_tool_provider().understand_image(
- prompt=prompt,
- image_url=image_url,
- image_path=image_path,
- image_base64=image_base64,
- mime_type=mime_type,
- )
- analysis_text = result.get("analysis")
- compact_chars = len(analysis_text) if isinstance(analysis_text, str) else len(json.dumps(analysis_text, ensure_ascii=True))
- self._record_token_usage_metric(
- operation="understand_image",
- project_path=project_path,
- event_type="provider_usage",
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- provider="opusmax",
- client_name=client_name,
- raw_chars=len(prompt),
- compact_chars=compact_chars,
- metadata={
- "request_id": result.get("request_id"),
- "endpoint": result["endpoint"],
- "latency_ms": result["latency_ms"],
- "image_source": result.get("image_source", {}),
- },
- )
- return result
-
- def get_raw_output_capture(
- self,
- capture_id: str,
- include_content: bool = False,
- project_path: str | None = None,
- ) -> dict[str, Any] | None:
- capture = self._store(project_path).get_raw_output_capture(capture_id)
- if not capture:
- return None
- if include_content:
- path = Path(capture["output_path"])
- capture["content"] = path.read_text(encoding="utf-8") if path.exists() else ""
- return capture
-
- def record_command_event(
- self,
- command_text: str,
- actor: str = "obsmcp",
- cwd: str = "",
- event_kind: str = "completed",
- status: str | None = None,
- risk_level: str = "normal",
- exit_code: int = 0,
- duration_ms: int = 0,
- output: str = "",
- stdout: str = "",
- stderr: str = "",
- summary: str | None = None,
- stdout_summary: str | None = None,
- stderr_summary: str | None = None,
- profile: str | None = None,
- policy_mode: str = "balanced",
- files_changed: list[str] | None = None,
- capture_raw_on_failure: bool = True,
- capture_raw_on_truncation: bool = True,
- session_id: str | None = None,
- task_id: str | None = None,
- metadata: dict[str, Any] | None = None,
- sync_mode: str = "deferred",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_task_id = task["id"] if task else task_id
- effective_profile = profile or self._classify_tool_output_profile(command_text)
- effective_status = (status or ("failed" if exit_code else "completed")).lower()
- if risk_level == "normal":
- risk_level = self._classify_command_execution_risk(command_text)["risk_level"]
- stream_stdout = stdout or output or ""
- stream_stderr = stderr or ""
- policy = self._build_optimization_policy(
- mode=policy_mode,
- task=task,
- command_profile=effective_profile,
- exit_code=exit_code,
- )
-
- if stream_stdout and not stdout_summary:
- stdout_result = self._summarize_command_stream(
- output=stream_stdout,
- profile=effective_profile,
- exit_code=exit_code,
- policy=policy,
- )
- else:
- normalized_stdout_summary = (stdout_summary or "").strip()
- stdout_result = {
- "summary": normalized_stdout_summary + ("\n" if normalized_stdout_summary else ""),
- "raw_chars": len(stream_stdout),
- "compact_chars": len(normalized_stdout_summary),
- "raw_tokens_est": self._estimated_tokens(stream_stdout),
- "compact_tokens_est": self._estimated_tokens(normalized_stdout_summary),
- "raw_lines": len(self._strip_ansi(stream_stdout).splitlines()) if stream_stdout else 0,
- "compact_lines": len(normalized_stdout_summary.splitlines()),
- "was_compacted": bool(stream_stdout and normalized_stdout_summary and self._strip_ansi(stream_stdout).strip() != normalized_stdout_summary),
- "profile_info": {"provided_summary": bool(stdout_summary)},
- }
-
- if stream_stderr and not stderr_summary:
- stderr_result = self._summarize_command_stream(
- output=stream_stderr,
- profile="logs",
- exit_code=exit_code,
- policy=policy,
- )
- else:
- normalized_stderr_summary = (stderr_summary or "").strip()
- stderr_result = {
- "summary": normalized_stderr_summary + ("\n" if normalized_stderr_summary else ""),
- "raw_chars": len(stream_stderr),
- "compact_chars": len(normalized_stderr_summary),
- "raw_tokens_est": self._estimated_tokens(stream_stderr),
- "compact_tokens_est": self._estimated_tokens(normalized_stderr_summary),
- "raw_lines": len(self._strip_ansi(stream_stderr).splitlines()) if stream_stderr else 0,
- "compact_lines": len(normalized_stderr_summary.splitlines()),
- "was_compacted": bool(stream_stderr and normalized_stderr_summary and self._strip_ansi(stream_stderr).strip() != normalized_stderr_summary),
- "profile_info": {"provided_summary": bool(stderr_summary)},
- }
-
- combined_output = "\n".join(part for part in [stream_stdout, stream_stderr] if part).strip()
- combined_summary = "\n\n".join(
- part.strip()
- for part in [stdout_result["summary"], stderr_result["summary"]]
- if part and part.strip()
- ).strip()
- raw_capture: dict[str, Any] | None = None
- capture_reason = ""
- if combined_output:
- raw_text = self._strip_ansi(combined_output).strip()
- compact_text = combined_summary.strip()
- if exit_code != 0 and capture_raw_on_failure and policy.get("raw_capture_on_failure", True):
- capture_reason = "failure"
- elif compact_text and raw_text and compact_text != raw_text and capture_raw_on_truncation and policy.get("raw_capture_on_truncation", True):
- capture_reason = "truncation"
- if capture_reason:
- raw_capture = self._store_raw_output_capture(
- command=command_text,
- output=combined_output,
- profile=effective_profile,
- reason=capture_reason,
- project_path=project_path,
- actor=actor,
- session_id=session_id,
- task_id=effective_task_id,
- exit_code=exit_code,
- metadata={
- "policy_mode": policy_mode,
- "stdout_profile_info": stdout_result["profile_info"],
- "stderr_profile_info": stderr_result["profile_info"],
- },
- )
-
- effective_summary = summary or self._build_command_event_summary(
- command_text=command_text,
- status=effective_status,
- exit_code=exit_code,
- duration_ms=duration_ms,
- stdout_summary=stdout_result["summary"],
- stderr_summary=stderr_result["summary"],
- raw_capture=raw_capture,
- )
- total_raw_tokens = stdout_result["raw_tokens_est"] + stderr_result["raw_tokens_est"]
- total_compact_tokens = stdout_result["compact_tokens_est"] + stderr_result["compact_tokens_est"]
- event = store.record_command_event(
- actor=actor,
- command_text=command_text,
- cwd=cwd,
- event_kind=event_kind,
- status=effective_status,
- risk_level=risk_level,
- exit_code=exit_code,
- duration_ms=duration_ms,
- summary=effective_summary,
- stdout_summary=stdout_result["summary"],
- stderr_summary=stderr_result["summary"],
- output_profile=effective_profile,
- raw_capture_id=(raw_capture or {}).get("capture_id"),
- raw_output_available=raw_capture is not None,
- files_changed=files_changed,
- metadata={
- **(metadata or {}),
- "policy_mode": policy_mode,
- "policy": policy,
- "capture_reason": capture_reason or None,
- "stdout_profile_info": stdout_result["profile_info"],
- "stderr_profile_info": stderr_result["profile_info"],
- },
- session_id=session_id,
- task_id=effective_task_id,
- ) or {}
- self._record_token_usage_metric(
- operation="record_command_event",
- project_path=project_path,
- event_type="command_history",
- actor=actor,
- session_id=session_id,
- task_id=effective_task_id,
- estimated_output_tokens=total_raw_tokens,
- compact_output_tokens=total_compact_tokens,
- saved_tokens=max(total_raw_tokens - total_compact_tokens, 0),
- raw_chars=stdout_result["raw_chars"] + stderr_result["raw_chars"],
- compact_chars=stdout_result["compact_chars"] + stderr_result["compact_chars"],
- metadata={
- "command": command_text,
- "status": effective_status,
- "exit_code": exit_code,
- "profile": effective_profile,
- "capture_reason": capture_reason or None,
- "event_id": event.get("id"),
- },
- )
- sync_result = self._sync_after_write(project_path, sync_mode=sync_mode)
- event["raw_capture"] = raw_capture
- event["sync"] = sync_result
- return event
-
- def record_command_batch(
- self,
- commands: list[dict[str, Any]],
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- policy_mode: str = "balanced",
- batch_label: str = "",
- sync_mode: str = "deferred",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- batch_id = f"BATCH-{uuid.uuid4().hex[:10].upper()}"
- recorded: list[dict[str, Any]] = []
- summary_lines: list[str] = []
- risk_counts = {"low": 0, "medium": 0, "high": 0}
- for idx, item in enumerate(commands or []):
- command_text = str(item.get("command_text") or item.get("command") or "").strip()
- if not command_text:
- continue
- policy = self.get_command_execution_policy(
- command=command_text,
- task_id=task_id,
- mode=policy_mode,
- exit_code=int(item.get("exit_code") or 0),
- project_path=project_path,
- )
- risk_counts[policy["risk_level"]] = risk_counts.get(policy["risk_level"], 0) + 1
- event = self.record_command_event(
- command_text=command_text,
- actor=item.get("actor", actor),
- cwd=item.get("cwd", ""),
- event_kind=item.get("event_kind", "batch_member"),
- status=item.get("status"),
- risk_level=item.get("risk_level", policy["risk_level"]),
- exit_code=int(item.get("exit_code") or 0),
- duration_ms=int(item.get("duration_ms") or 0),
- output=item.get("output", ""),
- stdout=item.get("stdout", ""),
- stderr=item.get("stderr", ""),
- summary=item.get("summary"),
- stdout_summary=item.get("stdout_summary"),
- stderr_summary=item.get("stderr_summary"),
- profile=item.get("profile"),
- policy_mode=item.get("policy_mode", policy_mode),
- files_changed=item.get("files_changed"),
- capture_raw_on_failure=bool(item.get("capture_raw_on_failure", True)),
- capture_raw_on_truncation=bool(item.get("capture_raw_on_truncation", True)),
- session_id=item.get("session_id", session_id),
- task_id=item.get("task_id", task_id),
- metadata={
- "batch_id": batch_id,
- "batch_index": idx,
- "batch_label": batch_label,
- },
- sync_mode="none",
- project_path=project_path,
- )
- recorded.append(event)
- summary_lines.append(f"- [{policy['risk_level']}/{event['status']}] `{command_text}`")
- sync_result = self._sync_after_write(project_path, sync_mode=sync_mode)
- return {
- "batch_id": batch_id,
- "batch_label": batch_label,
- "command_count": len(recorded),
- "commands": recorded,
- "risk_counts": risk_counts,
- "summary": "\n".join(summary_lines) if summary_lines else "- None",
- "sync": sync_result,
- }
-
- def get_command_event(self, event_id: int, project_path: str | None = None) -> dict[str, Any] | None:
- return self._store(project_path).get_command_event(event_id)
-
- def get_recent_commands(
- self,
- limit: int = 20,
- after_id: int | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- status: str | None = None,
- actor: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- effective_task_id = task_id
- if not effective_task_id and session_id:
- session = self._store(project_path).get_session(session_id)
- if session and session.get("task_id"):
- effective_task_id = session["task_id"]
- events = self._store(project_path).list_command_events(
- limit=limit,
- after_id=after_id,
- session_id=session_id,
- task_id=effective_task_id,
- status=status,
- actor=actor,
- )
- return {
- "events": events,
- "has_more": len(events) == limit,
- "next_cursor": events[-1]["id"] if events else None,
- }
-
- def get_last_command_result(
- self,
- session_id: str | None = None,
- task_id: str | None = None,
- actor: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any] | None:
- effective_task_id = task_id
- if not effective_task_id and session_id:
- session = self._store(project_path).get_session(session_id)
- if session and session.get("task_id"):
- effective_task_id = session["task_id"]
- return self._store(project_path).get_last_command_result(
- session_id=session_id,
- task_id=effective_task_id,
- actor=actor,
- )
-
- def get_command_failures(
- self,
- limit: int = 20,
- session_id: str | None = None,
- task_id: str | None = None,
- actor: str | None = None,
- project_path: str | None = None,
- ) -> list[dict[str, Any]]:
- effective_task_id = task_id
- if not effective_task_id and session_id:
- session = self._store(project_path).get_session(session_id)
- if session and session.get("task_id"):
- effective_task_id = session["task_id"]
- return self._store(project_path).get_command_failures(
- limit=limit,
- session_id=session_id,
- task_id=effective_task_id,
- actor=actor,
- )
-
- def compact_tool_output(
- self,
- command: str,
- output: str,
- exit_code: int = 0,
- profile: str | None = None,
- policy_mode: str = "balanced",
- actor: str = "obsmcp",
- session_id: str | None = None,
- task_id: str | None = None,
- capture_raw_on_failure: bool = True,
- capture_raw_on_truncation: bool = True,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_profile = profile or self._classify_tool_output_profile(command)
- policy = self._build_optimization_policy(
- mode=policy_mode,
- task=task,
- command_profile=effective_profile,
- exit_code=exit_code,
- )
- compact_output, profile_info = self._compact_tool_output_lines(
- profile=effective_profile,
- output=output,
- exit_code=exit_code,
- policy=policy,
- )
- raw_output_stripped = self._strip_ansi(output).strip() + ("\n" if output.strip() else "")
- raw_chars = len(output)
- compact_chars = len(compact_output)
- raw_tokens_est = self._estimated_tokens(output)
- compact_tokens_est = self._estimated_tokens(compact_output)
- line_count = len(raw_output_stripped.splitlines())
- compact_line_count = len(compact_output.splitlines())
- was_compacted = compact_output.strip() != raw_output_stripped.strip()
- raw_capture: dict[str, Any] | None = None
- capture_reason = ""
- if exit_code != 0 and capture_raw_on_failure and policy.get("raw_capture_on_failure", True):
- capture_reason = "failure"
- elif was_compacted and capture_raw_on_truncation and policy.get("raw_capture_on_truncation", True):
- capture_reason = "truncation"
- if capture_reason:
- raw_capture = self._store_raw_output_capture(
- command=command,
- output=output,
- profile=effective_profile,
- reason=capture_reason,
- project_path=project_path,
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- exit_code=exit_code,
- metadata={
- "raw_lines": line_count,
- "compact_lines": compact_line_count,
- "profile_info": profile_info,
- },
- )
- if raw_capture:
- compact_output = compact_output.rstrip() + (
- "\n\n[raw output saved]\n"
- f"- capture_id: {raw_capture['capture_id']}\n"
- f"- path: {raw_capture['output_path']}\n"
- )
- saved_tokens = max(raw_tokens_est - compact_tokens_est, 0)
- metrics = self._record_token_usage_metric(
- operation="compact_tool_output",
- project_path=project_path,
- event_type="tool_compaction",
- actor=actor,
- session_id=session_id,
- task_id=task_id,
- estimated_output_tokens=raw_tokens_est,
- compact_output_tokens=compact_tokens_est,
- saved_tokens=saved_tokens,
- raw_chars=raw_chars,
- compact_chars=len(compact_output),
- metadata={
- "command": command,
- "profile": effective_profile,
- "policy_mode": policy_mode,
- "exit_code": exit_code,
- "was_compacted": was_compacted,
- "capture_reason": capture_reason or None,
- "policy": policy,
- "profile_info": profile_info,
- },
- )
- return {
- "command": command,
- "profile": effective_profile,
- "policy_mode": policy_mode,
- "policy": policy,
- "exit_code": exit_code,
- "output": compact_output,
- "compact_output": compact_output,
- "raw_chars": raw_chars,
- "compact_chars": len(compact_output),
- "raw_tokens_est": raw_tokens_est,
- "compact_tokens_est": compact_tokens_est,
- "saved_tokens_est": saved_tokens,
- "raw_lines": line_count,
- "compact_lines": compact_line_count,
- "was_compacted": was_compacted,
- "raw_capture": raw_capture,
- "profile_info": profile_info,
- "metric": metrics,
- }
-
- def compact_response(
- self,
- text: str,
- level: str = "full",
- preserve_code: bool = True,
- actor: str = "obsmcp",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """
- Compress text output using rule-based patterns.
-
- Args:
- text: The text to compress
- level: Compression level - "lite", "full", or "ultra"
- preserve_code: If True, code blocks are preserved exactly
- actor: Actor name for tracking
- project_path: Project path for tracking
-
- Returns:
- Dictionary with compressed text and compression stats
- """
- if not text or not text.strip():
- return {
- "original": text,
- "compressed": text,
- "original_length": 0,
- "compressed_length": 0,
- "saved_ratio": 0.0,
- "was_compressed": False,
- }
-
- # Apply compression
- if preserve_code:
- result = compress_preserve_code(text, level)
- else:
- result = compress(text, level)
-
- # Record metrics if project path provided
- if project_path:
- saved_chars = result.original_length - result.compressed_length
- self._record_token_usage_metric(
- operation="compact_response",
- project_path=project_path,
- event_type="output_compression",
- actor=actor,
- raw_chars=result.original_length,
- compact_chars=result.compressed_length,
- metadata={
- "level": level,
- "preserve_code": preserve_code,
- "saved_ratio": result.saved_ratio,
- },
- )
-
- return {
- "original": text,
- "compressed": result.compressed,
- "original_length": result.original_length,
- "compressed_length": result.compressed_length,
- "saved_ratio": round(result.saved_ratio, 4),
- "was_compressed": result.was_compressed,
- "compression_level": level,
- }
-
- def generate_prompt_segments(
- self,
- profile: str = "balanced",
- task_id: str | None = None,
- max_tokens: int | None = 2600,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- params = {
- "profile": profile.lower(),
- "task_id": task["id"] if task else task_id,
- "max_tokens": max_tokens,
- }
- params_signature = self._artifact_signature(params)
- scope_key = self._context_scope_key(task)
- state_version = store.get_context_state_version(include_semantic=True)
- cached = store.get_context_artifact("prompt_segments", scope_key, params_signature)
- if cached and cached["state_version"] == state_version and not store.is_context_stale(cached):
- metadata = dict(cached.get("metadata", {}))
- metadata["cached"] = True
- combined_markdown = cached["content"]
- self._record_token_usage_metric(
- operation="generate_prompt_segments",
- project_path=project_path,
- estimated_output_tokens=int(metadata.get("combined_tokens") or self._estimated_tokens(combined_markdown)),
- compact_output_tokens=int(metadata.get("combined_tokens") or self._estimated_tokens(combined_markdown)),
- compact_chars=len(combined_markdown),
- metadata={
- "cached": True,
- "scope_key": scope_key,
- "profile": profile.lower(),
- },
- )
- return {
- "profile": profile.lower(),
- "scope_key": scope_key,
- "stable_markdown": metadata.get("stable_markdown", ""),
- "dynamic_markdown": metadata.get("dynamic_markdown", ""),
- "combined_markdown": combined_markdown,
- "cached": True,
- "state_version": state_version,
- "metadata": metadata,
- }
-
- data = self._collect_context_data(
- task_id=task["id"] if task else task_id,
- project_path=project_path,
- recent_work_limit=8,
- decisions_limit=6,
- blockers_limit=6,
- relevant_files_limit=12,
- semantic_limit=6,
- active_sessions_limit=4,
- active_tasks_limit=8,
- include_dependency_map=False,
- include_daily_notes=False,
- include_session_info=False,
- include_semantic=True,
- )
- brief = data["brief"]
- symbol_stats = store.get_symbol_index_stats()
- stable_sections = [
- {"name": "stable_header", "layer": "stable", "priority": 1, "text": "# Stable Prompt Prefix"},
- {"name": "mission", "layer": "stable", "priority": 2, "text": f"## Mission\n{brief.get('Mission', 'No mission recorded yet.').strip() or 'No mission recorded yet.'}"},
- {"name": "success_criteria", "layer": "stable", "priority": 3, "text": f"## Success Criteria\n{brief.get('Success Criteria', '').strip() or 'No success criteria recorded yet.'}"},
- {"name": "architecture", "layer": "stable", "priority": 4, "text": f"## Architecture\n{brief.get('Architecture', '').strip() or 'No architecture summary recorded yet.'}"},
- {"name": "working_agreements", "layer": "stable", "priority": 5, "text": f"## Working Agreements\n{brief.get('Working Agreements', '').strip() or 'No working agreements recorded yet.'}"},
- ]
- if symbol_stats.get("entity_count"):
- stable_sections.append(
- {
- "name": "atlas_snapshot",
- "layer": "stable",
- "priority": 6,
- "text": "\n".join(
- [
- "## Code Atlas Snapshot",
- f"- Entities: {symbol_stats.get('entity_count', 0)}",
- f"- Files: {symbol_stats.get('file_count', 0)}",
- f"- Features: {symbol_stats.get('feature_count', 0)}",
- ]
- ),
- }
- )
- stable_sections = self._apply_section_order_policy(stable_sections, task=task, mode=profile.lower())
-
- current_task_lines = ["## Current Task"]
- if task:
- current_task_lines.extend(
- [
- f"- ID: {task['id']}",
- f"- Title: {task['title']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- "",
- task["description"] or "No description.",
- ]
- )
- else:
- current_task_lines.append("- No current task is set.")
- dynamic_sections = [
- {"name": "dynamic_header", "layer": "dynamic", "priority": 1, "text": "# Dynamic Prompt Suffix"},
- {"name": "current_task", "layer": "dynamic", "priority": 2, "text": "\n".join(current_task_lines)},
- {"name": "relevant_files", "layer": "dynamic", "priority": 3, "text": "\n".join(["## Relevant Files"] + ([f"- {item}" for item in data["relevant_files"]] or ["- None"]))},
- ]
- handoff = data["latest_handoff"]
- handoff_lines = ["## Latest Handoff"]
- if handoff:
- handoff_lines.extend(
- [
- f"- From: {handoff['from_actor']}",
- f"- To: {handoff['to_actor']}",
- f"- Created: {handoff['created_at']}",
- "",
- handoff["summary"] or "No summary.",
- "",
- f"Next Steps: {handoff['next_steps'] or 'None recorded.'}",
- ]
- )
- else:
- handoff_lines.append("- No handoff recorded yet.")
- dynamic_sections.extend(
- [
- {"name": "latest_handoff", "layer": "dynamic", "priority": 4, "text": "\n".join(handoff_lines)},
- {"name": "blockers", "layer": "dynamic", "priority": 5, "text": "\n".join(["## Open Blockers"] + ([f"- [{item['id']}] {item['title']}: {item['description']}" for item in data["blockers"]] or ["- None"]))},
- {"name": "recent_work", "layer": "dynamic", "priority": 6, "text": "\n".join(["## Recent Work"] + ([f"- {item['created_at']} [{item['actor']}] {item['message']}" for item in data["recent_work"]] or ["- None"]))},
- {"name": "recent_decisions", "layer": "dynamic", "priority": 7, "text": "\n".join(["## Recent Decisions"] + ([f"- [{item['id']}] {item['title']}: {item['decision']}" for item in data["decisions"]] or ["- None"]))},
- {"name": "semantic", "layer": "dynamic", "priority": 8, "text": "\n".join(["## Recommended Semantic Lookups"] + ([f"- {item['entity_key']}: {item['summary_hint'] or item['name']}" for item in data["semantic_suggestions"]] or ["- None"]))},
- ]
- )
- dynamic_sections = self._apply_section_order_policy(dynamic_sections, task=task, mode=profile.lower())
-
- stable_budget = None
- if max_tokens and max_tokens > 0:
- stable_budget = min(max(max_tokens // 2, 200), max_tokens)
- stable_markdown, stable_used = self._render_context_sections(stable_sections, max_tokens=stable_budget)
- if max_tokens and max_tokens > 0:
- remaining_budget = max(max_tokens - stable_used, 0)
- if remaining_budget > 0:
- dynamic_markdown, dynamic_used = self._render_context_sections(dynamic_sections, max_tokens=remaining_budget)
- else:
- dynamic_markdown, dynamic_used = "", 0
- else:
- dynamic_markdown, dynamic_used = self._render_context_sections(dynamic_sections, max_tokens=None)
- combined_markdown = f"{stable_markdown.rstrip()}\n\n{dynamic_markdown.lstrip()}".strip() + "\n"
- metadata = {
- "profile": profile.lower(),
- "task_id": task["id"] if task else None,
- "stable_tokens": stable_used,
- "dynamic_tokens": dynamic_used,
- "combined_tokens": stable_used + dynamic_used,
- "stable_sections": [section["name"] for section in stable_sections],
- "dynamic_sections": [section["name"] for section in dynamic_sections],
- "stable_markdown": stable_markdown,
- "dynamic_markdown": dynamic_markdown,
- "max_tokens": max_tokens,
- }
- artifact = store.upsert_context_artifact(
- artifact_key=f"prompt_segments:{scope_key}:{params_signature}",
- artifact_type="prompt_segments",
- scope_key=scope_key,
- params_signature=params_signature,
- state_version=state_version,
- content=combined_markdown,
- metadata=metadata,
- )
- self._record_token_usage_metric(
- operation="generate_prompt_segments",
- project_path=project_path,
- estimated_output_tokens=stable_used + dynamic_used,
- compact_output_tokens=stable_used + dynamic_used,
- compact_chars=len(combined_markdown),
- metadata={
- "cached": False,
- "scope_key": scope_key,
- "profile": profile.lower(),
- "stable_sections": metadata["stable_sections"],
- "dynamic_sections": metadata["dynamic_sections"],
- },
- )
- final_metadata = (artifact or {}).get("metadata", metadata)
- return {
- "profile": profile.lower(),
- "scope_key": scope_key,
- "stable_markdown": final_metadata.get("stable_markdown", stable_markdown),
- "dynamic_markdown": final_metadata.get("dynamic_markdown", dynamic_markdown),
- "combined_markdown": combined_markdown,
- "cached": False,
- "state_version": state_version,
- "metadata": final_metadata,
- }
-
- def generate_retrieval_context(
- self,
- query: str,
- task_id: str | None = None,
- max_tokens: int | None = 1800,
- include_delta: bool = True,
- include_semantic: bool = True,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_query = query.strip() or ((task or {}).get("title", "").strip()) or "current task"
- params = {
- "query": effective_query,
- "task_id": task["id"] if task else task_id,
- "max_tokens": max_tokens,
- "include_delta": include_delta,
- "include_semantic": include_semantic,
- }
- params_signature = self._artifact_signature(params)
- scope_key = self._context_scope_key(task)
- state_version = store.get_context_state_version(include_semantic=True)
- cached = store.get_context_artifact("retrieval_context", scope_key, params_signature)
- if cached and cached["state_version"] == state_version and not store.is_context_stale(cached):
- metadata = dict(cached.get("metadata", {}))
- metadata["cached"] = True
- self._record_token_usage_metric(
- operation="generate_retrieval_context",
- project_path=project_path,
- estimated_output_tokens=int(metadata.get("used_tokens") or self._estimated_tokens(cached["content"])),
- compact_output_tokens=int(metadata.get("used_tokens") or self._estimated_tokens(cached["content"])),
- compact_chars=len(cached["content"]),
- metadata={
- "cached": True,
- "query": effective_query,
- "scope_key": scope_key,
- },
- )
- return {
- "query": effective_query,
- "scope_key": scope_key,
- "markdown": cached["content"],
- "cached": True,
- "state_version": state_version,
- "metadata": metadata,
- }
-
- terms = self._query_terms(effective_query)
- data = self._collect_context_data(
- task_id=task["id"] if task else task_id,
- project_path=project_path,
- recent_work_limit=12,
- decisions_limit=10,
- blockers_limit=8,
- relevant_files_limit=16,
- semantic_limit=8,
- active_sessions_limit=3,
- active_tasks_limit=10,
- include_dependency_map=False,
- include_daily_notes=False,
- include_session_info=False,
- include_semantic=include_semantic,
- )
- ranked_work = self._rank_values(
- data["recent_work"],
- terms=terms,
- text_getter=lambda item: f"{item.get('message', '')} {' '.join(item.get('files', []))}",
- limit=6,
- )
- ranked_decisions = self._rank_values(
- data["decisions"],
- terms=terms,
- text_getter=lambda item: f"{item.get('title', '')} {item.get('decision', '')}",
- limit=6,
- )
- ranked_blockers = self._rank_values(
- data["blockers"],
- terms=terms,
- text_getter=lambda item: f"{item.get('title', '')} {item.get('description', '')}",
- limit=5,
- )
- ranked_files = self._rank_values(
- data["relevant_files"],
- terms=terms,
- text_getter=lambda item: str(item),
- limit=8,
- )
- semantic_results = data["semantic_suggestions"]
- if include_semantic and effective_query:
- try:
- semantic_results = self.search_code_knowledge(effective_query, limit=8, project_path=project_path).get("results", [])
- except Exception:
- semantic_results = data["semantic_suggestions"]
- delta = self.generate_delta_context(task_id=(task or {}).get("id"), project_path=project_path) if include_delta else None
- delta_meta = (delta or {}).get("metadata", {})
-
- sections = [
- {
- "name": "header",
- "layer": "R0",
- "priority": 1,
- "text": "\n".join(
- [
- "# Retrieval Context",
- f"Query: {effective_query}",
- f"Generated: {utc_now()}",
- ]
- ),
- },
- {
- "name": "current_task",
- "layer": "R0",
- "priority": 2,
- "text": "\n".join(
- [
- "## Current Task",
- f"- ID: {(task or {}).get('id', 'none')}",
- f"- Title: {(task or {}).get('title', 'No current task')}",
- f"- Status: {(task or {}).get('status', 'unknown')}",
- "",
- (task or {}).get("description", "No task description."),
- ]
- ),
- },
- ]
- if include_delta and delta:
- sections.append(
- {
- "name": "delta",
- "layer": "R0",
- "priority": 3,
- "text": "\n".join(
- [
- "## Delta-First Summary",
- f"- Reference Kind: {delta_meta.get('reference_kind', 'unknown')}",
- f"- Changed Tasks: {delta_meta.get('counts', {}).get('tasks', 0)}",
- f"- New Work Logs: {delta_meta.get('counts', {}).get('work_logs', 0)}",
- f"- Decision Changes: {delta_meta.get('counts', {}).get('decisions', 0)}",
- f"- Matching Files: {', '.join(ranked_files[:5]) or 'None'}",
- ]
- ),
- }
- )
- sections.extend(
- [
- {
- "name": "relevant_files",
- "layer": "R1",
- "priority": 4,
- "text": "\n".join(["## Ranked Relevant Files"] + ([f"- {item}" for item in ranked_files] or ["- None"])),
- },
- {
- "name": "recent_work",
- "layer": "R1",
- "priority": 5,
- "text": "\n".join(["## Ranked Recent Work"] + ([f"- {item['created_at']} [{item['actor']}] {item['message']}" for item in ranked_work] or ["- None"])),
- },
- {
- "name": "decisions",
- "layer": "R1",
- "priority": 6,
- "text": "\n".join(["## Ranked Decisions"] + ([f"- [{item['id']}] {item['title']}: {item['decision']}" for item in ranked_decisions] or ["- None"])),
- },
- {
- "name": "blockers",
- "layer": "R1",
- "priority": 7,
- "text": "\n".join(["## Ranked Blockers"] + ([f"- [{item['id']}] {item['title']}: {item['description']}" for item in ranked_blockers] or ["- None"])),
- },
- {
- "name": "semantic",
- "layer": "R2",
- "priority": 8,
- "text": "\n".join(
- ["## Ranked Semantic Results"]
- + (
- [
- f"- {item.get('entity_key', item.get('name', 'unknown'))}: {item.get('summary_hint') or item.get('name', 'No summary')}"
- for item in semantic_results[:8]
- ]
- or ["- None"]
- )
- ),
- },
- ]
- )
- sections = self._apply_section_order_policy(sections, task=task, mode="balanced")
- markdown, used_tokens = self._render_context_sections(sections, max_tokens=max_tokens)
- metadata = {
- "query": effective_query,
- "task_id": (task or {}).get("id"),
- "used_tokens": used_tokens,
- "matched_files": ranked_files,
- "matched_work_ids": [item["id"] for item in ranked_work if item.get("id") is not None],
- "matched_decision_ids": [item["id"] for item in ranked_decisions if item.get("id") is not None],
- "matched_blocker_ids": [item["id"] for item in ranked_blockers if item.get("id") is not None],
- "semantic_entity_keys": [item.get("entity_key") for item in semantic_results[:8] if item.get("entity_key")],
- "section_order": [section["name"] for section in sections],
- "include_delta": include_delta,
- "include_semantic": include_semantic,
- }
- artifact = store.upsert_context_artifact(
- artifact_key=f"retrieval_context:{scope_key}:{params_signature}",
- artifact_type="retrieval_context",
- scope_key=scope_key,
- params_signature=params_signature,
- state_version=state_version,
- content=markdown,
- metadata=metadata,
- )
- self._record_token_usage_metric(
- operation="generate_retrieval_context",
- project_path=project_path,
- estimated_output_tokens=used_tokens,
- compact_output_tokens=used_tokens,
- compact_chars=len(markdown),
- metadata={
- "cached": False,
- "query": effective_query,
- "scope_key": scope_key,
- "section_order": metadata["section_order"],
- },
- )
- return {
- "query": effective_query,
- "scope_key": scope_key,
- "markdown": markdown,
- "cached": False,
- "state_version": state_version,
- "metadata": (artifact or {}).get("metadata", metadata),
- }
-
- def get_fast_path_response(
- self,
- kind: str,
- task_id: str | None = None,
- session_id: str | None = None,
- module_path: str | None = None,
- symbol_name: str | None = None,
- feature_name: str | None = None,
- query: str | None = None,
- as_markdown: bool = False,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- normalized_kind = kind.lower()
- payload: Any
- markdown = ""
- if normalized_kind == "current_task":
- payload = self.get_current_task(project_path=project_path) or {}
- markdown = "\n".join(
- [
- "# Fast Path: Current Task",
- f"- ID: {payload.get('id', 'none')}",
- f"- Title: {payload.get('title', 'No current task')}",
- f"- Status: {payload.get('status', 'unknown')}",
- f"- Priority: {payload.get('priority', 'unknown')}",
- "",
- payload.get("description", "No description."),
- ]
- ).strip() + "\n"
- elif normalized_kind == "blockers":
- payload = self.get_blockers(project_path=project_path)
- markdown = "\n".join(["# Fast Path: Blockers"] + ([f"- [{item['id']}] {item['title']}: {item['description']}" for item in payload] or ["- None"])) + "\n"
- elif normalized_kind == "relevant_files":
- payload = self.get_relevant_files(task_id=task_id, project_path=project_path)
- markdown = "\n".join(["# Fast Path: Relevant Files"] + ([f"- {item}" for item in payload] or ["- None"])) + "\n"
- elif normalized_kind == "task_snapshot":
- payload = self.generate_task_snapshot(task_id=task_id, project_path=project_path)
- markdown = json.dumps(payload, indent=2, ensure_ascii=True)
- elif normalized_kind == "project_status":
- payload = self.get_project_status_snapshot(project_path=project_path)
- markdown = json.dumps(payload, indent=2, ensure_ascii=True)
- elif normalized_kind == "resume_packet":
- payload = self.generate_resume_packet(session_id=session_id, task_id=task_id, project_path=project_path, write_files=False)
- markdown = payload["markdown"]
- elif normalized_kind == "startup_context":
- payload = self.generate_startup_context(session_id=session_id, task_id=task_id, project_path=project_path)
- markdown = payload["markdown"]
- elif normalized_kind == "startup_preflight":
- payload = self.get_startup_preflight(task_id=task_id, session_id=session_id, project_path=project_path)
- markdown = json.dumps(payload, indent=2, ensure_ascii=True)
- elif normalized_kind == "resume_board":
- payload = self.get_resume_board(project_path=project_path)
- markdown = json.dumps(payload, indent=2, ensure_ascii=True)
- elif normalized_kind == "recent_commands":
- payload = self.get_recent_commands(task_id=task_id, session_id=session_id, project_path=project_path)["events"]
- markdown = "\n".join(
- ["# Fast Path: Recent Commands"]
- + (
- [
- f"- {item.get('created_at', '')} [{item['status']}] `{item['command_text']}`"
- + (f": {item['summary']}" if item.get("summary") else "")
- for item in payload
- ]
- or ["- None"]
- )
- ) + "\n"
- elif normalized_kind == "last_command":
- payload = self.get_last_command_result(task_id=task_id, session_id=session_id, project_path=project_path) or {}
- markdown = "\n".join(
- [
- "# Fast Path: Last Command",
- f"- ID: {payload.get('id', 'none')}",
- f"- Status: {payload.get('status', 'none')}",
- f"- Exit Code: {payload.get('exit_code', 'none')}",
- f"- Command: {payload.get('command_text', 'none')}",
- "",
- payload.get("summary", "No command result recorded."),
- ]
- ).strip() + "\n"
- elif normalized_kind == "command_failures":
- payload = self.get_command_failures(task_id=task_id, session_id=session_id, project_path=project_path)
- markdown = "\n".join(
- ["# Fast Path: Command Failures"]
- + (
- [
- f"- {item.get('created_at', '')} [exit {item['exit_code']}] `{item['command_text']}`"
- + (f": {item['summary']}" if item.get("summary") else "")
- for item in payload
- ]
- or ["- None"]
- )
- ) + "\n"
- elif normalized_kind == "retrieval":
- payload = self.generate_retrieval_context(query=query or "", task_id=task_id, project_path=project_path)
- markdown = payload["markdown"]
- elif normalized_kind == "semantic_lookup":
- if symbol_name:
- payload = self.describe_symbol(symbol_name=symbol_name, module_path=module_path, project_path=project_path)
- elif feature_name:
- payload = self.describe_feature(feature_name=feature_name, project_path=project_path)
- elif module_path:
- payload = self.describe_module(module_path=module_path, project_path=project_path)
- else:
- raise ValueError("semantic_lookup fast path requires module_path, symbol_name, or feature_name.")
- markdown = json.dumps(payload, indent=2, ensure_ascii=True)
- else:
- raise ValueError(f"Unknown fast path kind: {kind}")
- rendered = markdown if as_markdown else payload
- output_text = markdown if markdown else json.dumps(payload, ensure_ascii=True)
- self._record_token_usage_metric(
- operation="get_fast_path_response",
- project_path=project_path,
- estimated_output_tokens=self._estimated_tokens(output_text),
- compact_output_tokens=self._estimated_tokens(output_text),
- compact_chars=len(output_text),
- metadata={
- "kind": normalized_kind,
- "task_id": task_id,
- "session_id": session_id,
- "as_markdown": as_markdown,
- },
- )
- return {
- "kind": normalized_kind,
- "source": "deterministic",
- "task_id": task_id,
- "session_id": session_id,
- "as_markdown": as_markdown,
- "result": rendered,
- "markdown": markdown,
- "json": payload,
- }
-
- def _artifact_sections_for_chunking(
- self,
- *,
- artifact_type: str,
- profile: str,
- task_id: str | None,
- project_path: str | None,
- query: str | None = None,
- ) -> list[dict[str, Any]]:
- task = self._store(project_path).get_task(task_id) if task_id else self._store(project_path).get_current_task()
- effective_task_id = task["id"] if task else task_id
- if artifact_type == "context_profile":
- sections, _ = self._build_tiered_sections(profile, task_id=effective_task_id, project_path=project_path)
- return sections
- if artifact_type == "prompt_segments":
- segments = self.generate_prompt_segments(profile=profile, task_id=effective_task_id, project_path=project_path)
- return [
- {"name": "stable", "layer": "stable", "priority": 1, "text": segments["stable_markdown"].strip()},
- {"name": "dynamic", "layer": "dynamic", "priority": 2, "text": segments["dynamic_markdown"].strip()},
- ]
- if artifact_type == "delta_context":
- delta = self.generate_delta_context(task_id=effective_task_id, project_path=project_path)
- return self._split_markdown_sections(delta["markdown"], fallback_name="delta_context")
- if artifact_type == "retrieval_context":
- retrieval = self.generate_retrieval_context(query=query or "", task_id=effective_task_id, project_path=project_path)
- return self._split_markdown_sections(retrieval["markdown"], fallback_name="retrieval_context")
- if artifact_type == "resume_packet":
- resume = self.generate_resume_packet(task_id=effective_task_id, project_path=project_path, write_files=False)
- return self._split_markdown_sections(resume["markdown"], fallback_name="resume_packet")
- raise ValueError(f"Unsupported artifact_type for chunking: {artifact_type}")
-
- def list_context_chunks(
- self,
- artifact_type: str = "context_profile",
- profile: str = "deep",
- task_id: str | None = None,
- query: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- sections = self._artifact_sections_for_chunking(
- artifact_type=artifact_type,
- profile=profile,
- task_id=task_id,
- project_path=project_path,
- query=query,
- )
- total_chunks = max(len(sections) // 4 + 1, 1)
- chunks = self._chunk_sections(sections, total_chunks)
- chunk_entries: list[dict[str, Any]] = []
- for idx, chunk_sections in enumerate(chunks):
- markdown, used_tokens = self._render_context_sections(chunk_sections, max_tokens=None)
- chunk_entries.append(
- {
- "chunk_index": idx,
- "is_last": idx == len(chunks) - 1,
- "section_names": [section["name"] for section in chunk_sections],
- "used_tokens": used_tokens,
- "char_count": len(markdown),
- }
- )
- return {
- "artifact_type": artifact_type,
- "profile": profile,
- "task_id": task_id,
- "query": query,
- "total_chunks": len(chunks),
- "chunks": chunk_entries,
- }
-
- def generate_progressive_context(
- self,
- artifact_type: str = "context_profile",
- profile: str = "deep",
- start_chunk: int = 0,
- chunk_count: int = 2,
- task_id: str | None = None,
- query: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- plan = self.list_context_chunks(
- artifact_type=artifact_type,
- profile=profile,
- task_id=task_id,
- query=query,
- project_path=project_path,
- )
- rendered_chunks: list[dict[str, Any]] = []
- for entry in plan["chunks"][start_chunk : start_chunk + max(chunk_count, 1)]:
- rendered = self.retrieve_context_chunk(
- artifact_type=artifact_type,
- chunk_index=entry["chunk_index"],
- profile=profile,
- task_id=task_id,
- query=query,
- project_path=project_path,
- )
- rendered_chunks.append(rendered)
- combined_markdown = "\n\n".join(chunk["markdown"].strip() for chunk in rendered_chunks if chunk.get("markdown")).strip() + "\n"
- self._record_token_usage_metric(
- operation="generate_progressive_context",
- project_path=project_path,
- estimated_output_tokens=self._estimated_tokens(combined_markdown),
- compact_output_tokens=self._estimated_tokens(combined_markdown),
- compact_chars=len(combined_markdown),
- metadata={
- "artifact_type": artifact_type,
- "profile": profile,
- "start_chunk": start_chunk,
- "chunk_count": chunk_count,
- },
- )
- return {
- "artifact_type": artifact_type,
- "profile": profile,
- "start_chunk": start_chunk,
- "chunk_count": len(rendered_chunks),
- "total_chunks": plan["total_chunks"],
- "chunks": rendered_chunks,
- "remaining_chunks": max(plan["total_chunks"] - (start_chunk + len(rendered_chunks)), 0),
- "combined_markdown": combined_markdown,
- }
-
- def _sync_context_artifacts(self, project_path: str | None = None) -> dict[str, str]:
- pcfg = self._project_config_for(project_path)
- context_dir = pcfg.context_path
- json_dir = pcfg.json_export_dir
- json_dir.mkdir(parents=True, exist_ok=True)
- files_written: dict[str, str] = {}
-
- hot = self.generate_context_profile(profile="fast", task_id=None, max_tokens=1200, project_path=str(pcfg.project_path))
- balanced = self.generate_context_profile(profile="balanced", task_id=None, max_tokens=2500, project_path=str(pcfg.project_path))
- deep = self.generate_context_profile(profile="deep", task_id=None, max_tokens=4500, include_daily_notes=True, project_path=str(pcfg.project_path))
- delta = self.generate_delta_context(project_path=str(pcfg.project_path))
- segments = self.generate_prompt_segments(profile="balanced", max_tokens=2600, project_path=str(pcfg.project_path))
- current_task = self._store(str(pcfg.project_path)).get_current_task()
- retrieval = self.generate_retrieval_context(
- query=((current_task or {}).get("title", "") or "current task"),
- task_id=(current_task or {}).get("id"),
- project_path=str(pcfg.project_path),
- )
- token_stats = self.get_token_usage_stats(limit=200, project_path=str(pcfg.project_path))
-
- hot_path = context_dir / "HOT_CONTEXT.md"
- balanced_path = context_dir / "BALANCED_CONTEXT.md"
- deep_path = context_dir / "DEEP_CONTEXT.md"
- delta_path = context_dir / "DELTA_CONTEXT.md"
- retrieval_path = context_dir / "RETRIEVAL_CONTEXT.md"
- stable_path = context_dir / "STABLE_CONTEXT.md"
- dynamic_path = context_dir / "DYNAMIC_CONTEXT.md"
- layers_path = json_dir / "context_layers.json"
- delta_json_path = json_dir / "delta_context.json"
- retrieval_json_path = json_dir / "retrieval_context.json"
- segments_json_path = json_dir / "prompt_segments.json"
- token_stats_path = json_dir / "token_usage_stats.json"
-
- write_text_atomic(hot_path, hot["markdown"])
- write_text_atomic(balanced_path, balanced["markdown"])
- write_text_atomic(deep_path, deep["markdown"])
- write_text_atomic(delta_path, delta["markdown"])
- write_text_atomic(retrieval_path, retrieval["markdown"])
- write_text_atomic(stable_path, segments["stable_markdown"])
- write_text_atomic(dynamic_path, segments["dynamic_markdown"])
- write_json_atomic(
- layers_path,
- {
- "fast": hot["metadata"],
- "balanced": balanced["metadata"],
- "deep": deep["metadata"],
- },
- )
- write_json_atomic(
- delta_json_path,
- {
- "markdown": delta["markdown"],
- "metadata": delta["metadata"],
- },
- )
- write_json_atomic(
- retrieval_json_path,
- {
- "markdown": retrieval["markdown"],
- "metadata": retrieval["metadata"],
- },
- )
- write_json_atomic(
- segments_json_path,
- {
- "stable_markdown": segments["stable_markdown"],
- "dynamic_markdown": segments["dynamic_markdown"],
- "combined_markdown": segments["combined_markdown"],
- "metadata": segments["metadata"],
- },
- )
- write_json_atomic(token_stats_path, token_stats)
- files_written["HOT_CONTEXT.md"] = str(hot_path)
- files_written["BALANCED_CONTEXT.md"] = str(balanced_path)
- files_written["DEEP_CONTEXT.md"] = str(deep_path)
- files_written["DELTA_CONTEXT.md"] = str(delta_path)
- files_written["RETRIEVAL_CONTEXT.md"] = str(retrieval_path)
- files_written["STABLE_CONTEXT.md"] = str(stable_path)
- files_written["DYNAMIC_CONTEXT.md"] = str(dynamic_path)
- files_written["context_layers.json"] = str(layers_path)
- files_written["delta_context.json"] = str(delta_json_path)
- files_written["retrieval_context.json"] = str(retrieval_json_path)
- files_written["prompt_segments.json"] = str(segments_json_path)
- files_written["token_usage_stats.json"] = str(token_stats_path)
- return files_written
-
- def _submit_precompute(self, project_path: str) -> None:
- """Submit a background precomputation job for context profiles, if not already running."""
- with self._precompute_lock:
- job_key = f"precompute:{project_path}"
- if job_key in self._precompute_jobs and not self._precompute_jobs[job_key].done():
- return
- self._precompute_jobs[job_key] = self._precompute_executor.submit(
- self._run_precompute, project_path
- )
-
- def _run_precompute(self, project_path: str) -> dict[str, Any]:
- """Background precomputation of standard context profiles and delta context."""
- try:
- store = self._store(project_path)
- task = store.get_current_task()
- if not task:
- sessions = store.get_active_sessions(limit=1)
- active_task_id = (sessions[0] if sessions else {}).get("task_id")
- if active_task_id:
- task = store.get_task(active_task_id)
- scope_key = self._context_scope_key(task)
- state_version = store.get_context_state_version(include_semantic=True)
- profiles = [
- ("fast", None, 1200),
- ("balanced", None, 2500),
- ("deep", True, 4500),
- ]
- results: dict[str, Any] = {}
- for profile, include_daily_notes, max_toks in profiles:
- params = {
- "profile": profile,
- "task_id": task["id"] if task else None,
- "max_tokens": max_toks,
- "include_daily_notes": include_daily_notes or False,
- }
- params_signature = self._artifact_signature(params)
- result = self.generate_context_profile(
- profile=profile,
- task_id=task["id"] if task else None,
- max_tokens=max_toks,
- include_daily_notes=include_daily_notes or False,
- project_path=project_path,
- )
- store.upsert_context_artifact(
- artifact_key=f"precomputed:{scope_key}:{profile}",
- artifact_type="precomputed",
- scope_key=scope_key,
- params_signature=params_signature,
- state_version=state_version,
- content=result["markdown"],
- metadata={
- "profile": profile,
- "max_tokens": max_toks,
- "precomputed": True,
- },
- )
- results[profile] = result["markdown"][:80]
- delta_params = {"task_id": task["id"] if task else None}
- delta_result = self.generate_delta_context(
- task_id=task["id"] if task else None,
- project_path=project_path,
- )
- store.upsert_context_artifact(
- artifact_key=f"precomputed:{scope_key}:delta",
- artifact_type="precomputed_delta",
- scope_key=scope_key,
- params_signature=self._artifact_signature(delta_params),
- state_version=state_version,
- content=delta_result["markdown"],
- metadata={"precomputed": True},
- )
- results["delta"] = delta_result["markdown"][:80]
- return {"precomputed": True, "profiles": results}
- except Exception as exc:
- return {"precomputed": False, "error": str(exc)}
-
- def _generate_minimal_fast_context(
- self,
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """Generate a lightweight L0-only fast context for startup/resume use cases.
-
- Returns only mission, current_task, relevant_files, latest_handoff, and blockers
- sections — no semantic lookups, dependency map, daily notes, or audit sections.
- Targeted at ~400-600 tokens.
- """
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- brief = store.get_project_brief()
- mission = brief.get("Mission", "No mission defined.")
- relevant_files = store.get_relevant_files(task["id"] if task else None)
- blockers = store.get_blockers(open_only=True, limit=6)
- latest_handoff = store.get_latest_handoff()
- recent_commands = store.list_command_events(limit=3, task_id=task["id"] if task else task_id)
- recent_failures = store.get_command_failures(limit=2, task_id=task["id"] if task else task_id)
- current_task_lines = ["## Current Task", ""]
- if task:
- current_task_lines.extend(
- [
- f"- ID: {task['id']}",
- f"- Title: {task['title']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- "",
- task["description"] or "No description.",
- ]
- )
- else:
- current_task_lines.append("- No current task is set.")
- sections = [
- {"name": "mission", "layer": "L0", "priority": 1, "text": f"## Mission\n{mission}"},
- {"name": "current_task", "layer": "L0", "priority": 2, "text": "\n".join(current_task_lines)},
- ]
- relevant_lines = ["## Relevant Files"]
- relevant_lines.extend([f"- {item}" for item in relevant_files] or ["- None"])
- sections.append({"name": "relevant_files", "layer": "L0", "priority": 3, "text": "\n".join(relevant_lines)})
- handoff_lines = ["## Latest Handoff"]
- if latest_handoff:
- handoff_lines.extend([
- f"- From: {latest_handoff['from_actor']}",
- f"- To: {latest_handoff['to_actor']}",
- f"- Created: {latest_handoff['created_at']}",
- "",
- latest_handoff["summary"] or "No summary.",
- ])
- else:
- handoff_lines.append("- No handoff recorded yet.")
- sections.append({"name": "latest_handoff", "layer": "L0", "priority": 4, "text": "\n".join(handoff_lines)})
- blocker_lines = ["## Open Blockers"]
- blocker_lines.extend([f"- [{item['id']}] {item['title']}: {item['description']}" for item in blockers] or ["- None"])
- sections.append({"name": "blockers", "layer": "L0", "priority": 5, "text": "\n".join(blocker_lines)})
- command_lines = ["## Recent Commands"]
- command_lines.extend(
- [
- f"- [{item['status']}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=140)}" if item.get("summary") else "")
- for item in recent_commands
- ]
- or ["- None"]
- )
- sections.append({"name": "recent_commands", "layer": "L0", "priority": 6, "text": "\n".join(command_lines)})
- failure_lines = ["## Recent Command Failures"]
- failure_lines.extend(
- [
- f"- [exit {item.get('exit_code', 0)}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=140)}" if item.get("summary") else "")
- for item in recent_failures
- ]
- or ["- None"]
- )
- sections.append({"name": "recent_command_failures", "layer": "L0", "priority": 7, "text": "\n".join(failure_lines)})
- markdown, used_tokens = self._render_context_sections(sections, max_tokens=None)
- return {
- "markdown": markdown.strip() + "\n",
- "used_tokens": used_tokens,
- "sections": [s["name"] for s in sections],
- }
-
- def list_tool_definitions(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return TOOL_DEFINITIONS
-
- def list_resource_definitions(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return [
- {
- "uri": "obsmcp://project/brief",
- "name": "Project Brief",
- "description": "Human-readable project brief composed from structured state.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://project/current-task",
- "name": "Current Task",
- "description": "The current task JSON snapshot.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://project/latest-handoff",
- "name": "Latest Handoff",
- "description": "Most recent handoff for cross-model continuity.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://project/status-snapshot",
- "name": "Status Snapshot",
- "description": "Compact project status snapshot.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://context/compact",
- "name": "Compact Context",
- "description": "Token-efficient continuity context for quick prompting.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/hot",
- "name": "Hot Context",
- "description": "Fastest L0/L1 continuity context for low-latency startup.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/delta",
- "name": "Delta Context",
- "description": "Only what changed since the last handoff/session reference.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/retrieval",
- "name": "Retrieval Context",
- "description": "Task-scoped retrieval-first context with ranked files, work, and semantic hits.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/stable",
- "name": "Stable Prompt Prefix",
- "description": "Cache-friendly stable project prompt segment.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/dynamic",
- "name": "Dynamic Prompt Suffix",
- "description": "Task and delta oriented prompt segment for active work.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/files",
- "name": "Context Files",
- "description": "Paths to synced .context continuity files.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://sessions/active",
- "name": "Active Sessions",
- "description": "Open AI/agent sessions tracked by obsmcp.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://sessions/audit",
- "name": "Session Audit",
- "description": "Missing write-back and heartbeat audit findings.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://prompts/master",
- "name": "Master Prompt",
- "description": "First-chat master prompt for MCP-dependent tools and agents.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://projects/list",
- "name": "Projects",
- "description": "Registered obsmcp projects.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://context/resume",
- "name": "Resume Packet",
- "description": "Compact resume packet for cross-tool handoff and recovery.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://context/startup",
- "name": "Startup Context",
- "description": "Delta-first startup context with recent command summaries and execution policy hints.",
- "mimeType": "text/markdown",
- },
- {
- "uri": "obsmcp://knowledge/index",
- "name": "Semantic Knowledge Index",
- "description": "Summary of semantic symbol counts for the active project.",
- "mimeType": "application/json",
- },
- {
- "uri": "obsmcp://metrics/tokens",
- "name": "Token Usage Metrics",
- "description": "Recent token, compaction, and prompt-cache usage metrics.",
- "mimeType": "application/json",
- },
- ]
-
- def get_resource(self, uri: str, project_path: str | None = None, project_slug: str | None = None) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- resolved_project_path = str(pcfg.project_path)
- if uri == "obsmcp://project/brief":
- brief = self.get_project_brief(project_path=resolved_project_path)
- text = "\n".join([f"# Project Brief", ""] + [f"## {section}\n\n{content}\n" for section, content in brief.items()])
- return {"uri": uri, "mimeType": "text/markdown", "text": text}
- if uri == "obsmcp://project/current-task":
- return {"uri": uri, "mimeType": "application/json", "json": self.get_current_task(project_path=resolved_project_path) or {}}
- if uri == "obsmcp://project/latest-handoff":
- handoff = self.get_latest_handoff(project_path=resolved_project_path)
- text = render_handoff_markdown(handoff)
- return {"uri": uri, "mimeType": "text/markdown", "text": text}
- if uri == "obsmcp://project/status-snapshot":
- return {"uri": uri, "mimeType": "application/json", "json": self.get_project_status_snapshot(project_path=resolved_project_path)}
- if uri == "obsmcp://context/compact":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_compact_context(project_path=resolved_project_path)}
- if uri == "obsmcp://context/hot":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_context_profile(profile="fast", max_tokens=1200, project_path=resolved_project_path)["markdown"]}
- if uri == "obsmcp://context/delta":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_delta_context(project_path=resolved_project_path)["markdown"]}
- if uri == "obsmcp://context/retrieval":
- current_task = self.get_current_task(project_path=resolved_project_path) or {}
- return {
- "uri": uri,
- "mimeType": "text/markdown",
- "text": self.generate_retrieval_context(
- query=current_task.get("title", "") or "current task",
- task_id=current_task.get("id"),
- project_path=resolved_project_path,
- )["markdown"],
- }
- if uri == "obsmcp://context/stable":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_prompt_segments(project_path=resolved_project_path)["stable_markdown"]}
- if uri == "obsmcp://context/dynamic":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_prompt_segments(project_path=resolved_project_path)["dynamic_markdown"]}
- if uri == "obsmcp://context/files":
- paths = {
- name: str(pcfg.context_path / name)
- for name in [
- "PROJECT_CONTEXT.md",
- "CURRENT_TASK.json",
- "HANDOFF.md",
- "DECISIONS.md",
- "RELEVANT_FILES.json",
- "BLOCKERS.json",
- "SESSION_SUMMARY.md",
- "SESSION_AUDIT.json",
- "RESUME_PACKET.md",
- "HOT_CONTEXT.md",
- "BALANCED_CONTEXT.md",
- "DEEP_CONTEXT.md",
- "DELTA_CONTEXT.md",
- "RETRIEVAL_CONTEXT.md",
- "STABLE_CONTEXT.md",
- "DYNAMIC_CONTEXT.md",
- ]
- }
- return {"uri": uri, "mimeType": "application/json", "json": paths}
- if uri == "obsmcp://projects/list":
- return {"uri": uri, "mimeType": "application/json", "json": self.list_projects()}
- if uri == "obsmcp://context/resume":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_resume_packet(project_path=resolved_project_path)["markdown"]}
- if uri == "obsmcp://context/startup":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_startup_context(project_path=resolved_project_path)["markdown"]}
- if uri == "obsmcp://knowledge/index":
- self._refresh_semantic_index(project_path=resolved_project_path)
- return {"uri": uri, "mimeType": "application/json", "json": self._store(resolved_project_path).get_symbol_index_stats()}
- if uri == "obsmcp://metrics/tokens":
- return {"uri": uri, "mimeType": "application/json", "json": self.get_token_usage_stats(project_path=resolved_project_path)}
- if uri == "obsmcp://sessions/active":
- return {"uri": uri, "mimeType": "application/json", "json": self.get_active_sessions(project_path=resolved_project_path)}
- if uri == "obsmcp://sessions/audit":
- return {"uri": uri, "mimeType": "application/json", "json": self.detect_missing_writeback(project_path=resolved_project_path)}
- if uri == "obsmcp://prompts/master":
- return {"uri": uri, "mimeType": "text/markdown", "text": self.generate_startup_prompt_template()}
- raise KeyError(f"Unknown resource: {uri}")
-
- def health_check(self, project_path: str | None = None, project_slug: str | None = None) -> dict[str, Any]:
- if not project_path and not project_slug and not self.config.bootstrap_default_project_on_startup:
- return {
- "name": self.config.app_name,
- "description": self.config.description,
- "host": self.config.host,
- "port": self.config.port,
- "project_slug": None,
- "project_path": None,
- "workspace_root": None,
- "database_path": None,
- "context_dir": None,
- "obsidian_vault_dir": None,
- "db_exists": False,
- "port_in_use": is_port_open(self.config.host, self.config.port),
- "current_task": None,
- "active_sessions": 0,
- "audit_issue_count": 0,
- "registered_projects": len(self.registry.list_projects()),
- "default_project_path": str(self.config.default_project_path) if self.config.default_project_path else None,
- "bootstrap_default_project_on_startup": False,
- "api_version": self.API_VERSION,
- "tool_schema_version": self.TOOL_SCHEMA_VERSION,
- "compatibility_rules_version": self.COMPATIBILITY_RULES_VERSION,
- }
-
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- store = self._store(project_path, project_slug=project_slug)
- active_sessions = store.get_active_sessions(limit=100)
- return {
- "name": self.config.app_name,
- "description": self.config.description,
- "host": self.config.host,
- "port": self.config.port,
- "project_slug": pcfg.project_slug,
- "project_path": str(pcfg.project_path),
- "workspace_root": str(pcfg.workspace_root),
- "database_path": str(pcfg.db_path),
- "context_dir": str(pcfg.context_path),
- "obsidian_vault_dir": str(pcfg.vault_path),
- "db_exists": pcfg.db_path.exists(),
- "port_in_use": is_port_open(self.config.host, self.config.port),
- "current_task": store.get_current_task(),
- "active_sessions": len(active_sessions),
- "audit_issue_count": len(store.detect_missing_writeback()),
- "registered_projects": len(self.registry.list_projects()),
- "default_project_path": str(self.config.default_project_path) if self.config.default_project_path else None,
- "bootstrap_default_project_on_startup": self.config.bootstrap_default_project_on_startup,
- "api_version": self.API_VERSION,
- "tool_schema_version": self.TOOL_SCHEMA_VERSION,
- "compatibility_rules_version": self.COMPATIBILITY_RULES_VERSION,
- }
-
- def register_project(
- self,
- repo_path: str,
- name: str | None = None,
- tags: list[str] | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- pcfg = self._project_config_for(repo_path)
- record = self._register_project_config(pcfg)
- if name or tags is not None:
- record = self.registry.touch(pcfg.project_slug, name=name, tags=tags, active_session_count=record.get("active_session_count", 0)) or record
- self.attach_repo_bridge(project_path=str(pcfg.project_path))
- self.sync_all(project_path=str(pcfg.project_path))
- return record
-
- def list_projects(self) -> list[dict[str, Any]]:
- return self.registry.list_projects()
-
- def resolve_project(self, project_slug: str | None = None, project_path: str | None = None) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- return {
- "project_slug": pcfg.project_slug,
- "project_name": pcfg.project_name,
- "project_path": str(pcfg.project_path),
- "workspace_root": str(pcfg.workspace_root),
- "vault_path": str(pcfg.vault_path),
- "context_path": str(pcfg.context_path),
- "db_path": str(pcfg.db_path),
- "sessions_path": str(pcfg.sessions_path),
- "manifest_path": str(pcfg.manifest_path),
- }
-
- def get_project_workspace_paths(self, project_slug: str | None = None, project_path: str | None = None) -> dict[str, Any]:
- return self.resolve_project(project_slug=project_slug, project_path=project_path)
-
- def attach_repo_bridge(self, project_path: str | None = None, project_slug: str | None = None) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- payload = {
- "project_slug": pcfg.project_slug,
- "project_name": pcfg.project_name,
- "repo_path": str(pcfg.project_path),
- "workspace_root": str(pcfg.workspace_root),
- "vault_path": str(pcfg.vault_path),
- "context_path": str(pcfg.context_path),
- "db_path": str(pcfg.db_path),
- "sessions_path": str(pcfg.sessions_path),
- "hub_vault_path": str(self.config.hub_vault_dir),
- "updated_at": utc_now(),
- }
- write_json_atomic(pcfg.bridge_file_path, payload)
- return {"attached": True, "bridge_file": str(pcfg.bridge_file_path), "workspace_root": str(pcfg.workspace_root)}
-
- def migrate_project_layout(self, project_path: str | None = None, project_slug: str | None = None) -> dict[str, Any]:
- pcfg = self._project_config_for(project_path, project_slug=project_slug)
- copied: list[str] = []
-
- legacy_context = pcfg.project_path / ".context"
- if legacy_context.exists():
- for source in legacy_context.rglob("*"):
- if not source.is_file():
- continue
- target = pcfg.context_path / source.relative_to(legacy_context)
- if not target.exists():
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(source, target)
- copied.append(str(target))
-
- legacy_vault = pcfg.project_path / "obsidian" / "vault"
- if legacy_vault.exists():
- for source in legacy_vault.rglob("*"):
- if not source.is_file():
- continue
- target = pcfg.vault_path / source.relative_to(legacy_vault)
- if not target.exists():
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(source, target)
- copied.append(str(target))
-
- self.attach_repo_bridge(project_path=str(pcfg.project_path))
- self.sync_all(project_path=str(pcfg.project_path))
- return {
- "migrated": True,
- "project_slug": pcfg.project_slug,
- "project_path": str(pcfg.project_path),
- "workspace_root": str(pcfg.workspace_root),
- "copied_files": copied,
- "copied_count": len(copied),
- }
-
- def get_project_brief(self, project_path: str | None = None) -> dict[str, str]:
- return self._store(project_path).get_project_brief()
-
- def get_current_task(self, project_path: str | None = None) -> dict[str, Any] | None:
- return self._store(project_path).get_current_task()
-
- def get_active_tasks(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).get_active_tasks(limit=20)
-
- def set_current_task(self, task_id: str, actor: str = "unknown", session_id: str | None = None, project_path: str | None = None) -> dict[str, Any] | None:
- result = self._store(project_path).set_current_task(task_id=task_id, actor=actor, session_id=session_id)
- if result and self.config.semantic_auto_generate.on_set_current_task:
- self._submit_semantic_prewarm(
- result.get("relevant_files", []),
- task_id=result.get("id"),
- project_path=project_path,
- reason="set_current_task",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- )
- self.sync_all(project_path)
- return result
-
- def get_latest_handoff(self, project_path: str | None = None) -> dict[str, Any] | None:
- return self._store(project_path).get_latest_handoff()
-
- def get_recent_work(
- self,
- limit: int | None = None,
- after_id: int | None = None,
- project_path: str | None = None,
- ) -> list[dict[str, Any]]:
- effective_limit = max(1, min(limit or self.config.max_recent_work_items, 1000))
- return self._store(project_path).get_recent_work(limit=effective_limit, after_id=after_id)
-
- def get_decisions(
- self,
- limit: int | None = None,
- after_id: int | None = None,
- project_path: str | None = None,
- ) -> list[dict[str, Any]]:
- effective_limit = max(1, min(limit or self.config.max_decisions, 1000))
- return self._store(project_path).get_decisions(limit=effective_limit, after_id=after_id)
-
- def get_blockers(
- self,
- open_only: bool = True,
- limit: int | None = None,
- after_id: int | None = None,
- project_path: str | None = None,
- ) -> list[dict[str, Any]]:
- effective_limit = max(1, min(limit or self.config.max_blockers, 1000))
- return self._store(project_path).get_blockers(open_only=open_only, limit=effective_limit, after_id=after_id)
-
- def get_relevant_files(self, task_id: str | None = None, project_path: str | None = None) -> list[str]:
- return self._store(project_path).get_relevant_files(task_id=task_id)
-
- def get_table_schema(self, table_name: str, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).get_table_schema(table_name)
-
- def search_notes(self, query: str, limit: int = 10, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).search_notes(query, limit=limit)
-
- def read_note(self, path: str, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).read_note(path)
-
- def get_project_status_snapshot(self, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).get_project_status_snapshot()
-
- def log_work(self, actor: str = "unknown", project_path: str | None = None, **kwargs: Any) -> dict[str, Any]:
- result = self._store(project_path).log_work(actor=actor, **kwargs)
- if self.config.semantic_auto_generate.on_log_work:
- self._submit_semantic_prewarm(
- result.get("files", []),
- task_id=kwargs.get("task_id"),
- project_path=project_path,
- reason="log_work",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- )
- self.sync_all(project_path)
- return result
-
- def update_task(self, task_id: str, actor: str = "unknown", project_path: str | None = None, **fields: Any) -> dict[str, Any]:
- store = self._store(project_path)
- result = store.update_task(task_id, actor=actor, **fields)
- if self.config.semantic_auto_generate.on_update_task:
- candidate_files = fields.get("relevant_files") if isinstance(fields.get("relevant_files"), list) else result.get("relevant_files", [])
- self._submit_semantic_prewarm(
- candidate_files,
- task_id=task_id,
- project_path=project_path,
- reason="update_task",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- )
- if fields.get("status") == "done":
- self.scan_codebase(project_path=project_path, force_refresh=False)
- try:
- self.sync_all(project_path)
- except Exception:
- # Side-effect sync may race with background scans that rebuild the semantic index.
- # The primary operation (task update) already succeeded — surface the result
- # even if sync failed. The error is non-fatal for the caller's purpose.
- pass
- return result
-
- def create_task(self, actor: str = "unknown", project_path: str | None = None, **kwargs: Any) -> dict[str, Any]:
- result = self._store(project_path).create_task(actor=actor, **kwargs)
- if self.config.semantic_auto_generate.on_create_task:
- self._submit_semantic_prewarm(
- result.get("relevant_files", []),
- task_id=result.get("id"),
- project_path=project_path,
- reason="create_task",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- )
- self.sync_all(project_path)
- return result
-
- def log_checkpoint(
- self,
- task_id: str,
- checkpoint_id: str,
- title: str,
- actor: str = "unknown",
- message: str = "",
- status: str = "completed",
- files: list[str] | None = None,
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- if not self.config.checkpoints.enabled:
- raise ValueError("Checkpoint logging is disabled in config.")
- result = self._store(project_path).log_checkpoint(
- task_id=task_id,
- checkpoint_id=checkpoint_id,
- title=title,
- message=message,
- status=status,
- files=files,
- actor=actor,
- session_id=session_id,
- )
- progress = self._store(project_path).get_checkpoint_progress(task_id)
- if self.config.checkpoints.auto_rollup and self.config.checkpoints.auto_close_task and progress.get("all_expected_complete"):
- task = self._store(project_path).get_task(task_id)
- if task and task.get("status") != "done":
- result["auto_closed_task"] = self._store(project_path).update_task(
- task_id,
- actor=actor,
- session_id=session_id,
- status="done",
- )
- self.sync_all(project_path)
- return result
-
- def get_task_progress(self, task_id: str, project_path: str | None = None) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id)
- if not task:
- raise ValueError(f"Unknown task: {task_id}")
- progress = store.get_checkpoint_progress(task_id)
- progress["recent_checkpoints"] = store.get_checkpoints_for_task(task_id, limit=self.config.checkpoints.render_limit)
- progress["task"] = task
- return progress
-
- def log_decision(self, actor: str = "unknown", project_path: str | None = None, **kwargs: Any) -> dict[str, Any]:
- result = self._store(project_path).log_decision(actor=actor, **kwargs)
- self.sync_all(project_path)
- return result
-
- def log_blocker(self, actor: str = "unknown", project_path: str | None = None, **kwargs: Any) -> dict[str, Any]:
- result = self._store(project_path).log_blocker(actor=actor, **kwargs)
- self.sync_all(project_path)
- return result
-
- def resolve_blocker(self, blocker_id: int, resolution_note: str, actor: str = "unknown", project_path: str | None = None) -> dict[str, Any] | None:
- result = self._store(project_path).resolve_blocker(blocker_id, resolution_note=resolution_note, actor=actor)
- self.sync_all(project_path)
- return result
-
- def _build_handoff_context(self, task_id: str | None = None, project_path: str | None = None) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_task_id = task["id"] if task else task_id
- relevant_files = store.get_relevant_files(task_id=effective_task_id, limit=8)
- blockers = [item for item in store.get_blockers(open_only=True, limit=8) if not effective_task_id or item.get("task_id") in {None, effective_task_id}]
- decisions = [item for item in store.get_decisions(limit=8) if not effective_task_id or item.get("task_id") in {None, effective_task_id}]
- recent_work = [item for item in store.get_recent_work(limit=10) if not effective_task_id or item.get("task_id") in {None, effective_task_id}]
- semantic_suggestions = self._semantic_lookup_suggestions(relevant_files, project_path=project_path, limit=6)
- return {
- "task": task,
- "relevant_files": relevant_files,
- "blockers": blockers[:4],
- "decisions": decisions[:4],
- "recent_work": recent_work[:4],
- "semantic_suggestions": semantic_suggestions[:4],
- }
-
- def generate_fast_context(
- self,
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """Generate a guaranteed-fast L0-only context for startup/resume use cases.
-
- This method bypasses the full tiered section assembly path and directly renders
- only the essential L0 sections (mission, current task, relevant files, handoff,
- blockers). It is ephemeral — no artifact is written — and returns in a single
- direct call with no cache lookup overhead.
- """
- result = self._generate_minimal_fast_context(task_id=task_id, project_path=project_path)
- result["fast"] = True
- result["ephemeral"] = True
- self._record_token_usage_metric(
- operation="generate_fast_context",
- project_path=project_path,
- estimated_output_tokens=result.get("used_tokens", self._estimated_tokens(result["markdown"])),
- compact_output_tokens=result.get("used_tokens", self._estimated_tokens(result["markdown"])),
- compact_chars=len(result["markdown"]),
- metadata={
- "sections": result.get("sections", []),
- "ephemeral": True,
- },
- )
- return result
-
- def _get_cached_delta_context(
- self,
- *,
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any] | None:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- scope_key = self._context_scope_key(task)
- params_signature = self._artifact_signature({"task_id": task["id"] if task else task_id})
- cached = store.get_context_artifact("precomputed_delta", scope_key, params_signature)
- if not cached:
- return None
- state_version = store.get_context_state_version(include_semantic=True)
- if cached["state_version"] != state_version or store.is_context_stale(cached):
- return None
- return {
- "cached": True,
- "markdown": cached["content"],
- "state_version": state_version,
- "metadata": dict(cached.get("metadata") or {}),
- }
-
- def generate_startup_context(
- self,
- task_id: str | None = None,
- session_id: str | None = None,
- max_tokens: int = 1800,
- prefer_cached_delta: bool = True,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_task_id = task["id"] if task else task_id
- if self.config.semantic_auto_generate.on_startup:
- self._best_effort_semantic_prewarm(
- None,
- task_id=effective_task_id,
- project_path=project_path,
- reason="startup_context",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_startup,
- )
- fast = self.generate_fast_context(task_id=effective_task_id, project_path=project_path)
- delta = self._get_cached_delta_context(task_id=effective_task_id, project_path=project_path) if prefer_cached_delta else None
- if not delta:
- delta = self.generate_delta_context(task_id=effective_task_id, project_path=project_path)
- recent_commands = self.get_recent_commands(limit=4, session_id=session_id, task_id=effective_task_id, project_path=project_path)["events"]
- recent_failures = self.get_command_failures(limit=3, session_id=session_id, task_id=effective_task_id, project_path=project_path)
- policy_hint = self.get_command_execution_policy(command="rg TODO .", task_id=effective_task_id, project_path=project_path)
- preflight = self.get_startup_preflight(task_id=effective_task_id, session_id=session_id, project_path=project_path)
- resume_board = self.get_resume_board(project_path=project_path)
-
- lines = [
- "# Startup Context",
- "",
- "## Startup Preflight",
- "",
- f"- Healthy: {preflight['ok']}",
- f"- Recommended Action: {preflight['recommended_action']}",
- "",
- ]
- lines.extend([f"- [{item['severity']}] {item['message']}" for item in preflight["warnings"]] or ["- No startup warnings."])
- lines.extend(
- [
- "",
- "## Resume Board",
- "",
- f"- Open Tasks: {len(resume_board['open_tasks'])}",
- f"- Paused Tasks: {len(resume_board['paused_tasks'])}",
- f"- Active Sessions: {len(resume_board['active_sessions'])}",
- f"- Stale Sessions: {len(resume_board['stale_sessions'])}",
- "",
- ]
- )
- recommended = resume_board["recommended_resume_target"]
- if recommended.get("task"):
- lines.extend(
- [
- f"- Recommended Task: {recommended['task']['title']}",
- f"- Recommended Session: {(recommended.get('session') or {}).get('session_label', 'none')}",
- "",
- ]
- )
- else:
- lines.extend(["- Recommended Task: none", "", ])
- lines.extend(
- [
- "## Fast Baseline",
- "",
- fast["markdown"].strip(),
- "",
- "## Delta Since Last Reference",
- "",
- delta["markdown"].strip(),
- "",
- "## Recent Commands",
- "",
- ]
- )
- lines.extend(
- [
- f"- [{item['status']}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=160)}" if item.get("summary") else "")
- for item in recent_commands
- ]
- or ["- None"]
- )
- lines.extend(["", "## Recent Command Failures", ""])
- lines.extend(
- [
- f"- [exit {item.get('exit_code', 0)}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=160)}" if item.get("summary") else "")
- for item in recent_failures
- ]
- or ["- None"]
- )
- lines.extend(["", "## Execution Policy Hint", ""])
- lines.extend(
- [
- f"- Sample Command: `{policy_hint['command']}`",
- f"- Action Type: {policy_hint['action_type']}",
- f"- Risk Level: {policy_hint['risk_level']}",
- f"- Batch Eligible: {policy_hint['can_batch']}",
- f"- Review Recommended: {policy_hint['needs_model_review']}",
- ]
- )
- markdown, used_tokens = self._render_context_sections(
- [{"name": "startup_context", "layer": "startup", "priority": 1, "text": "\n".join(lines)}],
- max_tokens=max_tokens,
- )
- self._record_token_usage_metric(
- operation="generate_startup_context",
- project_path=project_path,
- estimated_output_tokens=used_tokens,
- compact_output_tokens=used_tokens,
- compact_chars=len(markdown),
- metadata={
- "task_id": effective_task_id,
- "session_id": session_id,
- "prefer_cached_delta": prefer_cached_delta,
- "delta_cached": bool(delta.get("cached")),
- },
- )
- return {
- "markdown": markdown,
- "used_tokens": used_tokens,
- "task_id": effective_task_id,
- "session_id": session_id,
- "delta_cached": bool(delta.get("cached")),
- "sections": [
- "startup_preflight",
- "resume_board",
- "fast_baseline",
- "delta",
- "recent_commands",
- "recent_command_failures",
- "execution_policy_hint",
- ],
- }
-
- def retrieve_context_chunk(
- self,
- artifact_type: str = "context_profile",
- chunk_index: int = 0,
- profile: str = "deep",
- task_id: str | None = None,
- query: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """Retrieve a specific chunk of a context artifact.
-
- If the chunk does not exist in the cache, generates the full artifact,
- splits it into chunks, and stores each chunk for future requests.
- """
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- scope_key = self._context_scope_key(task)
-
- params = {
- "artifact_type": artifact_type,
- "profile": profile,
- "task_id": task["id"] if task else None,
- "query": query,
- }
- params_signature = self._artifact_signature(params)
- cached = store.get_context_chunk(scope_key, params_signature, chunk_index)
- if cached:
- metadata = cached.get("metadata") or {}
- result = {
- "chunk_index": metadata.get("chunk_index", chunk_index),
- "total_chunks": metadata.get("total_chunks", 1),
- "is_last": metadata.get("is_last", chunk_index == 0),
- "markdown": cached["content"],
- "cached": True,
- "scope_key": scope_key,
- "section_names": metadata.get("section_names", []),
- "next_chunk_index": metadata.get("chunk_index", chunk_index) + 1 if not metadata.get("is_last", chunk_index == 0) else None,
- "previous_chunk_index": max(metadata.get("chunk_index", chunk_index) - 1, 0) if metadata.get("chunk_index", chunk_index) > 0 else None,
- }
- self._record_token_usage_metric(
- operation="retrieve_context_chunk",
- project_path=project_path,
- estimated_output_tokens=self._estimated_tokens(cached["content"]),
- compact_output_tokens=self._estimated_tokens(cached["content"]),
- compact_chars=len(cached["content"]),
- metadata={
- "artifact_type": artifact_type,
- "cached": True,
- "chunk_index": result["chunk_index"],
- "total_chunks": result["total_chunks"],
- "profile": profile,
- },
- )
- return result
-
- sections = self._artifact_sections_for_chunking(
- artifact_type=artifact_type,
- profile=profile,
- task_id=task["id"] if task else None,
- project_path=project_path,
- query=query,
- )
-
- state_version = store.get_context_state_version(include_semantic=True)
- total_chunks = max(len(sections) // 4 + 1, 1)
- chunks = self._chunk_sections(sections, total_chunks)
- for i, chunk_sections in enumerate(chunks):
- chunk_markdown, _ = self._render_context_sections(chunk_sections, max_tokens=None)
- store.upsert_context_chunk(
- scope_key=scope_key,
- params_signature=params_signature,
- chunk_index=i,
- total_chunks=total_chunks,
- state_version=state_version,
- content=chunk_markdown,
- metadata={
- "artifact_type": artifact_type,
- "profile": profile,
- "query": query,
- "section_names": [section["name"] for section in chunk_sections],
- },
- )
-
- target = chunks[chunk_index] if chunk_index < len(chunks) else []
- chunk_markdown, used_tokens = self._render_context_sections(target, max_tokens=None)
- result = {
- "chunk_index": chunk_index,
- "total_chunks": total_chunks,
- "is_last": chunk_index >= total_chunks - 1,
- "markdown": chunk_markdown,
- "used_tokens": used_tokens,
- "cached": False,
- "scope_key": scope_key,
- "section_names": [section["name"] for section in target],
- "next_chunk_index": chunk_index + 1 if chunk_index < total_chunks - 1 else None,
- "previous_chunk_index": chunk_index - 1 if chunk_index > 0 else None,
- }
- self._record_token_usage_metric(
- operation="retrieve_context_chunk",
- project_path=project_path,
- estimated_output_tokens=used_tokens,
- compact_output_tokens=used_tokens,
- compact_chars=len(chunk_markdown),
- metadata={
- "artifact_type": artifact_type,
- "cached": False,
- "chunk_index": chunk_index,
- "total_chunks": total_chunks,
- "profile": profile,
- "query": query,
- "section_names": result["section_names"],
- },
- )
- return result
-
- def _autofill_handoff_fields(
- self,
- *,
- summary: str = "",
- next_steps: str = "",
- open_questions: str = "",
- note: str = "",
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- context = self._build_handoff_context(task_id=task_id, project_path=project_path)
- task = context["task"]
- effective_summary = summary.strip()
- if not effective_summary:
- if task:
- status_text = f"{task['title']} is currently {task['status']}."
- if context["recent_work"]:
- effective_summary = f"{status_text} Latest progress: {context['recent_work'][0]['message']}"
- else:
- effective_summary = status_text
- elif context["recent_work"]:
- effective_summary = context["recent_work"][0]["message"]
- else:
- effective_summary = "Session ended without a detailed handoff summary; resume from the latest task state."
-
- effective_next_steps = next_steps.strip()
- if not effective_next_steps:
- if context["blockers"]:
- effective_next_steps = f"Resolve blocker: {context['blockers'][0]['title']}."
- elif task and context["relevant_files"]:
- effective_next_steps = f"Continue `{task['title']}` starting with `{Path(context['relevant_files'][0]).name}`."
- elif task:
- effective_next_steps = f"Continue `{task['title']}` from the current persisted state."
- else:
- effective_next_steps = "Review the current task, recent work, and relevant files, then continue implementation."
-
- effective_open_questions = open_questions.strip()
- if not effective_open_questions:
- if context["blockers"]:
- effective_open_questions = "; ".join(item["title"] for item in context["blockers"])
- else:
- effective_open_questions = "None recorded."
-
- note_parts = [note.strip()] if note.strip() else []
- if task:
- note_parts.append(
- "\n".join(
- [
- "Task State:",
- f"- ID: {task['id']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- ]
- )
- )
- if context["relevant_files"]:
- note_parts.append("Relevant files:\n" + "\n".join(f"- {item}" for item in context["relevant_files"]))
- if task and self.config.checkpoints.enabled:
- progress = store.get_checkpoint_progress(task["id"])
- progress_lines = [
- (
- f"- Progress: {progress['completed_count']}/{progress['total_count']}"
- if progress.get("total_count") is not None
- else f"- Completed checkpoints: {progress['completed_count']}"
- )
- ]
- progress_lines.extend(
- (
- f"- {item['phase_key']}: {item['completed_count']}/{item['total_count']} complete"
- if item.get("total_count") is not None
- else f"- {item['phase_key']}: {item['completed_count']} complete"
- )
- for item in progress.get("phase_rollups", [])
- )
- note_parts.append("Checkpoint progress:\n" + "\n".join(progress_lines))
- if context["decisions"]:
- note_parts.append("Recent decisions:\n" + "\n".join(f"- {item['title']}" for item in context["decisions"]))
- if context["semantic_suggestions"]:
- note_parts.append(
- "Recommended semantic lookups:\n"
- + "\n".join(
- f"- {item['entity_key']} ({item['entity_type']}) {item['summary_hint'] or item['name']}"
- for item in context["semantic_suggestions"]
- )
- )
- effective_note = "\n\n".join(part for part in note_parts if part).strip()
- return {
- "summary": effective_summary,
- "next_steps": effective_next_steps,
- "open_questions": effective_open_questions,
- "note": effective_note,
- "context": context,
- }
-
- def _inject_handoff_environment_context(
- self,
- session_id: str | None,
- project_path: str | None,
- ) -> dict[str, Any]:
- """Enrich handoff with environment-specific context from the session's IDE metadata."""
- store = self._store(project_path)
- env_info = store.get_session_env_info(session_id) if session_id else None
- lineage_chain: list[dict[str, Any]] = []
- if session_id:
- lineage_chain = store.get_session_lineage_chain(session_id)
- return {
- "env_info": env_info,
- "lineage_chain": lineage_chain,
- }
-
- def create_handoff(self, from_actor: str = "unknown", project_path: str | None = None, **kwargs: Any) -> dict[str, Any]:
- session_id = kwargs.get("session_id")
- env_context = self._inject_handoff_environment_context(session_id, project_path)
- enriched = self._autofill_handoff_fields(
- summary=kwargs.get("summary", ""),
- next_steps=kwargs.get("next_steps", ""),
- open_questions=kwargs.get("open_questions", ""),
- note=kwargs.get("note", ""),
- task_id=kwargs.get("task_id"),
- project_path=project_path,
- )
- payload = dict(kwargs)
- payload.update(
- {
- "summary": enriched["summary"],
- "next_steps": enriched["next_steps"],
- "open_questions": enriched["open_questions"],
- "note": enriched["note"],
- }
- )
- handoff_task_id = payload.get("task_id") or (enriched["context"].get("task") or {}).get("id")
- if self.config.semantic_auto_generate.on_handoff:
- self._best_effort_semantic_prewarm(
- enriched["context"].get("relevant_files", []),
- task_id=handoff_task_id,
- project_path=project_path,
- reason="handoff",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_handoff,
- )
- result = self._store(project_path).create_handoff(from_actor=from_actor, **payload)
-
- handoff_id_value = result.get("id")
- if isinstance(handoff_id_value, int) and handoff_id_value > 0:
- for tool in kwargs.get("target_tools", ["claude-code", "vscode", "jetbrains"]):
- try:
- self.generate_cross_tool_handoff(
- handoff_id=handoff_id_value,
- session_id=session_id,
- target_tool=tool,
- target_env=kwargs.get("target_env", "default"),
- project_path=project_path,
- )
- except Exception:
- pass
-
- self.sync_all(project_path)
- return result
-
- def append_handoff_note(self, handoff_id: int, note: str, actor: str = "unknown", project_path: str | None = None) -> dict[str, Any] | None:
- result = self._store(project_path).append_handoff_note(handoff_id, note=note, actor=actor)
- self.sync_all(project_path)
- return result
-
- def update_project_brief_section(
- self,
- section: str,
- content: str,
- actor: str = "unknown",
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).update_project_brief_section(section=section, content=content, actor=actor, session_id=session_id)
- self.sync_all(project_path)
- return result
-
- def create_daily_note_entry(
- self,
- entry: str,
- actor: str = "unknown",
- note_date: str | None = None,
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).create_daily_note_entry(entry=entry, actor=actor, note_date=note_date, session_id=session_id)
- self.sync_all(project_path)
- return result
-
- def sync_context(self, project_path: str | None = None) -> dict[str, Any]:
- return self.sync_all(project_path)
-
- def generate_compact_context(self, task_id: str | None = None, project_path: str | None = None) -> str:
- if self.config.semantic_auto_generate.on_startup:
- self._best_effort_semantic_prewarm(
- None,
- task_id=task_id,
- project_path=project_path,
- reason="compact_context",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_startup,
- )
- result = self.generate_context_profile(
- profile="balanced",
- task_id=task_id,
- max_tokens=2200,
- project_path=project_path,
- )
- markdown = result["markdown"]
- if markdown.startswith("# Balanced Context"):
- markdown = markdown.replace("# Balanced Context", "# Compact Context", 1)
- return markdown
-
- def generate_compact_context_v2(
- self,
- task_id: str | None = None,
- max_tokens: int = 3000,
- include_decision_chain: bool = True,
- include_dependency_map: bool = True,
- include_session_info: bool = True,
- include_recent_work: bool = True,
- include_daily_notes: bool = False,
- project_path: str | None = None,
- ) -> str:
- if self.config.semantic_auto_generate.on_startup:
- self._best_effort_semantic_prewarm(
- None,
- task_id=task_id,
- project_path=project_path,
- reason="compact_context_v2",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_startup,
- )
- result = self.generate_context_profile(
- profile="deep",
- task_id=task_id,
- max_tokens=max_tokens,
- include_daily_notes=include_daily_notes,
- project_path=project_path,
- )
- markdown = result["markdown"].rstrip()
- if include_decision_chain is False or include_dependency_map is False or include_session_info is False or include_recent_work is False:
- sections, _ = self._build_tiered_sections(
- "deep",
- task_id=task_id,
- project_path=project_path,
- include_daily_notes=include_daily_notes,
- include_dependency_map=include_dependency_map,
- include_session_info=include_session_info,
- include_recent_work=include_recent_work,
- )
- if not include_decision_chain:
- sections = [section for section in sections if section["name"] != "decisions"]
- markdown, used_tokens = self._render_context_sections(sections, max_tokens=max_tokens)
- markdown = markdown.rstrip() + f"\n\n---\n_Context v2 | {used_tokens} tokens (budget: {max_tokens})_\n"
- return markdown
- used_tokens = result["metadata"].get("used_tokens", self._estimated_tokens(markdown))
- return markdown + f"\n\n---\n_Context v2 | {used_tokens} tokens (budget: {max_tokens})_\n"
-
- def _now(self) -> datetime:
- return datetime.now(timezone.utc)
-
- def generate_task_snapshot(self, task_id: str | None = None, project_path: str | None = None) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- if not task:
- return {"task": None, "recent_work": [], "blockers": [], "relevant_files": []}
- related_logs = [item for item in store.get_recent_work(limit=100) if item.get("task_id") == task["id"]][:10]
- related_blockers = [item for item in store.get_blockers(open_only=True, limit=100) if item.get("task_id") == task["id"]]
- return {
- "task": task,
- "progress": store.get_checkpoint_progress(task["id"]),
- "recent_work": related_logs,
- "blockers": related_blockers,
- "relevant_files": store.get_relevant_files(task_id=task["id"]),
- }
-
- def session_open(
- self,
- actor: str,
- project_path: str | None = None,
- resume_strategy: str = "auto",
- resume_session_id: str | None = None,
- sync_mode: str = "deferred",
- **kwargs: Any,
- ) -> dict[str, Any]:
- with span("session.open", actor=actor, project_path=project_path, resume_strategy=resume_strategy):
- effective_config = self._project_config_for(project_path)
- effective_project_path = str(effective_config.project_path)
- store = self._store(project_path)
- normalized_client_name = self._normalize_client_name(kwargs.get("client_name", ""))
- normalized_model_name = self._normalize_model_name(kwargs.get("model_name", ""))
- explicit_workstream_key = (kwargs.get("workstream_key", "") or "").strip()
- task = store.get_task(kwargs.get("task_id")) if kwargs.get("task_id") else store.get_current_task()
- identity = self._derive_session_identity(
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- task=task,
- session_label=kwargs.get("session_label", ""),
- workstream_key=kwargs.get("workstream_key", ""),
- workstream_title=kwargs.get("workstream_title", ""),
- )
- warnings = self._build_session_open_warnings(
- task=task,
- task_id=kwargs.get("task_id"),
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- latest_handoff=store.get_latest_handoff(),
- )
-
- resumed: dict[str, Any] | None = None
- resume_blocked_reason: str | None = None
- if resume_strategy != "new":
- if resume_session_id:
- resumed = store.resume_session(
- resume_session_id,
- actor=actor,
- client_name=normalized_client_name,
- model_name=normalized_model_name,
- session_label=identity["session_label"],
- workstream_key=identity["workstream_key"],
- workstream_title=identity["workstream_title"],
- task_id=kwargs.get("task_id"),
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- )
- elif resume_strategy == "auto":
- existing = store.find_resumable_session(
- actor=actor,
- client_name=normalized_client_name,
- model_name=normalized_model_name,
- workstream_key=explicit_workstream_key,
- project_path=effective_project_path,
- )
- if existing:
- resume_blocked_reason = self._session_resume_mismatch_reason(
- existing,
- task_id=kwargs.get("task_id"),
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- session_label=identity["session_label"],
- workstream_key=explicit_workstream_key,
- )
- if resume_blocked_reason is None:
- resumed = store.resume_session(
- existing["id"],
- actor=actor,
- client_name=normalized_client_name,
- model_name=normalized_model_name,
- session_label=identity["session_label"],
- workstream_key=identity["workstream_key"],
- workstream_title=identity["workstream_title"],
- task_id=kwargs.get("task_id"),
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- )
- else:
- warnings.append(f"Auto-resume skipped: {resume_blocked_reason}.")
-
- if resumed:
- resumed["sync"] = self._sync_after_write(project_path, sync_mode=sync_mode)
- resumed["warnings"] = warnings
- return {**resumed, "resumed": True, "resume_strategy": resume_strategy}
-
- result = store.open_session(
- actor=actor,
- project_path=effective_project_path,
- client_name=normalized_client_name,
- model_name=normalized_model_name,
- session_label=identity["session_label"],
- workstream_key=identity["workstream_key"],
- workstream_title=identity["workstream_title"],
- initial_request=kwargs.get("initial_request", ""),
- session_goal=kwargs.get("session_goal", ""),
- task_id=kwargs.get("task_id"),
- require_heartbeat=kwargs.get("require_heartbeat", True),
- require_work_log=kwargs.get("require_work_log", True),
- heartbeat_interval_seconds=kwargs.get("heartbeat_interval_seconds", 900),
- work_log_interval_seconds=kwargs.get("work_log_interval_seconds", 1800),
- min_work_logs=kwargs.get("min_work_logs", 1),
- handoff_required=kwargs.get("handoff_required", True),
- ide_name=kwargs.get("ide_name", ""),
- ide_version=kwargs.get("ide_version", ""),
- ide_platform=kwargs.get("ide_platform", ""),
- os_name=kwargs.get("os_name", ""),
- os_version=kwargs.get("os_version", ""),
- )
- result["sync"] = self._sync_after_write(project_path, sync_mode=sync_mode)
- result["warnings"] = warnings
- return result
-
- def session_heartbeat(self, session_id: str, actor: str, project_path: str | None = None, sync_mode: str = "deferred", **kwargs: Any) -> dict[str, Any] | None:
- result = self._store(project_path).heartbeat_session(session_id=session_id, actor=actor, **kwargs)
- if result is not None:
- result["sync"] = self._sync_after_write(project_path, sync_mode=sync_mode)
- return result
-
- def session_close(self, session_id: str, actor: str, project_path: str | None = None, **kwargs: Any) -> dict[str, Any] | None:
- session = self._store(project_path).get_session(session_id)
- created_handoff: dict[str, Any] | None = None
- if kwargs.get("create_handoff", True):
- created_handoff = self.create_handoff(
- from_actor=actor,
- project_path=project_path,
- summary=kwargs.get("handoff_summary") or kwargs.get("summary", ""),
- next_steps=kwargs.get("handoff_next_steps", ""),
- open_questions=kwargs.get("handoff_open_questions", ""),
- note=kwargs.get("handoff_note", ""),
- task_id=(session or {}).get("task_id"),
- to_actor=kwargs.get("handoff_to_actor", "next-agent"),
- session_id=session_id,
- )
- elif self.config.semantic_auto_generate.on_handoff:
- self._best_effort_semantic_prewarm(
- None,
- task_id=(session or {}).get("task_id"),
- project_path=project_path,
- reason="session_close",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_handoff,
- )
-
- result = self._store(project_path).close_session(
- session_id=session_id,
- actor=actor,
- summary=kwargs.get("summary") or (created_handoff or {}).get("summary", ""),
- create_handoff=False,
- existing_handoff_id=(created_handoff or {}).get("id"),
- handoff_to_actor=kwargs.get("handoff_to_actor", "next-agent"),
- )
- self.sync_all(project_path)
- return result
-
- def get_active_sessions(
- self,
- limit: int = 50,
- after_heartbeat_at: str | None = None,
- after_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- sessions = self._store(project_path).get_active_sessions(
- limit=limit,
- after_heartbeat_at=after_heartbeat_at,
- after_id=after_id,
- )
- return {
- "sessions": sessions,
- "has_more": len(sessions) == limit,
- "next_cursor": {"heartbeat_at": sessions[-1]["heartbeat_at"], "id": sessions[-1]["id"]} if sessions else None,
- }
-
- def detect_missing_writeback(self, include_closed: bool = False, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).detect_missing_writeback(include_closed=include_closed)
-
- def get_startup_preflight(
- self,
- actor: str = "",
- task_id: str | None = None,
- session_id: str | None = None,
- initial_request: str = "",
- session_goal: str = "",
- session_label: str = "",
- workstream_key: str = "",
- client_name: str = "",
- model_name: str = "",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- store = self._store(project_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_task_id = task["id"] if task else task_id
- explicit_workstream_key = (workstream_key or "").strip()
- identity = self._derive_session_identity(
- initial_request=initial_request,
- session_goal=session_goal,
- task=task,
- session_label=session_label,
- workstream_key=workstream_key,
- workstream_title="",
- )
- warnings: list[dict[str, str]] = []
- current_task = store.get_current_task()
- latest_handoff = store.get_latest_handoff()
- active_sessions = store.get_active_sessions(limit=10)
- session_audit = store.detect_missing_writeback()
-
- if current_task and current_task.get("status") == "done":
- warnings.append(
- {
- "code": "current_task_done",
- "severity": "high",
- "message": f"Current task `{current_task['title']}` is already marked done.",
- }
- )
- if latest_handoff and effective_task_id and latest_handoff.get("task_id") and latest_handoff.get("task_id") != effective_task_id:
- warnings.append(
- {
- "code": "latest_handoff_task_mismatch",
- "severity": "medium",
- "message": "Latest handoff belongs to another task.",
- }
- )
- if not task_id and self._request_needs_task_anchor(initial_request, session_goal):
- warnings.append(
- {
- "code": "session_without_task",
- "severity": "medium",
- "message": "This startup request looks substantial but has no task attached.",
- }
- )
-
- normalized_client_name = self._normalize_client_name(client_name)
- normalized_model_name = self._normalize_model_name(model_name)
- candidate = None
- for session in active_sessions:
- if actor and session.get("actor") != actor:
- continue
- if normalized_client_name and session.get("client_name") != normalized_client_name:
- continue
- if normalized_model_name and session.get("model_name") != normalized_model_name:
- continue
- if explicit_workstream_key and session.get("workstream_key") != explicit_workstream_key:
- continue
- candidate = session
- break
- if candidate:
- mismatch = self._session_resume_mismatch_reason(
- candidate,
- task_id=effective_task_id,
- initial_request=initial_request,
- session_goal=session_goal,
- session_label=identity["session_label"],
- workstream_key=identity["workstream_key"],
- )
- if mismatch:
- warnings.append(
- {
- "code": "resume_mismatch",
- "severity": "high",
- "message": f"Auto-resume candidate exists but conflicts with the incoming request: {mismatch}.",
- }
- )
-
- for issue in session_audit:
- warnings.append(
- {
- "code": issue["issue"],
- "severity": issue["severity"],
- "message": issue["details"],
- }
- )
-
- warning_codes = {item["code"] for item in warnings}
- if "resume_mismatch" in warning_codes or "current_task_done" in warning_codes:
- recommended_action = "Create or select the correct task, then open a new session instead of auto-resuming."
- elif "session_without_task" in warning_codes:
- recommended_action = "Create/select a task before starting substantive work."
- elif any(code in warning_codes for code in {"stale_open_session", "abandoned_session", "heartbeat_overdue"}):
- recommended_action = "Recover or close stale sessions before continuing."
- else:
- recommended_action = "Startup state looks healthy."
-
- return {
- "ok": not any(item["severity"] == "high" for item in warnings),
- "project_path": str(self._project_config_for(project_path).project_path),
- "current_task": current_task,
- "task": task,
- "latest_handoff": latest_handoff,
- "active_session_count": len(active_sessions),
- "warnings": warnings,
- "recommended_action": recommended_action,
- "derived_session_identity": identity,
- "session_id": session_id,
- }
-
- def get_resume_board(self, project_path: str | None = None) -> dict[str, Any]:
- store = self._store(project_path)
- current_task = store.get_current_task()
- active_tasks = store.get_active_tasks(limit=20)
- active_sessions = store.get_active_sessions(limit=20)
- stale_issues = store.detect_missing_writeback()
- recent_handoffs = store.get_recent_handoffs(limit=5)
- active_task_ids = {item.get("task_id") for item in active_sessions if item.get("task_id")}
- paused_tasks = [task for task in active_tasks if task["id"] not in active_task_ids]
- stale_session_ids = {item["session_id"] for item in stale_issues}
- stale_sessions = [session for session in active_sessions if session["id"] in stale_session_ids]
-
- recommended_task = None
- if current_task and current_task.get("status") in {"open", "in_progress", "blocked"}:
- recommended_task = current_task
- elif paused_tasks:
- recommended_task = paused_tasks[0]
- elif active_tasks:
- recommended_task = active_tasks[0]
-
- recommended_session = None
- if recommended_task:
- for session in active_sessions:
- if session.get("task_id") == recommended_task["id"]:
- recommended_session = session
- break
-
- return {
- "current_task": current_task,
- "open_tasks": active_tasks,
- "paused_tasks": paused_tasks,
- "active_sessions": active_sessions,
- "stale_sessions": stale_sessions,
- "latest_handoffs": recent_handoffs,
- "recommended_resume_target": {
- "task": recommended_task,
- "session": recommended_session,
- },
- }
-
- def get_server_capabilities(self, project_path: str | None = None) -> dict[str, Any]:
- return {
- "api_version": self.API_VERSION,
- "tool_schema_version": self.TOOL_SCHEMA_VERSION,
- "compatibility_rules_version": self.COMPATIBILITY_RULES_VERSION,
- "project_path": str(self._project_config_for(project_path).project_path) if project_path else None,
- "features": {
- "session_labels": True,
- "workstreams": True,
- "startup_preflight": True,
- "resume_board": True,
- "session_mismatch_guard": True,
- "task_first_session_warnings": True,
- "reset_verification": True,
- "stable_client_identity_normalization": True,
- },
- }
-
- def check_client_compatibility(
- self,
- client_api_version: str = "",
- client_tool_schema_version: int | None = None,
- client_name: str = "",
- model_name: str = "",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- warnings: list[str] = []
- compatible = True
- if client_api_version and client_api_version != self.API_VERSION:
- compatible = False
- warnings.append(f"Client API version `{client_api_version}` does not match server API version `{self.API_VERSION}`.")
- if client_tool_schema_version is not None and client_tool_schema_version != self.TOOL_SCHEMA_VERSION:
- compatible = False
- warnings.append(
- f"Client tool schema version `{client_tool_schema_version}` does not match server tool schema version `{self.TOOL_SCHEMA_VERSION}`."
- )
- return {
- "compatible": compatible,
- "server": self.get_server_capabilities(project_path=project_path),
- "client": {
- "client_name": self._normalize_client_name(client_name),
- "model_name": self._normalize_model_name(model_name),
- "api_version": client_api_version or None,
- "tool_schema_version": client_tool_schema_version,
- },
- "warnings": warnings,
- }
-
- def generate_resume_packet(
- self,
- session_id: str | None = None,
- task_id: str | None = None,
- project_path: str | None = None,
- write_files: bool = True,
- ) -> dict[str, Any]:
- inferred_path = project_path or self._find_project_path_for_session(session_id) or self._find_project_path_for_task(task_id)
- pcfg = self._project_config_for(inferred_path)
- store = self._store(inferred_path)
- task = store.get_task(task_id) if task_id else store.get_current_task()
- effective_task_id = task["id"] if task else task_id
- if self.config.semantic_auto_generate.on_startup:
- self._best_effort_semantic_prewarm(
- None,
- task_id=effective_task_id,
- project_path=inferred_path,
- reason="resume_packet",
- limit=self.config.semantic_auto_generate.max_modules_per_write,
- wait_ms=self.config.semantic_auto_generate.wait_ms_on_startup,
- )
- latest_handoff = store.get_latest_handoff()
- blockers = store.get_blockers(open_only=True, limit=self.config.max_blockers)
- decisions = store.get_decisions(limit=min(self.config.max_decisions, 8))
- recent_work = store.get_recent_work(limit=min(self.config.max_recent_work_items, 8))
- relevant_files = store.get_relevant_files(task_id=effective_task_id)
- recent_commands = store.list_command_events(limit=6, task_id=effective_task_id)
- recent_failures = store.get_command_failures(limit=4, task_id=effective_task_id)
- active_sessions = store.get_active_sessions(limit=10)
- semantic_suggestions = self._semantic_lookup_suggestions(relevant_files, project_path=inferred_path, limit=6)
- delta = self.generate_delta_context(task_id=effective_task_id, project_path=inferred_path)
- retrieval = self.generate_retrieval_context(
- query=((task or {}).get("title", "") or "current task"),
- task_id=effective_task_id,
- max_tokens=900,
- project_path=inferred_path,
- )
- lines = [
- "# Resume Packet",
- "",
- f"- Project: {pcfg.project_name}",
- f"- Project Slug: {pcfg.project_slug}",
- f"- Repo Path: {pcfg.project_path}",
- f"- Generated At: {utc_now()}",
- "",
- "## Current Task",
- "",
- ]
- if task:
- task_progress = store.get_checkpoint_progress(task["id"])
- lines.extend(
- [
- f"- ID: {task['id']}",
- f"- Title: {task['title']}",
- f"- Status: {task['status']}",
- f"- Priority: {task['priority']}",
- (
- f"- Checkpoints: {task_progress['completed_count']}/{task_progress['total_count']}"
- if task_progress.get("total_count") is not None
- else f"- Checkpoints Completed: {task_progress['completed_count']}"
- ),
- "",
- task["description"],
- "",
- ]
- )
- else:
- lines.extend(["No current task set.", ""])
- lines.extend(["## Relevant Files", ""])
- lines.extend([f"- {item}" for item in relevant_files] or ["- None"])
- lines.extend(["", "## Recommended Semantic Lookups", ""])
- lines.extend([f"- {item['entity_key']}: {item['summary_hint'] or item['name']}" for item in semantic_suggestions] or ["- None"])
- lines.extend(["", "## Delta Summary", ""])
- delta_meta = delta.get("metadata", {})
- lines.extend(
- [
- f"- Reference Kind: {delta_meta.get('reference_kind', 'unknown')}",
- f"- Since: {delta_meta.get('reference_time', 'unknown')}",
- f"- Changed Tasks: {delta_meta.get('counts', {}).get('tasks', 0)}",
- f"- New Work Logs: {delta_meta.get('counts', {}).get('work_logs', 0)}",
- f"- Decision Changes: {delta_meta.get('counts', {}).get('decisions', 0)}",
- ]
- )
- retrieval_meta = retrieval.get("metadata", {})
- lines.extend(["", "## Targeted Retrieval", ""])
- lines.extend([f"- Query: {retrieval.get('query', 'current task')}"])
- lines.extend([f"- Matched Files: {', '.join(retrieval_meta.get('matched_files', [])[:5]) or 'None'}"])
- lines.extend(
- [
- f"- Semantic Hits: {', '.join(retrieval_meta.get('semantic_entity_keys', [])[:4]) or 'None'}",
- f"- Ranked Work IDs: {', '.join(str(item) for item in retrieval_meta.get('matched_work_ids', [])[:4]) or 'None'}",
- ]
- )
- lines.extend(["", "## Recent Work", ""])
- lines.extend([f"- {item['created_at']} [{item['actor']}] {item['message']}" for item in recent_work] or ["- None"])
- lines.extend(["", "## Recent Commands", ""])
- lines.extend(
- [
- f"- {item['created_at']} [{item['status']}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=180)}" if item.get("summary") else "")
- for item in recent_commands
- ]
- or ["- None"]
- )
- lines.extend(["", "## Recent Command Failures", ""])
- lines.extend(
- [
- f"- [exit {item.get('exit_code', 0)}] `{item['command_text']}`"
- + (f": {self._single_line_summary(item['summary'], max_chars=180)}" if item.get("summary") else "")
- for item in recent_failures
- ]
- or ["- None"]
- )
- lines.extend(["", "## Blockers", ""])
- lines.extend([f"- [{item['id']}] {item['title']}: {item['description']}" for item in blockers] or ["- None"])
- lines.extend(["", "## Decisions", ""])
- lines.extend([f"- [{item['id']}] {item['title']}: {item['decision']}" for item in decisions] or ["- None"])
- lines.extend(["", "## Latest Handoff", ""])
- if latest_handoff:
- lines.extend(
- [
- f"- From: {latest_handoff['from_actor']}",
- f"- To: {latest_handoff['to_actor']}",
- "",
- latest_handoff["summary"],
- "",
- f"Next Steps: {latest_handoff['next_steps'] or 'None recorded.'}",
- "",
- ]
- )
- else:
- lines.extend(["No handoff recorded yet.", ""])
- lines.extend(["## Active Sessions", ""])
- lines.extend([f"- {item['id']} [{item['status']}] {item['actor']} ({item['client_name']}/{item['model_name']})" for item in active_sessions] or ["- None"])
- markdown = "\n".join(lines).rstrip() + "\n"
- context_path = pcfg.context_path / "RESUME_PACKET.md"
- if write_files:
- write_text_atomic(context_path, markdown)
- if session_id:
- write_text_atomic(pcfg.sessions_path / session_id / "resume_packet.md", markdown)
- self._record_token_usage_metric(
- operation="generate_resume_packet",
- project_path=inferred_path,
- estimated_output_tokens=self._estimated_tokens(markdown),
- compact_output_tokens=self._estimated_tokens(markdown),
- compact_chars=len(markdown),
- metadata={
- "session_id": session_id,
- "task_id": (task or {}).get("id"),
- "write_files": write_files,
- },
- )
- return {"project_slug": pcfg.project_slug, "project_path": str(pcfg.project_path), "path": str(context_path), "markdown": markdown}
-
- def generate_emergency_handoff(
- self,
- session_id: str | None = None,
- task_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- inferred_path = project_path or self._find_project_path_for_session(session_id) or self._find_project_path_for_task(task_id)
- pcfg = self._project_config_for(inferred_path)
- store = self._store(inferred_path)
- session = store.get_session(session_id) if session_id else None
- task = store.get_task(task_id) if task_id else store.get_current_task()
- recent_work = store.get_recent_work(limit=6)
- blockers = store.get_blockers(open_only=True, limit=5)
- relevant_files = store.get_relevant_files(task_id=task["id"] if task else task_id)
- summary = recent_work[0]["message"] if recent_work else "Session ended before a clean handoff could be written."
- next_steps = "Resume from the current task, review the recent work and relevant files, then continue implementation."
- open_questions = "; ".join(item["title"] for item in blockers[:3]) or "No explicit blockers recorded."
- note = f"Recovered from {'session ' + session_id if session_id else 'an interrupted session'} in project {pcfg.project_slug}."
- markdown = "\n".join(
- [
- "# Emergency Handoff",
- "",
- f"- Project: {pcfg.project_name}",
- f"- Session: {session_id or 'unknown'}",
- f"- Task: {(task or {}).get('id', 'none')}",
- "",
- "## Summary",
- "",
- summary,
- "",
- "## Next Steps",
- "",
- next_steps,
- "",
- "## Open Questions",
- "",
- open_questions,
- "",
- "## Relevant Files",
- "",
- *([f"- {item}" for item in relevant_files] or ["- None"]),
- "",
- "## Recovery Note",
- "",
- note,
- "",
- ]
- )
- path = pcfg.context_path / "HANDOFF_EMERGENCY.md"
- write_text_atomic(path, markdown)
- if session_id:
- write_text_atomic(pcfg.sessions_path / session_id / "handoff_emergency.md", markdown)
- return {
- "project_slug": pcfg.project_slug,
- "project_path": str(pcfg.project_path),
- "session_id": session_id,
- "task_id": (task or {}).get("id"),
- "summary": summary,
- "next_steps": next_steps,
- "open_questions": open_questions,
- "relevant_files": relevant_files,
- "note": note,
- "path": str(path),
- "markdown": markdown,
- }
-
- def recover_session(
- self,
- session_id: str | None = None,
- actor: str = "recovery",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- inferred_path = project_path or self._find_project_path_for_session(session_id)
- store = self._store(inferred_path)
- if not session_id:
- sessions = store.get_active_sessions(limit=1)
- if not sessions:
- raise ValueError("No open sessions available to recover.")
- session_id = sessions[0]["id"]
- emergency = self.generate_emergency_handoff(session_id=session_id, project_path=inferred_path)
- handoff = self.create_handoff(
- summary=emergency["summary"],
- next_steps=emergency["next_steps"],
- open_questions=emergency["open_questions"],
- note=emergency["note"],
- task_id=emergency["task_id"],
- from_actor=actor,
- to_actor="next-agent",
- project_path=inferred_path,
- )
- resume = self.generate_resume_packet(session_id=session_id, project_path=inferred_path)
- return {"recovered": True, "session_id": session_id, "handoff": handoff, "resume_packet": resume, "emergency_handoff": emergency}
-
- def generate_cross_tool_handoff(
- self,
- handoff_id: int | None = None,
- session_id: str | None = None,
- target_tool: str = "claude-code",
- target_env: str = "default",
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """Generate a structured cross-tool handoff for a specific target environment.
-
- Produces a compact JSON payload with all context needed for another tool or
- agent to resume work — including task state, relevant files, recent decisions,
- blockers, and session lineage chain.
- """
- store = self._store(project_path)
- task = None
- relevant_files: list[str] = []
- decisions: list[dict[str, Any]] = []
- blockers: list[dict[str, Any]] = []
- recent_work: list[dict[str, Any]] = []
- lineage_chain: list[dict[str, Any]] = []
-
- if session_id:
- lineage = store.get_session_lineage_chain(session_id)
- lineage_chain = lineage
-
- if handoff_id is not None:
- handoff = store.get_handoff(handoff_id)
- if handoff and handoff.get("task_id"):
- task = store.get_task(handoff["task_id"])
- relevant_files = store.get_relevant_files(handoff["task_id"])
- decisions = store.get_decisions(limit=8)
- blockers = store.get_blockers(open_only=True, limit=5)
- recent_work = store.get_recent_work(limit=6)
- elif session_id:
- session = store.get_session(session_id)
- if session and session.get("task_id"):
- task = store.get_task(session["task_id"])
- relevant_files = store.get_relevant_files(session["task_id"])
- decisions = store.get_decisions(limit=8)
- blockers = store.get_blockers(open_only=True, limit=5)
- recent_work = store.get_recent_work(limit=6)
- else:
- task_dict = store.get_current_task()
- if task_dict:
- task = task_dict
- relevant_files = store.get_relevant_files(task_dict["id"])
- decisions = store.get_decisions(limit=8)
- blockers = store.get_blockers(open_only=True, limit=5)
- recent_work = store.get_relevant_files(task_dict["id"] if task_dict else None) if task_dict else []
-
- env_info = store.get_session_env_info(session_id) if session_id else None
- structured_payload = {
- "handoff_version": "1.0",
- "target_tool": target_tool,
- "target_env": target_env,
- "generated_at": utc_now(),
- "task": {
- "id": (task or {}).get("id"),
- "title": (task or {}).get("title"),
- "status": (task or {}).get("status"),
- "priority": (task or {}).get("priority"),
- "description": (task or {}).get("description"),
- } if task else None,
- "relevant_files": relevant_files[:12],
- "recent_decisions": [{"id": d["id"], "title": d["title"], "decision": d["decision"][:200]} for d in decisions],
- "open_blockers": [{"id": b["id"], "title": b["title"]} for b in blockers],
- "recent_work": [{"message": w["message"], "actor": w["actor"], "created_at": w["created_at"]} for w in recent_work],
- "session_lineage": [
- {"session_id": s["session_id"], "actor": s["actor"], "depth": s["lineage_depth"]}
- for s in lineage_chain
- ],
- "ide_env": {
- "ide_name": (env_info or {}).get("ide_name"),
- "ide_version": (env_info or {}).get("ide_version"),
- "ide_platform": (env_info or {}).get("ide_platform"),
- "os_name": (env_info or {}).get("os_name"),
- "os_version": (env_info or {}).get("os_version"),
- } if env_info else None,
- }
-
- handoff_record = store.get_latest_handoff() if handoff_id is None else None
- effective_handoff_id = handoff_id or (handoff_record["id"] if handoff_record else 0)
-
- if effective_handoff_id > 0:
- cross = store.create_cross_tool_handoff(
- handoff_id=effective_handoff_id,
- target_tool=target_tool,
- target_env=target_env,
- structured_payload=structured_payload,
- )
- self.sync_all(project_path)
- return {"cross_tool_handoff": cross, "structured_payload": structured_payload}
-
- return {"structured_payload": structured_payload, "cross_tool_handoff": None}
-
- def get_session_lineage_chain(
- self,
- session_id: str,
- max_depth: int = 10,
- project_path: str | None = None,
- ) -> list[dict[str, Any]]:
- """Get the full lineage chain from session_id to root ancestor."""
- return self._store(project_path).get_session_lineage_chain(session_id, max_depth=max_depth)
-
- def set_session_environment(
- self,
- session_id: str,
- ide_name: str = "",
- ide_version: str = "",
- ide_platform: str = "",
- os_name: str = "",
- os_version: str = "",
- env_variables: dict[str, str] | None = None,
- startup_context: dict[str, Any] | None = None,
- parent_session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- """Attach IDE/environment metadata to a session and optionally set its parent lineage."""
- store = self._store(project_path)
- result = store.upsert_session_env_info(
- session_id=session_id,
- ide_name=ide_name,
- ide_version=ide_version,
- ide_platform=ide_platform,
- os_name=os_name,
- os_version=os_version,
- env_variables=env_variables,
- startup_context=startup_context,
- )
- if parent_session_id:
- parent_lineage = store.get_session_lineage(parent_session_id)
- depth = (parent_lineage.get("lineage_depth", 0) + 1) if parent_lineage else 1
- store.upsert_session_lineage(
- session_id=session_id,
- parent_session_id=parent_session_id,
- lineage_depth=depth,
- )
- self.sync_all(project_path)
- return result or {}
-
- def _detect_project_type(self, project_path: Path) -> list[str]:
- """Detect project types from file patterns in the root directory."""
- types: list[str] = []
- root_files = set(f.name for f in project_path.iterdir() if f.is_file())
- root_names = set(p.name for p in project_path.iterdir())
-
- if "pyproject.toml" in root_files or "setup.py" in root_files or "setup.cfg" in root_files:
- types.append("python")
- if any(f.name == "package.json" for f in project_path.iterdir() if f.is_file()):
- types.append("javascript")
- if any(f.suffix == ".ts" and f.name == "package.json" for f in project_path.iterdir() if f.is_file()):
- types.append("typescript")
- if "Cargo.toml" in root_files:
- types.append("rust")
- if "go.mod" in root_files:
- types.append("go")
- if "pom.xml" in root_files or "build.gradle" in root_files:
- types.append("java")
- if ".NETFramework" in root_names or any(f.name.endswith(".csproj") for f in project_path.iterdir() if f.is_file()):
- types.append("csharp")
- if "Makefile" in root_files and not types:
- types.append("c-cpp")
- if any(f.suffix in {".c", ".h"} for f in project_path.iterdir() if f.is_file()):
- types.append("c-cpp")
- if "requirements.txt" in root_files:
- types.append("python")
- if "package-lock.json" in root_files or "yarn.lock" in root_files:
- types.append("javascript")
- if "Cargo.lock" in root_files:
- types.append("rust")
- if ".gitmodules" in root_files:
- types.append("git-submodule")
- if ".git" in root_names:
- types.append("git")
- return types
-
- def _detect_workspace_type(self, project_path: Path) -> str:
- """Detect workspace/mono-repo type."""
- root_names = [p.name for p in project_path.iterdir() if p.is_dir()]
- if "packages" in root_names or "apps" in root_names or "services" in root_names:
- return "mono-repo"
- if "src" in root_names and "tests" in root_names:
- return "standard"
- if "libs" in root_names or "shared" in root_names:
- return "library-mono"
- return "single-repo"
-
- def _scan_nearby_projects(self, project_path: Path, max_distance: int = 3) -> list[dict[str, Any]]:
- """Scan nearby directories for other projects at shallow depth."""
- candidates: list[dict[str, Any]] = []
- parent = project_path.parent.resolve()
- try:
- entries = list(parent.iterdir())
- except OSError:
- return candidates
- for entry in entries[:50]:
- if not entry.is_dir():
- continue
- if entry == project_path.resolve():
- continue
- depth = len(entry.parts) - len(parent.parts)
- if depth > max_distance:
- continue
- bridge = entry / ".obsmcp-link.json"
- if bridge.exists():
- continue
- git_root = entry / ".git"
- if git_root.exists():
- try:
- rel = entry.relative_to(parent)
- except ValueError:
- continue
- project_types = self._detect_project_type(entry)
- workspace_type = self._detect_workspace_type(entry)
- candidates.append({
- "path": str(entry),
- "relative_path": str(rel),
- "depth": depth,
- "project_types": project_types,
- "workspace_type": workspace_type,
- })
- return sorted(candidates, key=lambda c: c["depth"])
-
- def _build_project_resolution_payload(
- self,
- inferred_path: str,
- *,
- already_registered: bool,
- scan_nearby: bool = False,
- resolution_source: str | None = None,
- matched_hint: str | None = None,
- ide_name: str | None = None,
- ) -> dict[str, Any]:
- pcfg = self._project_config_for(inferred_path)
- nearby = self._scan_nearby_projects(pcfg.project_path) if scan_nearby else []
- payload = {
- "resolved": True,
- "already_registered": already_registered,
- "project_slug": pcfg.project_slug,
- "project_path": str(pcfg.project_path),
- "workspace_root": str(pcfg.workspace_root),
- "project_types": self._detect_project_type(pcfg.project_path),
- "workspace_type": self._detect_workspace_type(pcfg.project_path),
- "nearby_projects": nearby[:5],
- }
- if resolution_source:
- payload["resolution_source"] = resolution_source
- if matched_hint:
- payload["matched_hint"] = matched_hint
- if ide_name:
- payload["ide_name"] = ide_name
- return payload
-
- def _resolve_project_from_ide_metadata(
- self,
- *,
- project_path: str | None = None,
- project_slug: str | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- repo_path: str | None = None,
- cwd: str | None = None,
- workspace_path: str | None = None,
- workspace_folders: list[str] | None = None,
- active_file: str | None = None,
- open_files: list[str] | None = None,
- env_variables: dict[str, str] | None = None,
- ) -> tuple[str | None, str | None, str | None]:
- if project_slug:
- record = self.registry.get_by_slug(project_slug)
- if record:
- return record["repo_path"], "project_slug", project_slug
- if project_path:
- return project_path, "project_path", project_path
- if session_id:
- inferred = self._find_project_path_for_session(session_id)
- if inferred:
- return inferred, "session_id", session_id
- if task_id:
- inferred = self._find_project_path_for_task(task_id)
- if inferred:
- return inferred, "task_id", task_id
-
- env_variables = env_variables or {}
- path_candidates: list[tuple[str, str]] = []
-
- for label, value in [
- ("repo_path", repo_path),
- ("workspace_path", workspace_path),
- ("cwd", cwd),
- ]:
- if isinstance(value, str) and value.strip():
- path_candidates.append((label, value))
-
- for idx, value in enumerate(workspace_folders or []):
- if isinstance(value, str) and value.strip():
- path_candidates.append((f"workspace_folders[{idx}]", value))
-
- if isinstance(active_file, str) and active_file.strip():
- path_candidates.append(("active_file", active_file))
-
- for idx, value in enumerate(open_files or []):
- if isinstance(value, str) and value.strip():
- path_candidates.append((f"open_files[{idx}]", value))
-
- for key in (
- "OBSMCP_PROJECT",
- "PROJECT_PATH",
- "PROJECT_ROOT",
- "REPO_PATH",
- "REPO_ROOT",
- "WORKSPACE_PATH",
- "WORKSPACE_ROOT",
- "WORKSPACE_FOLDER",
- "PWD",
- "INIT_CWD",
- ):
- value = env_variables.get(key)
- if isinstance(value, str) and value.strip():
- path_candidates.append((f"env:{key}", value))
-
- for source, hint in path_candidates:
- inferred = self._registered_project_for_path_hint(hint)
- if inferred:
- return inferred, source, hint
-
- return None, None, None
-
- def get_or_create_project(
- self,
- project_path: str | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- auto_register: bool = True,
- project_name: str | None = None,
- tags: list[str] | None = None,
- scan_nearby: bool = False,
- ) -> dict[str, Any]:
- """Auto-detect or create a project from a path hint, session, task, or environment.
-
- This method resolves a project path from multiple sources (explicit, session,
- task, env, cwd, file paths) and optionally registers it if not already known.
- It also returns nearby detected projects and project type metadata.
- """
- inferred = self._infer_project_path({"session_id": session_id, "task_id": task_id, "path": project_path})
- if inferred:
- provisional = self.config.get_project_config(inferred)
- if self.registry.get_by_slug(provisional.project_slug):
- return self._build_project_resolution_payload(inferred, already_registered=True, scan_nearby=scan_nearby)
-
- if not auto_register:
- return {"resolved": False, "project_path": None}
-
- if inferred:
- registered = self.register_project(repo_path=inferred, name=project_name, tags=tags)
- payload = self._build_project_resolution_payload(inferred, already_registered=False, scan_nearby=scan_nearby)
- payload["project_slug"] = registered.get("slug", payload["project_slug"])
- return payload
-
- return {"resolved": False, "project_path": None}
-
- def resolve_active_project(
- self,
- project_path: str | None = None,
- project_slug: str | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- repo_path: str | None = None,
- cwd: str | None = None,
- workspace_path: str | None = None,
- workspace_folders: list[str] | None = None,
- active_file: str | None = None,
- open_files: list[str] | None = None,
- env_variables: dict[str, str] | None = None,
- auto_register: bool = True,
- project_name: str | None = None,
- tags: list[str] | None = None,
- scan_nearby: bool = False,
- ide_name: str = "",
- ) -> dict[str, Any]:
- """Resolve the active project from IDE metadata before the first continuity write."""
- inferred, source, matched_hint = self._resolve_project_from_ide_metadata(
- project_path=project_path,
- project_slug=project_slug,
- session_id=session_id,
- task_id=task_id,
- repo_path=repo_path,
- cwd=cwd,
- workspace_path=workspace_path,
- workspace_folders=workspace_folders,
- active_file=active_file,
- open_files=open_files,
- env_variables=env_variables,
- )
- if not inferred:
- return {
- "resolved": False,
- "already_registered": False,
- "project_slug": None,
- "project_path": None,
- "workspace_root": None,
- "resolution_source": None,
- "matched_hint": None,
- "ide_name": ide_name,
- "requires_registration": False,
- "reason": "No project hint could be resolved from IDE metadata.",
- "recommended_action": "Pass project_path, cwd, active_file, workspace_folders, repo_path, session_id, or task_id.",
- }
-
- provisional = self.config.get_project_config(inferred)
- registered = self.registry.get_by_slug(provisional.project_slug)
- if registered:
- return self._build_project_resolution_payload(
- inferred,
- already_registered=True,
- scan_nearby=scan_nearby,
- resolution_source=source,
- matched_hint=matched_hint,
- ide_name=ide_name,
- )
-
- if not auto_register:
- return {
- "resolved": False,
- "already_registered": False,
- "project_slug": provisional.project_slug,
- "project_path": str(provisional.project_path),
- "workspace_root": str(provisional.workspace_root),
- "resolution_source": source,
- "matched_hint": matched_hint,
- "ide_name": ide_name,
- "requires_registration": True,
- "reason": "Project was inferred from IDE metadata but is not registered yet.",
- "recommended_action": "Retry with auto_register=true or call register_project with the inferred project_path.",
- }
-
- registered_record = self.register_project(repo_path=inferred, name=project_name, tags=tags)
- payload = self._build_project_resolution_payload(
- inferred,
- already_registered=False,
- scan_nearby=scan_nearby,
- resolution_source=source,
- matched_hint=matched_hint,
- ide_name=ide_name,
- )
- payload["project_slug"] = registered_record.get("slug", payload["project_slug"])
- return payload
-
- def generate_startup_prompt_template(self, first_contact: bool = True, project_path: str | None = None) -> str:
- prompt_path = self.config.root_dir / "master prompt.md"
- policy = self._resolve_output_policy(
- operation_kind="general",
- project_path=project_path,
- )
- if prompt_path.exists():
- base = prompt_path.read_text(encoding="utf-8").rstrip()
- appendix = [
- "",
- "## Startup Hints",
- "",
- "- Prefer `generate_startup_context()` or `get_fast_path_response(kind=\"startup_context\")` for low-latency startup.",
- "- Prefer `get_recent_commands`, `get_last_command_result`, and `get_command_failures` over replaying raw terminal output.",
- "- Use `get_command_execution_policy` before batching or reviewing terminal commands.",
- ]
- if policy.prompt_contract:
- appendix.extend(["", policy.prompt_contract.strip()])
- rendered = base + "\n" + "\n".join(appendix) + "\n"
- self._record_output_policy_metric(
- operation="generate_startup_prompt_template",
- policy=policy,
- rendered_text=rendered,
- project_path=project_path,
- )
- return rendered
-
- lines = [
- "# Master Prompt",
- "",
- "Use obsmcp as the primary continuity system.",
- ]
- if first_contact:
- lines.append("This is a first-contact startup prompt.")
- lines.extend(
- [
- "",
- "Startup hints:",
- "- Prefer generate_startup_context for startup/resume.",
- "- Prefer recent command summaries over raw terminal replay.",
- ]
- )
- if policy.prompt_contract:
- lines.extend(["", policy.prompt_contract.strip()])
- lines.append("")
- rendered = "\n".join(lines)
- self._record_output_policy_metric(
- operation="generate_startup_prompt_template",
- policy=policy,
- rendered_text=rendered,
- project_path=project_path,
- )
- return rendered
-
- # ---------------------------------------------------------------------------
- # Code Atlas
- # ---------------------------------------------------------------------------
-
- def scan_codebase(
- self,
- project_path: str | None = None,
- force_refresh: bool = False,
- background: bool = False,
- requested_by: str = "unknown",
- ) -> dict[str, Any]:
- """Scan the entire project and generate (or refresh) the Code Atlas.
-
- Args:
- project_path: Project root to scan (defaults to the active project).
- force_refresh: If True, always regenerate. If False, only regenerate
- if the atlas is missing or older than any source file.
- background: If True, queue the scan and return a pollable job instead of blocking.
- """
- with span("code_atlas.scan", project_path=project_path, force_refresh=force_refresh, background=background):
- if not self._atlas_needs_refresh(project_path, force_refresh=force_refresh):
- return self._current_atlas_metadata(project_path)
- if not background:
- return self._scan_codebase_sync(project_path=project_path, force_refresh=force_refresh)
- job = self.start_scan_job(project_path=project_path, force_refresh=force_refresh, requested_by=requested_by)
- job["scan_required"] = True
- job["current_atlas"] = self._current_atlas_metadata(project_path) if (self._project_config_for(project_path).vault_path / "Research" / "Code Atlas.md").exists() else None
- return job
-
- def get_code_atlas_status(self, project_path: str | None = None) -> dict[str, Any]:
- """Return current atlas status without regenerating."""
- pcfg = self._project_config_for(project_path)
- atlas_path = pcfg.vault_path / "Research" / "Code Atlas.md"
- active_job = self._store(project_path).get_active_scan_job(job_type="code_atlas")
- if not atlas_path.exists():
- return {
- "exists": False,
- "message": "Code Atlas has not been generated yet.",
- "hint": "Call scan_codebase() to generate it.",
- "active_job": active_job,
- }
-
- cached = read_json_with_retry(pcfg.json_export_dir / "code_atlas.json", {})
- if cached:
- semantic_stats = self._store(project_path).get_symbol_index_stats()
- mtime = datetime.fromtimestamp(atlas_path.stat().st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
- return {
- "exists": True,
- "path": str(atlas_path),
- "last_generated": mtime,
- "total_files": cached.get("total_files", 0),
- "total_lines": cached.get("total_lines", 0),
- "languages": cached.get("languages", {}),
- "semantic_index": semantic_stats,
- "hint": "Call scan_codebase(force_refresh=True) to regenerate.",
- "active_job": active_job,
- }
- atlas = self._build_code_atlas(project_path)
- result = atlas.scan()
- _, _, _, semantic_stats = self._refresh_semantic_index(project_path=str(pcfg.project_path), atlas_result=result)
- mtime = datetime.fromtimestamp(atlas_path.stat().st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
-
- return {
- "exists": True,
- "path": str(atlas_path),
- "last_generated": mtime,
- "total_files": result.total_files,
- "total_lines": result.total_lines,
- "languages": result.languages,
- "semantic_index": semantic_stats,
- "hint": "Call scan_codebase(force_refresh=True) to regenerate.",
- "active_job": active_job,
- }
-
- # ---------------------------------------------------------------------------
- # Semantic Knowledge
- # ---------------------------------------------------------------------------
-
- def describe_module(self, module_path: str, project_path: str | None = None, force_llm: bool = False) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- entity = index.get_module(module_path)
- if not entity:
- raise ValueError(f"Module '{module_path}' was not found in project {pcfg.project_slug}.")
- return self._describe_entity(entity.to_index_row(), index, project_path=str(pcfg.project_path), force_llm=force_llm)
-
- def describe_symbol(
- self,
- symbol_name: str | None = None,
- module_path: str | None = None,
- entity_key: str | None = None,
- entity_type: str | None = None,
- project_path: str | None = None,
- force_llm: bool = False,
- ) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- if entity_key:
- entity = index.entity_map.get(entity_key)
- if not entity:
- raise ValueError(f"Entity '{entity_key}' was not found in project {pcfg.project_slug}.")
- return self._describe_entity(entity.to_index_row(), index, project_path=str(pcfg.project_path), force_llm=force_llm)
-
- if not symbol_name:
- raise ValueError("describe_symbol requires symbol_name or entity_key.")
- allowed_types = [entity_type] if entity_type in {"function", "class"} else None
- candidates = index.get_symbol_candidates(symbol_name, module_path=module_path, entity_types=allowed_types)
- if not candidates:
- return {
- "status": "not_found",
- "message": f"No symbol named '{symbol_name}' was found.",
- "query": {"symbol_name": symbol_name, "module_path": module_path, "entity_type": entity_type},
- }
- if len(candidates) > 1:
- return {
- "status": "ambiguous",
- "message": f"Multiple symbols named '{symbol_name}' were found.",
- "candidates": [item.to_index_row() for item in candidates],
- }
- return self._describe_entity(candidates[0].to_index_row(), index, project_path=str(pcfg.project_path), force_llm=force_llm)
-
- def describe_feature(self, feature_name: str, project_path: str | None = None, force_llm: bool = False) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- entity = index.get_feature(feature_name)
- if not entity:
- normalized = feature_name.lower()
- module_matches = [
- item
- for item in index.entities
- if item.entity_type == "module"
- and (
- normalized in [tag.lower() for tag in item.feature_tags]
- or item.metadata.get("language", "").lower() == normalized
- )
- ]
- if not module_matches:
- return {"status": "not_found", "message": f"No feature named '{feature_name}' was found."}
- source_files = sorted({item.file_path for item in module_matches})
- entity = type(module_matches[0])(
- entity_key=f"feature:{feature_name.lower()}",
- entity_type="feature",
- name=feature_name,
- file_path=source_files[0],
- symbol_path=feature_name,
- signature="",
- line_number=1,
- feature_tags=[feature_name],
- source_files=source_files,
- source_fingerprint="|".join(item.source_fingerprint for item in module_matches),
- summary_hint=f"Feature `{feature_name}` inferred from {len(source_files)} module(s).",
- metadata={"files": source_files, "language_count": len({item.metadata.get('language', '') for item in module_matches})},
- )
- index.entity_map[entity.entity_key] = entity
- return self._describe_entity(entity.to_index_row(), index, project_path=str(pcfg.project_path), force_llm=force_llm)
-
- def search_code_knowledge(self, query: str, limit: int = 10, project_path: str | None = None) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- matches = index.search(query, limit=limit)
- store = self._store(project_path)
- results = []
- for entity in matches:
- cached = store.get_semantic_description(entity.entity_key)
- results.append(
- {
- "entity_key": entity.entity_key,
- "entity_type": entity.entity_type,
- "name": entity.name,
- "file_path": entity.file_path,
- "symbol_path": entity.symbol_path,
- "summary_hint": (cached or {}).get("purpose") or entity.summary_hint,
- "freshness": (cached or {}).get("freshness", "unverified"),
- "feature_tags": entity.feature_tags,
- }
- )
- return {"query": query, "project_slug": pcfg.project_slug, "match_count": len(results), "results": results}
-
- def get_symbol_candidates(
- self,
- symbol_name: str,
- module_path: str | None = None,
- entity_type: str | None = None,
- limit: int = 20,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- allowed_types = [entity_type] if entity_type in {"function", "class"} else None
- candidates = index.get_symbol_candidates(symbol_name, module_path=module_path, entity_types=allowed_types)[:limit]
- return {"symbol_name": symbol_name, "project_slug": pcfg.project_slug, "candidates": [item.to_index_row() for item in candidates]}
-
- def get_related_symbols(self, entity_key: str, limit: int = 8, project_path: str | None = None) -> dict[str, Any]:
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- entity = index.entity_map.get(entity_key)
- if not entity:
- raise ValueError(f"Entity '{entity_key}' was not found in project {pcfg.project_slug}.")
- return {"entity_key": entity_key, "related_symbols": [item.to_index_row() for item in index.related_symbols(entity, limit=limit)]}
-
- def invalidate_semantic_cache(
- self,
- entity_key: str | None = None,
- file_paths: list[str] | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).invalidate_semantic_cache(entity_key=entity_key, file_paths=file_paths)
- self.sync_all(project_path)
- return result
-
- def refresh_semantic_description(
- self,
- entity_key: str | None = None,
- module_path: str | None = None,
- symbol_name: str | None = None,
- feature_name: str | None = None,
- entity_type: str | None = None,
- force_llm: bool = False,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- if entity_key:
- self.invalidate_semantic_cache(entity_key=entity_key, project_path=project_path)
- return self.describe_symbol(entity_key=entity_key, project_path=project_path, force_llm=force_llm)
- if module_path and not symbol_name and not feature_name:
- self.invalidate_semantic_cache(file_paths=[module_path], project_path=project_path)
- pcfg, _, index, _ = self._refresh_semantic_index(project_path)
- entity = index.get_module(module_path)
- if not entity:
- raise ValueError(f"Module '{module_path}' was not found in project {pcfg.project_slug}.")
- return self._describe_entity(
- entity.to_index_row(),
- index,
- project_path=str(pcfg.project_path),
- force_llm=force_llm,
- force_refresh=True,
- )
- if symbol_name:
- return self.describe_symbol(symbol_name=symbol_name, module_path=module_path, entity_type=entity_type, project_path=project_path, force_llm=force_llm)
- if feature_name:
- return self.describe_feature(feature_name=feature_name, project_path=project_path, force_llm=force_llm)
- raise ValueError("refresh_semantic_description requires entity_key, module_path, symbol_name, or feature_name.")
-
- # =========================================================================
- # Phase 1: Task Templates (service layer)
- # =========================================================================
-
- def create_task_from_template(
- self,
- template_name: str,
- variables: dict[str, str] | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).create_task_from_template(
- template_name=template_name,
- variables=variables,
- actor=actor,
- session_id=session_id,
- )
- self.sync_all(project_path)
- return result
-
- def get_task_templates(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).get_task_templates()
-
- def get_task_template(self, name: str, project_path: str | None = None) -> dict[str, Any] | None:
- return self._store(project_path).get_task_template(name)
-
- def create_task_template(
- self,
- name: str,
- title_template: str,
- description_template: str,
- priority: str = "medium",
- tags: list[str] | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).create_task_template(
- name=name,
- title_template=title_template,
- description_template=description_template,
- priority=priority,
- tags=tags,
- )
- self.sync_all(project_path)
- return result
-
- def delete_task_template(self, name: str, project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).delete_task_template(name)
- self.sync_all(project_path)
- return result
-
- # =========================================================================
- # Phase 1: Quick Log
- # =========================================================================
-
- def quick_log(
- self,
- message: str,
- files: list[str] | None = None,
- actor: str = "quick-log",
- session_id: str | None = None,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- result = self._store(project_path).quick_log(message=message, files=files, actor=actor, session_id=session_id)
- self.sync_all(project_path)
- return result
-
- # =========================================================================
- # Phase 1: Audit Log
- # =========================================================================
-
- def get_audit_log(
- self,
- actor: str | None = None,
- task_id: str | None = None,
- action_type: str | None = None,
- from_date: str | None = None,
- to_date: str | None = None,
- limit: int = 100,
- include_ai_only: bool = False,
- project_path: str | None = None,
- ) -> dict[str, Any]:
- return self._store(project_path).get_audit_log(
- actor=actor,
- task_id=task_id,
- action_type=action_type,
- from_date=from_date,
- to_date=to_date,
- limit=limit,
- include_ai_only=include_ai_only,
- )
-
- # =========================================================================
- # Phase 2: Reset Project
- # =========================================================================
-
- def reset_project(self, scope: str, actor: str = "unknown", project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).reset_project(scope=scope, actor=actor)
- self.sync_all(project_path)
- result["post_reset_snapshot"] = self.get_project_status_snapshot(project_path=project_path)
- return result
-
- # =========================================================================
- # Phase 2: Bulk Task Operations
- # =========================================================================
-
- def bulk_task_ops(self, operations: list[dict[str, Any]], actor: str = "unknown", project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).bulk_task_ops(operations=operations, actor=actor)
- if result["failed"] == 0:
- self.sync_all(project_path)
- return result
-
- # =========================================================================
- # Phase 2: Project Export
- # =========================================================================
-
- def export_project(self, format: str = "json", project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).export_project(format=format)
-
- # =========================================================================
- # Phase 3: Work Log Expiry
- # =========================================================================
-
- def configure_log_expiry(self, days: int, actor: str = "unknown", project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).configure_log_expiry(days=days, actor=actor)
-
- def expire_old_logs(self, actor: str = "unknown", project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).expire_old_logs(actor=actor)
- self.sync_all(project_path)
- return result
-
- def get_log_stats(self, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).get_log_stats()
-
- # =========================================================================
- # Phase 3: Session Replay
- # =========================================================================
-
- def session_replay(self, session_id: str | None = None, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).session_replay(session_id=session_id)
-
- # =========================================================================
- # Phase 3: Task Dependencies
- # =========================================================================
-
- def add_task_dependency(self, task_id: str, blocked_by: list[str] | None = None, blocks: list[str] | None = None, project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).add_task_dependency(task_id=task_id, blocked_by=blocked_by, blocks=blocks)
- self.sync_all(project_path)
- return result
-
- def remove_task_dependency(self, task_id: str, blocked_by: list[str] | None = None, blocks: list[str] | None = None, project_path: str | None = None) -> dict[str, Any]:
- result = self._store(project_path).remove_task_dependency(task_id=task_id, blocked_by=blocked_by, blocks=blocks)
- self.sync_all(project_path)
- return result
-
- def get_task_dependency(self, task_id: str, project_path: str | None = None) -> dict[str, Any] | None:
- return self._store(project_path).get_task_dependency(task_id=task_id)
-
- def get_all_dependencies(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).get_all_dependencies()
-
- def get_blocked_tasks(self, project_path: str | None = None) -> list[dict[str, Any]]:
- return self._store(project_path).get_blocked_tasks()
-
- def validate_dependencies(self, project_path: str | None = None) -> dict[str, Any]:
- return self._store(project_path).validate_dependencies()
-
- def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
- log = get_logger("obsmcp")
- log.info("tool_started", tool_name=name, has_args=bool(arguments))
- arguments = dict(arguments) if arguments else {} # copy to avoid mutating
- inferred_project_path = self._infer_project_path(arguments)
- if inferred_project_path:
- arguments["project_path"] = inferred_project_path
- elif self.config.strict_project_routing and self._tool_requires_project_context(name):
- raise self._missing_project_context_error(name)
- with span("tool.dispatch", tool_name=name, project_path=inferred_project_path):
- if name == "scan_codebase" and "background" not in arguments:
- arguments["background"] = True
- if name not in {"resolve_project", "get_project_workspace_paths"}:
- arguments.pop("project_slug", None)
- handlers: dict[str, Callable[..., Any]] = {
- "register_project": self.register_project,
- "list_projects": self.list_projects,
- "resolve_project": self.resolve_project,
- "resolve_active_project": self.resolve_active_project,
- "get_project_workspace_paths": self.get_project_workspace_paths,
- "attach_repo_bridge": self.attach_repo_bridge,
- "migrate_project_layout": self.migrate_project_layout,
- "get_project_brief": self.get_project_brief,
- "get_current_task": self.get_current_task,
- "get_active_tasks": self.get_active_tasks,
- "get_latest_handoff": self.get_latest_handoff,
- "get_recent_work": self.get_recent_work,
- "get_decisions": self.get_decisions,
- "get_blockers": self.get_blockers,
- "get_relevant_files": self.get_relevant_files,
- "get_table_schema": self.get_table_schema,
- "search_notes": self.search_notes,
- "read_note": self.read_note,
- "get_project_status_snapshot": self.get_project_status_snapshot,
- "log_work": self.log_work,
- "log_checkpoint": self.log_checkpoint,
- "update_task": self.update_task,
- "create_task": self.create_task,
- "get_task_progress": self.get_task_progress,
- "log_decision": self.log_decision,
- "log_blocker": self.log_blocker,
- "resolve_blocker": self.resolve_blocker,
- "create_handoff": self.create_handoff,
- "append_handoff_note": self.append_handoff_note,
- "update_project_brief_section": self.update_project_brief_section,
- "create_daily_note_entry": self.create_daily_note_entry,
- "sync_context_files": self.sync_context,
- "session_open": self.session_open,
- "session_heartbeat": self.session_heartbeat,
- "session_close": self.session_close,
- "get_active_sessions": self.get_active_sessions,
- "detect_missing_writeback": self.detect_missing_writeback,
- "get_startup_preflight": self.get_startup_preflight,
- "get_resume_board": self.get_resume_board,
- "generate_resume_packet": self.generate_resume_packet,
- "generate_emergency_handoff": self.generate_emergency_handoff,
- "recover_session": self.recover_session,
- "sync_hub": self.sync_hub,
- "health_check": self.health_check,
- "get_server_capabilities": self.get_server_capabilities,
- "check_client_compatibility": self.check_client_compatibility,
- "list_tools": self.list_tool_definitions,
- "list_resources": self.list_resource_definitions,
- "generate_compact_context": self.generate_compact_context,
- "generate_compact_context_v2": self.generate_compact_context_v2,
- "generate_context_profile": self.generate_context_profile,
- "generate_delta_context": self.generate_delta_context,
- "generate_prompt_segments": self.generate_prompt_segments,
- "generate_retrieval_context": self.generate_retrieval_context,
- "generate_task_snapshot": self.generate_task_snapshot,
- "record_token_usage": self.record_token_usage,
- "get_token_usage_stats": self.get_token_usage_stats,
- "record_command_event": self.record_command_event,
- "record_command_batch": self.record_command_batch,
- "get_command_event": self.get_command_event,
- "get_recent_commands": self.get_recent_commands,
- "get_last_command_result": self.get_last_command_result,
- "get_command_failures": self.get_command_failures,
- "get_command_execution_policy": self.get_command_execution_policy,
- "get_output_response_policy": self.get_output_response_policy,
- "compact_tool_output": self.compact_tool_output,
- "compact_response": self.compact_response,
- "get_raw_output_capture": self.get_raw_output_capture,
- "get_fast_path_response": self.get_fast_path_response,
- "get_optimization_policy": self.get_optimization_policy,
- "list_context_chunks": self.list_context_chunks,
- "generate_progressive_context": self.generate_progressive_context,
- "generate_startup_context": self.generate_startup_context,
- "generate_startup_prompt_template": self.generate_startup_prompt_template,
- "set_current_task": self.set_current_task,
- "scan_codebase": self.scan_codebase,
- "get_code_atlas_status": self.get_code_atlas_status,
- "start_scan_job": self.start_scan_job,
- "get_scan_job": self.get_scan_job,
- "list_scan_jobs": self.list_scan_jobs,
- "wait_for_scan_job": self.wait_for_scan_job,
- "describe_module": self.describe_module,
- "describe_symbol": self.describe_symbol,
- "describe_feature": self.describe_feature,
- "web_search": self.web_search,
- "understand_image": self.understand_image,
- "search_code_knowledge": self.search_code_knowledge,
- "get_symbol_candidates": self.get_symbol_candidates,
- "get_related_symbols": self.get_related_symbols,
- "invalidate_semantic_cache": self.invalidate_semantic_cache,
- "refresh_semantic_description": self.refresh_semantic_description,
- # Phase 1: Task Templates
- "get_task_templates": self.get_task_templates,
- "get_task_template": self.get_task_template,
- "create_task_template": self.create_task_template,
- "delete_task_template": self.delete_task_template,
- "create_task_from_template": self.create_task_from_template,
- # Phase 1: Quick Log
- "quick_log": self.quick_log,
- # Phase 1: Audit Log
- "get_audit_log": self.get_audit_log,
- # Phase 2: Reset Project
- "reset_project": self.reset_project,
- # Phase 2: Bulk Task Operations
- "bulk_task_ops": self.bulk_task_ops,
- # Phase 2: Project Export
- "export_project": self.export_project,
- # Phase 3: Work Log Expiry
- "configure_log_expiry": self.configure_log_expiry,
- "expire_old_logs": self.expire_old_logs,
- "get_log_stats": self.get_log_stats,
- # Phase 3: Session Replay
- "session_replay": self.session_replay,
- # Phase 3: Task Dependencies
- "add_task_dependency": self.add_task_dependency,
- "remove_task_dependency": self.remove_task_dependency,
- "get_task_dependency": self.get_task_dependency,
- "get_all_dependencies": self.get_all_dependencies,
- "get_blocked_tasks": self.get_blocked_tasks,
- "validate_dependencies": self.validate_dependencies,
- # Milestone C: Token-saving retrieval
- "generate_fast_context": self.generate_fast_context,
- "retrieve_context_chunk": self.retrieve_context_chunk,
- # Milestone D: Cross-IDE / plugin handoff
- "generate_cross_tool_handoff": self.generate_cross_tool_handoff,
- "get_session_lineage_chain": self.get_session_lineage_chain,
- "set_session_environment": self.set_session_environment,
- # Milestone E: Automatic project resolution
- "get_or_create_project": self.get_or_create_project,
- "detect_project_type": self._detect_project_type,
- "scan_nearby_projects": self._scan_nearby_projects,
- }
- if name not in handlers:
- raise KeyError(f"Unknown tool: {name}")
- try:
- result = handlers[name](**arguments)
- log.info("tool_completed", tool_name=name)
- return result
- except Exception as exc:
- log.error("tool_failed", tool_name=name, error_type=type(exc).__name__, error_msg=str(exc))
- raise
-
-
-TOOL_DEFINITIONS = [
- {"name": "register_project", "description": "Register a repo with obsmcp and create its centralized workspace.", "inputSchema": {"type": "object", "properties": {"repo_path": {"type": "string"}, "name": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}}, "required": ["repo_path"]}},
- {"name": "list_projects", "description": "List registered obsmcp projects.", "inputSchema": {"type": "object", "properties": {}}},
- {"name": "resolve_project", "description": "Resolve a project by slug or repo path.", "inputSchema": {"type": "object", "properties": {"project_slug": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "resolve_active_project", "description": "Resolve the active project from IDE metadata such as cwd, active file, workspace folders, open files, session_id, task_id, repo_path, or environment hints. Use this before the first continuity write from a plugin or IDE client.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string"}, "project_slug": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "repo_path": {"type": "string"}, "cwd": {"type": "string"}, "workspace_path": {"type": "string"}, "workspace_folders": {"type": "array", "items": {"type": "string"}}, "active_file": {"type": "string"}, "open_files": {"type": "array", "items": {"type": "string"}}, "env_variables": {"type": "object", "additionalProperties": {"type": "string"}}, "auto_register": {"type": "boolean", "default": True}, "project_name": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "scan_nearby": {"type": "boolean", "default": False}, "ide_name": {"type": "string"}}, "required": []}},
- {"name": "get_project_workspace_paths", "description": "Return the workspace paths for a project.", "inputSchema": {"type": "object", "properties": {"project_slug": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "attach_repo_bridge", "description": "Write a lightweight bridge file into the repo that points at the centralized obsmcp workspace.", "inputSchema": {"type": "object", "properties": {"project_slug": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "migrate_project_layout", "description": "Copy legacy repo-local .context and obsidian/vault content into the centralized project workspace and attach a repo bridge.", "inputSchema": {"type": "object", "properties": {"project_slug": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "get_project_brief", "description": "Return the current project brief sections.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_current_task", "description": "Return the current task.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_active_tasks", "description": "Return open, in-progress, and blocked tasks.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_latest_handoff", "description": "Return the latest handoff.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_recent_work", "description": "Return recent work logs with cursor-style limit and after_id parameters.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 10, "description": "Maximum number of entries to return (default 10, max 1000)."}, "after_id": {"type": "integer", "description": "Return entries with id less than this value."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_decisions", "description": "Return recent decisions with cursor-style limit and after_id parameters.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 10, "description": "Maximum number of entries to return (default 10, max 1000)."}, "after_id": {"type": "integer", "description": "Return entries with id less than this value."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_blockers", "description": "Return open blockers with cursor-based pagination.", "inputSchema": {"type": "object", "properties": {"open_only": {"type": "boolean", "default": True, "description": "Only return open blockers (default true)."}, "limit": {"type": "integer", "default": 20, "description": "Maximum number of entries to return (default 20, max 1000)."}, "after_id": {"type": "integer", "description": "Return entries with id less than this value. Use next_cursor from the previous response to paginate."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_relevant_files", "description": "Return relevant file paths for a task or the current task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_table_schema", "description": "Return the SQLite schema for a given table.", "inputSchema": {"type": "object", "properties": {"table_name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["table_name"]}},
- {"name": "search_notes", "description": "Search the Obsidian vault for notes.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["query"]}},
- {"name": "read_note", "description": "Read a note from the Obsidian vault.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["path"]}},
- {"name": "get_project_status_snapshot", "description": "Return a compact project status snapshot.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "log_work", "description": "Append a work log entry.", "inputSchema": {"type": "object", "properties": {"message": {"type": "string"}, "summary": {"type": "string"}, "task_id": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["message"]}},
- {"name": "log_checkpoint", "description": "Record a completed checkpoint or subtask for a task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "checkpoint_id": {"type": "string"}, "title": {"type": "string"}, "message": {"type": "string"}, "status": {"type": "string", "default": "completed"}, "files": {"type": "array", "items": {"type": "string"}}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id", "checkpoint_id", "title"]}},
- {"name": "update_task", "description": "Update an existing task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "title": {"type": "string"}, "description": {"type": "string"}, "status": {"type": "string"}, "priority": {"type": "string"}, "owner": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "relevant_files": {"type": "array", "items": {"type": "string"}}, "tags": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "create_task", "description": "Create a task.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "description": {"type": "string"}, "priority": {"type": "string"}, "owner": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "relevant_files": {"type": "array", "items": {"type": "string"}}, "tags": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["title", "description"]}},
- {"name": "get_task_progress", "description": "Return checkpoint progress and recent checkpoints for a task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "log_decision", "description": "Record an ADR-style decision.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "decision": {"type": "string"}, "rationale": {"type": "string"}, "impact": {"type": "string"}, "task_id": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["title", "decision"]}},
- {"name": "log_blocker", "description": "Record a blocker.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string"}, "description": {"type": "string"}, "task_id": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["title", "description"]}},
- {"name": "resolve_blocker", "description": "Resolve an open blocker.", "inputSchema": {"type": "object", "properties": {"blocker_id": {"type": "integer"}, "resolution_note": {"type": "string"}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["blocker_id", "resolution_note"]}},
- {"name": "create_handoff", "description": "Create a model-to-model or user-to-model handoff.", "inputSchema": {"type": "object", "properties": {"summary": {"type": "string"}, "next_steps": {"type": "string"}, "open_questions": {"type": "string"}, "note": {"type": "string"}, "task_id": {"type": "string"}, "from_actor": {"type": "string"}, "to_actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["summary"]}},
- {"name": "append_handoff_note", "description": "Append an additional note to an existing handoff.", "inputSchema": {"type": "object", "properties": {"handoff_id": {"type": "integer"}, "note": {"type": "string"}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["handoff_id", "note"]}},
- {"name": "update_project_brief_section", "description": "Update a named project brief section.", "inputSchema": {"type": "object", "properties": {"section": {"type": "string"}, "content": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["section", "content"]}},
- {"name": "create_daily_note_entry", "description": "Append an entry to the daily note stream.", "inputSchema": {"type": "object", "properties": {"entry": {"type": "string"}, "note_date": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["entry"]}},
- {"name": "sync_context_files", "description": "Force a sync of generated context and Obsidian files.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "session_open", "description": "Open an auditable AI session with heartbeat and write-back policy. By default, obsmcp auto-resumes a recent matching open session for the same actor/client/project unless the mismatch guard blocks it.", "inputSchema": {"type": "object", "properties": {"actor": {"type": "string"}, "client_name": {"type": "string"}, "model_name": {"type": "string"}, "session_label": {"type": "string"}, "workstream_key": {"type": "string"}, "workstream_title": {"type": "string"}, "project_path": {"type": "string"}, "initial_request": {"type": "string"}, "session_goal": {"type": "string"}, "task_id": {"type": "string"}, "require_heartbeat": {"type": "boolean"}, "require_work_log": {"type": "boolean"}, "heartbeat_interval_seconds": {"type": "integer"}, "work_log_interval_seconds": {"type": "integer"}, "min_work_logs": {"type": "integer"}, "handoff_required": {"type": "boolean"}, "resume_strategy": {"type": "string", "enum": ["auto", "new", "resume"]}, "resume_session_id": {"type": "string"}}, "required": ["actor"]}},
- {"name": "session_heartbeat", "description": "Record a session heartbeat and optionally emit a heartbeat work log.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "actor": {"type": "string"}, "status_note": {"type": "string"}, "task_id": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "create_work_log": {"type": "boolean"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["session_id", "actor"]}},
- {"name": "session_close", "description": "Close a session with summary and optional handoff creation.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "actor": {"type": "string"}, "summary": {"type": "string"}, "create_handoff": {"type": "boolean"}, "handoff_summary": {"type": "string"}, "handoff_next_steps": {"type": "string"}, "handoff_open_questions": {"type": "string"}, "handoff_note": {"type": "string"}, "handoff_to_actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["session_id", "actor"]}},
- {"name": "get_active_sessions", "description": "List open tracked sessions with cursor-based pagination.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 50, "description": "Maximum number of sessions to return (default 50, max 1000)."}, "after_heartbeat_at": {"type": "string", "description": "Return sessions with heartbeat_at less than this value. Use next_cursor.heartbeat_at from previous response."}, "after_id": {"type": "string", "description": "Return sessions with id less than this value (tiebreaker when heartbeat_at equals after_heartbeat_at). Use next_cursor.id from previous response."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "detect_missing_writeback", "description": "Audit sessions for missing write-back, missing handoffs, or overdue heartbeats.", "inputSchema": {"type": "object", "properties": {"include_closed": {"type": "boolean"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_startup_preflight", "description": "Run startup safety checks before opening or resuming a session.", "inputSchema": {"type": "object", "properties": {"actor": {"type": "string"}, "task_id": {"type": "string"}, "session_id": {"type": "string"}, "initial_request": {"type": "string"}, "session_goal": {"type": "string"}, "session_label": {"type": "string"}, "workstream_key": {"type": "string"}, "client_name": {"type": "string"}, "model_name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_resume_board", "description": "Return a startup dashboard of open tasks, paused tasks, stale sessions, latest handoffs, and the recommended resume target.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_resume_packet", "description": "Generate a compact resume packet for the next tool or model and write it to the project workspace.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "task_id": {"type": "string"}, "project_path": {"type": "string"}, "write_files": {"type": "boolean"}}}},
- {"name": "generate_emergency_handoff", "description": "Generate a best-effort handoff from the latest persisted state when a session ended abruptly.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "task_id": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "recover_session", "description": "Recover an interrupted session by generating an emergency handoff and resume packet.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "actor": {"type": "string"}, "project_path": {"type": "string"}}}},
- {"name": "sync_hub", "description": "Refresh the central obsmcp hub vault from the registry.", "inputSchema": {"type": "object", "properties": {}}},
- {"name": "health_check", "description": "Return health information about obsmcp.", "inputSchema": {"type": "object", "properties": {}}},
- {"name": "get_server_capabilities", "description": "Return server API/schema versions and supported workflow-safety capabilities.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Optional project root path."}}, "required": []}},
- {"name": "check_client_compatibility", "description": "Compare client API/tool-schema expectations with the current server.", "inputSchema": {"type": "object", "properties": {"client_api_version": {"type": "string"}, "client_tool_schema_version": {"type": "integer"}, "client_name": {"type": "string"}, "model_name": {"type": "string"}, "project_path": {"type": "string", "description": "Optional project root path."}}, "required": []}},
- {"name": "list_tools", "description": "Return the obsmcp tool catalog.", "inputSchema": {"type": "object", "properties": {}}},
- {"name": "list_resources", "description": "Return the obsmcp resource catalog.", "inputSchema": {"type": "object", "properties": {}}},
- {"name": "generate_compact_context", "description": "Generate compact context for manual prompt injection.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_compact_context_v2", "description": "Token-budget-aware compact context with decision chains, dependency map, session info, and smart truncation.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string", "description": "Optional task ID to focus on."}, "max_tokens": {"type": "integer", "description": "Max tokens budget (default 3000).", "default": 3000}, "include_decision_chain": {"type": "boolean", "description": "Include decision chain section.", "default": True}, "include_dependency_map": {"type": "boolean", "description": "Include ASCII dependency map.", "default": True}, "include_session_info": {"type": "boolean", "description": "Include active session info.", "default": True}, "include_recent_work": {"type": "boolean", "description": "Include recent work log.", "default": True}, "include_daily_notes": {"type": "boolean", "description": "Include daily notes.", "default": False}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_context_profile", "description": "Generate a cached tiered context profile such as fast, balanced, deep, handoff, or recovery.", "inputSchema": {"type": "object", "properties": {"profile": {"type": "string", "enum": ["fast", "balanced", "deep", "handoff", "recovery"], "default": "balanced"}, "task_id": {"type": "string"}, "max_tokens": {"type": "integer"}, "include_daily_notes": {"type": "boolean", "default": False}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_delta_context", "description": "Generate a compact delta view showing what changed since a handoff, session, or timestamp.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "since_handoff_id": {"type": "integer"}, "since_session_id": {"type": "string"}, "since_timestamp": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_prompt_segments", "description": "Generate stable and dynamic prompt segments for prompt-cache-friendly context assembly.", "inputSchema": {"type": "object", "properties": {"profile": {"type": "string", "enum": ["fast", "balanced", "deep", "handoff", "recovery"], "default": "balanced"}, "task_id": {"type": "string"}, "max_tokens": {"type": "integer", "default": 2600}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_retrieval_context", "description": "Generate retrieval-first context with ranked files, recent work, decisions, blockers, and semantic hits for a query.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "task_id": {"type": "string"}, "max_tokens": {"type": "integer", "default": 1800}, "include_delta": {"type": "boolean", "default": True}, "include_semantic": {"type": "boolean", "default": True}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["query"]}},
- {"name": "generate_task_snapshot", "description": "Generate a detailed snapshot for a task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "record_token_usage", "description": "Record provider or local token usage metrics, including prompt cache fields and compaction savings.", "inputSchema": {"type": "object", "properties": {"operation": {"type": "string"}, "event_type": {"type": "string", "default": "provider_usage"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "model_name": {"type": "string"}, "provider": {"type": "string"}, "client_name": {"type": "string"}, "raw_input_tokens": {"type": "integer"}, "raw_output_tokens": {"type": "integer"}, "estimated_input_tokens": {"type": "integer"}, "estimated_output_tokens": {"type": "integer"}, "compact_input_tokens": {"type": "integer"}, "compact_output_tokens": {"type": "integer"}, "saved_tokens": {"type": "integer"}, "cache_creation_input_tokens": {"type": "integer"}, "cache_read_input_tokens": {"type": "integer"}, "raw_chars": {"type": "integer"}, "compact_chars": {"type": "integer"}, "metadata": {"type": "object"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["operation"]}},
- {"name": "get_token_usage_stats", "description": "Return recent token, compaction, and prompt-cache usage aggregates for the project.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 200}, "operation": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "record_command_event", "description": "Record a terminal command outcome with compact summaries and optional raw output capture for later retrieval.", "inputSchema": {"type": "object", "properties": {"command_text": {"type": "string"}, "actor": {"type": "string"}, "cwd": {"type": "string"}, "event_kind": {"type": "string", "default": "completed"}, "status": {"type": "string"}, "risk_level": {"type": "string", "default": "normal"}, "exit_code": {"type": "integer", "default": 0}, "duration_ms": {"type": "integer", "default": 0}, "output": {"type": "string", "description": "Combined command output to summarize if stdout/stderr are not provided."}, "stdout": {"type": "string"}, "stderr": {"type": "string"}, "summary": {"type": "string"}, "stdout_summary": {"type": "string"}, "stderr_summary": {"type": "string"}, "profile": {"type": "string"}, "policy_mode": {"type": "string", "enum": ["compact", "balanced", "debug", "recovery"], "default": "balanced"}, "files_changed": {"type": "array", "items": {"type": "string"}}, "capture_raw_on_failure": {"type": "boolean", "default": True}, "capture_raw_on_truncation": {"type": "boolean", "default": True}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "metadata": {"type": "object"}, "sync_mode": {"type": "string", "enum": ["full", "deferred", "none"], "default": "deferred"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["command_text"]}},
- {"name": "record_command_batch", "description": "Record a batch of command outcomes and return an aggregate summary with risk counts.", "inputSchema": {"type": "object", "properties": {"commands": {"type": "array", "items": {"type": "object"}}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "policy_mode": {"type": "string", "enum": ["compact", "balanced", "debug", "recovery"], "default": "balanced"}, "batch_label": {"type": "string"}, "sync_mode": {"type": "string", "enum": ["full", "deferred", "none"], "default": "deferred"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["commands"]}},
- {"name": "get_command_event", "description": "Retrieve a recorded command event by ID.", "inputSchema": {"type": "object", "properties": {"event_id": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["event_id"]}},
- {"name": "get_recent_commands", "description": "List recent recorded command events with cursor-based pagination, optionally filtered by session, task, status, or actor.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 20, "description": "Maximum entries to return (default 20, max 1000)."}, "after_id": {"type": "integer", "description": "Return entries with id less than this value. Use next_cursor from the previous response to paginate."}, "session_id": {"type": "string", "description": "Filter by session ID."}, "task_id": {"type": "string", "description": "Filter by task ID."}, "status": {"type": "string", "description": "Filter by status (completed, failed, etc.)."}, "actor": {"type": "string", "description": "Filter by actor."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_last_command_result", "description": "Return the most recent recorded command event for a session or task.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "task_id": {"type": "string"}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_command_failures", "description": "List recent failing command events for a session or task.", "inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 20}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_command_execution_policy", "description": "Classify a command for batching and review risk, and combine that with the current optimization policy.", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}, "task_id": {"type": "string"}, "mode": {"type": "string", "enum": ["compact", "balanced", "debug", "recovery"], "default": "balanced"}, "exit_code": {"type": "integer", "default": 0}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["command"]}},
- {"name": "get_output_response_policy", "description": "Resolve the effective output-token policy for the current task/operation, including task overrides and safety bypasses.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "operation_kind": {"type": "string", "default": "general", "enum": ["general", "review", "debugging", "architecture", "dangerous_actions", "security_sensitive", "legal_medical_financial", "ambiguity_clarification", "step_by_step_sensitive"]}, "detail_requested": {"type": "boolean", "default": False}, "command": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "compact_tool_output", "description": "Apply RTK-style output compaction to noisy tool output and optionally save the full raw output for debugging.", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}, "output": {"type": "string"}, "exit_code": {"type": "integer", "default": 0}, "profile": {"type": "string"}, "policy_mode": {"type": "string", "enum": ["compact", "balanced", "debug", "recovery"], "default": "balanced"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "capture_raw_on_failure": {"type": "boolean", "default": True}, "capture_raw_on_truncation": {"type": "boolean", "default": True}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["command", "output"]}},
- {"name": "compact_response", "description": "Compress text output using rule-based patterns to reduce output tokens. Preserves code blocks, URLs, filepaths, and error messages. Use this to reduce token costs on verbose AI responses.", "inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "The text to compress."}, "level": {"type": "string", "enum": ["lite", "full", "ultra"], "default": "full", "description": "Compression level: lite (minimal), full (default, recommended), ultra (maximum)."}}, "required": ["text"]}},
- {"name": "get_raw_output_capture", "description": "Retrieve metadata or full content for a previously saved raw tool output capture.", "inputSchema": {"type": "object", "properties": {"capture_id": {"type": "string"}, "include_content": {"type": "boolean", "default": False}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["capture_id"]}},
- {"name": "get_fast_path_response", "description": "Return a deterministic no-LLM fast-path response such as current task, blockers, relevant files, project status, startup/resume packets, startup preflight, resume board, retrieval context, command history lookups, or semantic lookup.", "inputSchema": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["current_task", "blockers", "relevant_files", "task_snapshot", "project_status", "resume_packet", "startup_context", "startup_preflight", "resume_board", "recent_commands", "last_command", "command_failures", "retrieval", "semantic_lookup"]}, "task_id": {"type": "string"}, "session_id": {"type": "string"}, "module_path": {"type": "string"}, "symbol_name": {"type": "string"}, "feature_name": {"type": "string"}, "query": {"type": "string"}, "as_markdown": {"type": "boolean", "default": False}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["kind"]}},
- {"name": "get_optimization_policy", "description": "Return the active adaptive optimization policy for a mode, task, command, and exit state.", "inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["compact", "balanced", "debug", "recovery"], "default": "balanced"}, "task_id": {"type": "string"}, "command": {"type": "string"}, "exit_code": {"type": "integer", "default": 0}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "list_context_chunks", "description": "List prioritized chunk metadata for a context artifact to support progressive loading.", "inputSchema": {"type": "object", "properties": {"artifact_type": {"type": "string", "enum": ["context_profile", "delta_context", "prompt_segments", "retrieval_context", "resume_packet"], "default": "context_profile"}, "profile": {"type": "string", "default": "deep"}, "task_id": {"type": "string"}, "query": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_progressive_context", "description": "Render one or more prioritized chunks from a context artifact with navigation metadata for progressive loading.", "inputSchema": {"type": "object", "properties": {"artifact_type": {"type": "string", "enum": ["context_profile", "delta_context", "prompt_segments", "retrieval_context", "resume_packet"], "default": "context_profile"}, "profile": {"type": "string", "default": "deep"}, "start_chunk": {"type": "integer", "default": 0}, "chunk_count": {"type": "integer", "default": 2}, "task_id": {"type": "string"}, "query": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_startup_context", "description": "Generate a delta-first startup context with fast baseline, recent command history, and execution policy hints.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "session_id": {"type": "string"}, "max_tokens": {"type": "integer", "default": 1800}, "prefer_cached_delta": {"type": "boolean", "default": True}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "generate_startup_prompt_template", "description": "Return the first-contact startup prompt template for tools and agents.", "inputSchema": {"type": "object", "properties": {"first_contact": {"type": "boolean"}}}},
- {"name": "set_current_task", "description": "Set the current active task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "scan_codebase", "description": "Scan the project directory and generate a Code Atlas documenting every file, function, class, and feature. Supports Python, JS/TS, Rust, Java, C/C++, Go, HTML, CSS, and more. When called over MCP, scans default to background mode so clients can poll instead of timing out.", "inputSchema": {"type": "object", "properties": {"force_refresh": {"type": "boolean", "description": "If True, always regenerate the atlas. If False (default), only regenerate if any source file is newer than the existing atlas."}, "background": {"type": "boolean", "description": "If True, queue the scan as a background job and return a job record instead of blocking."}, "requested_by": {"type": "string", "description": "Optional actor label for the queued job."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_code_atlas_status", "description": "Return the current status of the Code Atlas without regenerating it. Tells you if the atlas exists, when it was last generated, total files, total lines, and languages found.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "start_scan_job", "description": "Queue a background code atlas scan job and return its job ID for polling.", "inputSchema": {"type": "object", "properties": {"force_refresh": {"type": "boolean"}, "requested_by": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_scan_job", "description": "Get the current status and result payload for a background scan job.", "inputSchema": {"type": "object", "properties": {"job_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["job_id"]}},
- {"name": "list_scan_jobs", "description": "List recent background scan jobs for the project.", "inputSchema": {"type": "object", "properties": {"status": {"type": "string", "enum": ["queued", "running", "completed", "failed", "interrupted"]}, "limit": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "wait_for_scan_job", "description": "Poll a background scan job until it completes or the wait timeout elapses.", "inputSchema": {"type": "object", "properties": {"job_id": {"type": "string"}, "wait_seconds": {"type": "integer"}, "poll_interval_seconds": {"type": "number"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["job_id"]}},
- {"name": "describe_module", "description": "Return a cached or freshly generated semantic description for a module/file.", "inputSchema": {"type": "object", "properties": {"module_path": {"type": "string"}, "force_llm": {"type": "boolean", "default": False, "description": "Force LLM-powered generation even if cache is valid."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["module_path"]}},
- {"name": "describe_symbol", "description": "Return a semantic description for a function or class. If multiple candidates exist, returns an ambiguity payload.", "inputSchema": {"type": "object", "properties": {"symbol_name": {"type": "string"}, "module_path": {"type": "string"}, "entity_key": {"type": "string"}, "entity_type": {"type": "string", "enum": ["function", "class"]}, "force_llm": {"type": "boolean", "default": False, "description": "Force LLM-powered generation even if cache is valid."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "describe_feature", "description": "Return a semantic description for a feature tag from the Code Atlas.", "inputSchema": {"type": "object", "properties": {"feature_name": {"type": "string"}, "force_llm": {"type": "boolean", "default": False, "description": "Force LLM-powered generation even if cache is valid."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["feature_name"]}},
- {"name": "web_search", "description": "Run a web search through obsmcp using the configured OpusMax tool provider.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "client_name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["query"]}},
- {"name": "understand_image", "description": "Analyze an image through obsmcp using the configured OpusMax image-understanding tool provider.", "inputSchema": {"type": "object", "properties": {"prompt": {"type": "string"}, "image_url": {"type": "string", "description": "HTTP(S) URL, data URL, or existing local file path."}, "image_path": {"type": "string"}, "image_base64": {"type": "string"}, "mime_type": {"type": "string"}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "client_name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["prompt"]}},
- {"name": "search_code_knowledge", "description": "Search semantic knowledge and symbol index entries.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["query"]}},
- {"name": "get_symbol_candidates", "description": "Return matching function/class symbol candidates for a name.", "inputSchema": {"type": "object", "properties": {"symbol_name": {"type": "string"}, "module_path": {"type": "string"}, "entity_type": {"type": "string", "enum": ["function", "class"]}, "limit": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["symbol_name"]}},
- {"name": "get_related_symbols", "description": "Return nearby or feature-related symbols for a semantic entity.", "inputSchema": {"type": "object", "properties": {"entity_key": {"type": "string"}, "limit": {"type": "integer"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["entity_key"]}},
- {"name": "invalidate_semantic_cache", "description": "Mark semantic description cache entries stale by entity or file.", "inputSchema": {"type": "object", "properties": {"entity_key": {"type": "string"}, "file_paths": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "refresh_semantic_description", "description": "Force a fresh semantic description generation for an entity lookup.", "inputSchema": {"type": "object", "properties": {"entity_key": {"type": "string"}, "module_path": {"type": "string"}, "symbol_name": {"type": "string"}, "feature_name": {"type": "string"}, "entity_type": {"type": "string", "enum": ["function", "class"]}, "force_llm": {"type": "boolean", "default": False, "description": "Use LLM-powered generation when refreshing."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Phase 1: Task Templates
- {"name": "get_task_templates", "description": "List all available task templates.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "get_task_template", "description": "Get a specific task template by name.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["name"]}},
- {"name": "create_task_template", "description": "Create a new task template.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}, "title_template": {"type": "string"}, "description_template": {"type": "string"}, "priority": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["name", "title_template", "description_template"]}},
- {"name": "delete_task_template", "description": "Delete a task template by name.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "create_task_from_template", "description": "Create a task from a named template, filling in template variables.", "inputSchema": {"type": "object", "properties": {"template_name": {"type": "string"}, "variables": {"type": "object", "additionalProperties": {"type": "string"}}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["template_name"]}},
- # Phase 1: Quick Log
- {"name": "quick_log", "description": "One-liner work log that auto-tags the current task. No task_id required — uses the current task from project state.", "inputSchema": {"type": "object", "properties": {"message": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "actor": {"type": "string"}, "session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["message"]}},
- # Phase 1: Audit Log
- {"name": "get_audit_log", "description": "Full project-wide activity timeline with cursor-based pagination. Shows every action performed, by whom, when, and on which task.", "inputSchema": {"type": "object", "properties": {"actor": {"type": "string", "description": "Filter by actor."}, "task_id": {"type": "string", "description": "Filter by task."}, "action_type": {"type": "string", "description": "Filter by action type."}, "from_date": {"type": "string", "description": "Filter entries from this date (ISO8601)."}, "to_date": {"type": "string", "description": "Filter entries up to this date (ISO8601)."}, "limit": {"type": "integer", "default": 100, "description": "Maximum entries to return (default 100, max 1000)."}, "after_id": {"type": "integer", "description": "Return entries with id less than this value. Use next_cursor from the previous response to paginate."}, "include_ai_only": {"type": "boolean", "default": False, "description": "Exclude ctx/manual/human actors."}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Phase 2: Reset Project
- {"name": "reset_project", "description": "Wipe project data by scope. WARNING: This permanently deletes data. Always creates an audit trail before wiping. Valid scopes: tasks, blockers, sessions, work_logs, decisions, handoffs, full.", "inputSchema": {"type": "object", "properties": {"scope": {"type": "string", "enum": ["tasks", "blockers", "sessions", "work_logs", "decisions", "handoffs", "full"]}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["scope"]}},
- # Phase 2: Bulk Task Operations
- {"name": "bulk_task_ops", "description": "Execute multiple task operations atomically. All succeed or all fail. Actions: create, update, close, delete, set_current.", "inputSchema": {"type": "object", "properties": {"operations": {"type": "array", "items": {"type": "object"}}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["operations"]}},
- # Phase 2: Project Export
- {"name": "export_project", "description": "Export full project state as JSON (gzipped) and/or Markdown bundle. Creates a timestamped export in data/exports/.", "inputSchema": {"type": "object", "properties": {"format": {"type": "string", "enum": ["json", "markdown", "both"]}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Phase 3: Work Log Expiry
- {"name": "configure_log_expiry", "description": "Set the work log retention period in days. Set to 0 to disable. Logs older than N days are purged on expire_old_logs() or auto-trigger.", "inputSchema": {"type": "object", "properties": {"days": {"type": "integer"}, "actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["days"]}},
- {"name": "expire_old_logs", "description": "Purge work logs older than the configured retention period. Records a decision entry. Never deletes logs from open sessions.", "inputSchema": {"type": "object", "properties": {"actor": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "get_log_stats", "description": "Return work log statistics: total count, age buckets (today/this week/this month/last 3 months/older), and current expiry setting.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Phase 3: Session Replay
- {"name": "session_replay", "description": "Reconstruct the full timeline of events within a session. If no session_id is provided, uses the most recent session. Returns events, statistics, warnings, and a rendered Markdown timeline.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Phase 3: Task Dependencies
- {"name": "add_task_dependency", "description": "Link a task as blocked by other tasks and/or blocking other tasks. Automatically detects and rejects circular dependencies.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "blocked_by": {"type": "array", "items": {"type": "string"}}, "blocks": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "remove_task_dependency", "description": "Remove task dependencies. Provide blocked_by or blocks lists to selectively remove specific links.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "blocked_by": {"type": "array", "items": {"type": "string"}}, "blocks": {"type": "array", "items": {"type": "string"}}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "get_task_dependency", "description": "Get dependencies for a specific task.", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": ["task_id"]}},
- {"name": "get_all_dependencies", "description": "Get all task dependencies across the project.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "get_blocked_tasks", "description": "Return tasks that are currently blocked by unresolved dependencies.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- {"name": "validate_dependencies", "description": "Validate all task dependencies. Checks for circular dependencies and broken task references.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}}},
- # Milestone C: Token-saving retrieval
- {"name": "generate_fast_context", "description": "Generate a guaranteed-fast L0-only context for startup/resume use cases. Returns only mission, current task, relevant files, latest handoff, and blockers — no semantic lookups, dependency map, daily notes, or audit. Ephemeral (no artifact written).", "inputSchema": {"type": "object", "properties": {"task_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- {"name": "retrieve_context_chunk", "description": "Retrieve a specific chunk of a context artifact for large profile navigation. If chunk not cached, generates full artifact, splits by section priority, and caches each chunk.", "inputSchema": {"type": "object", "properties": {"artifact_type": {"type": "string", "enum": ["context_profile", "delta_context", "prompt_segments", "retrieval_context", "resume_packet"]}, "chunk_index": {"type": "integer", "minimum": 0}, "profile": {"type": "string"}, "task_id": {"type": "string"}, "query": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path. Defaults to OBSMCP_PROJECT env var or configured default."}}, "required": []}},
- # Milestone D: Cross-IDE / plugin handoff
- {"name": "generate_cross_tool_handoff", "description": "Generate a structured JSON handoff payload targeting a specific tool or environment (e.g., claude-code, vscode, jetbrains). Includes task state, recent decisions, blockers, relevant files, session lineage chain, and IDE environment metadata.", "inputSchema": {"type": "object", "properties": {"handoff_id": {"type": "integer"}, "session_id": {"type": "string"}, "target_tool": {"type": "string", "description": "Target tool identifier (e.g., claude-code, vscode, jetbrains)."}, "target_env": {"type": "string", "description": "Target environment label (e.g., default, production, testing)."}, "project_path": {"type": "string", "description": "Project root path."}}, "required": []}},
- {"name": "get_session_lineage_chain", "description": "Traverse the session lineage chain from a given session to its root ancestor, showing parent sessions, actors, depth, and timestamps.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "max_depth": {"type": "integer", "minimum": 1, "maximum": 20}, "project_path": {"type": "string", "description": "Project root path."}}, "required": ["session_id"]}},
- {"name": "set_session_environment", "description": "Attach IDE/environment metadata to an active session and optionally establish its parent lineage. Use this when switching IDEs or resuming across environments.", "inputSchema": {"type": "object", "properties": {"session_id": {"type": "string"}, "ide_name": {"type": "string"}, "ide_version": {"type": "string"}, "ide_platform": {"type": "string"}, "os_name": {"type": "string"}, "os_version": {"type": "string"}, "env_variables": {"type": "object", "additionalProperties": {"type": "string"}}, "startup_context": {"type": "object"}, "parent_session_id": {"type": "string"}, "project_path": {"type": "string", "description": "Project root path."}}, "required": ["session_id"]}},
- # Milestone E: Automatic project resolution
- {"name": "get_or_create_project", "description": "Auto-detect or create a project from a path hint, session, task, or environment. Resolves from multiple sources and optionally registers if not known. Returns project type metadata, workspace type, and nearby projects.", "inputSchema": {"type": "object", "properties": {"project_path": {"type": "string", "description": "Project root path."}, "session_id": {"type": "string"}, "task_id": {"type": "string"}, "auto_register": {"type": "boolean"}, "project_name": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "scan_nearby": {"type": "boolean"}}, "required": []}},
-]
diff --git a/server/store.py b/server/store.py
deleted file mode 100644
index 82528c9..0000000
--- a/server/store.py
+++ /dev/null
@@ -1,3886 +0,0 @@
-from __future__ import annotations
-
-import json
-import re
-import sqlite3
-import uuid
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-
-from .config import AppConfig, ProjectConfig
-from .database import Database
-from .utils import parse_json, read_text_with_retry, slugify, utc_now, write_json_atomic, write_text_atomic
-
-
-DEFAULT_BRIEF_SECTIONS = {
- "Mission": "Describe the project mission and intended business or engineering outcome.",
- "Success Criteria": "Define the outcome that marks the current phase as successful.",
- "Architecture": "Capture the current architecture, constraints, and integration boundaries.",
- "Working Agreements": "Record conventions that every model, agent, or teammate should preserve.",
-}
-
-DEFAULT_TASK_TEMPLATES = [
- {
- "name": "bug",
- "title_template": "Bug: {summary}",
- "description_template": "## Summary\n{summary}\n\n## Steps to Reproduce\n{steps}\n\n## Expected Behavior\n{expected}\n\n## Actual Behavior\n{actual}",
- "priority": "high",
- "tags": ["bug"],
- },
- {
- "name": "feature",
- "title_template": "Feature: {name}",
- "description_template": "## Goal\n{goal}\n\n## Acceptance Criteria\n{criteria}\n\n## Notes\n{notes}",
- "priority": "medium",
- "tags": ["feature"],
- },
- {
- "name": "research",
- "title_template": "Research: {topic}",
- "description_template": "## Research Topic\n{topic}\n\n## Question\n{question}\n\n## Findings\n{finding}\n\n## Conclusion\n{conclusion}",
- "priority": "low",
- "tags": ["research"],
- },
- {
- "name": "refactor",
- "title_template": "Refactor: {target}",
- "description_template": "## Target\n{target}\n\n## Reason\n{reason}\n\n## Approach\n{approach}\n\n## Risk Assessment\n{risk}",
- "priority": "medium",
- "tags": ["refactor"],
- },
- {
- "name": "docs",
- "title_template": "Document: {target}",
- "description_template": "## Document Target\n{target}\n\n## Purpose\n{purpose}\n\n## Outline\n{outline}",
- "priority": "low",
- "tags": ["documentation"],
- },
- {
- "name": "test",
- "title_template": "Test: {target}",
- "description_template": "## What to Test\n{what_to_test}\n\n## Test Cases\n{test_cases}\n\n## Edge Cases\n{edge_cases}",
- "priority": "medium",
- "tags": ["testing"],
- },
-]
-
-CHECKPOINT_TOKEN_RE = re.compile(r"\b([A-Z]{1,4}\d+(?:-[A-Z0-9]+)+)\b")
-CHECKPOINT_PHASE_RE = re.compile(r"^([A-Z]+[0-9]+)")
-
-
-class StateStore:
- def __init__(self, config: AppConfig, project_config: ProjectConfig | None = None) -> None:
- self.config = config
- self.project_config = project_config or config.get_project_config(None)
- self.project_root = str(self.project_config.project_path.resolve())
- self._ensure_dirs()
- self.database = Database(self.project_config.db_path)
- self.database.initialize()
- self._migrate_schema()
- self._bootstrap()
-
- def _migrate_schema(self) -> None:
- """Auto-migrate schema for projects created with older versions.
-
- Detects missing columns in semantic_descriptions table and adds them.
- This ensures older projects get new columns (llm_model, etc.) without
- requiring manual schema updates.
- """
- SEMANTIC_COLUMNS = [
- ("llm_model", "TEXT"),
- ("llm_latency_ms", "REAL"),
- ("llm_input_tokens", "INTEGER"),
- ("llm_output_tokens", "INTEGER"),
- ("llm_generated", "INTEGER NOT NULL DEFAULT 0"),
- ("language", "TEXT"),
- ]
- SESSION_COLUMNS = [
- ("session_label", "TEXT NOT NULL DEFAULT ''"),
- ("workstream_key", "TEXT NOT NULL DEFAULT ''"),
- ("workstream_title", "TEXT NOT NULL DEFAULT ''"),
- ]
- try:
- with self._connect() as connection:
- cursor = connection.execute("PRAGMA table_info(semantic_descriptions)")
- existing = {row[1] for row in cursor.fetchall()}
- for col_name, col_type in SEMANTIC_COLUMNS:
- if col_name not in existing:
- connection.execute(f"ALTER TABLE semantic_descriptions ADD COLUMN {col_name} {col_type}")
- cursor = connection.execute("PRAGMA table_info(sessions)")
- existing = {row[1] for row in cursor.fetchall()}
- for col_name, col_type in SESSION_COLUMNS:
- if col_name not in existing:
- connection.execute(f"ALTER TABLE sessions ADD COLUMN {col_name} {col_type}")
- connection.execute(
- """
- UPDATE sessions
- SET session_label = COALESCE(NULLIF(session_label, ''), id),
- workstream_key = COALESCE(NULLIF(workstream_key, ''), lower(replace(replace(client_name, ' ', '-'), '_', '-'))),
- workstream_title = COALESCE(NULLIF(workstream_title, ''), NULLIF(session_label, ''), id)
- """
- )
- connection.execute(
- """
- CREATE INDEX IF NOT EXISTS idx_sessions_workstream_status
- ON sessions(workstream_key, status, heartbeat_at DESC)
- """
- )
- connection.commit()
- except Exception:
- pass # Silently skip if table doesn't exist yet
-
- def _ensure_dirs(self) -> None:
- """Create per-project directory structure on demand."""
- base_paths = {
- self.project_config.workspace_root,
- self.project_config.data_root,
- self.project_config.db_path.parent,
- self.project_config.json_export_dir,
- self.project_config.backup_dir,
- self.project_config.export_dir,
- self.project_config.log_dir,
- self.project_config.vault_path,
- self.project_config.context_path,
- self.project_config.sessions_path,
- }
- paths: set[Path] = set()
- for path in base_paths:
- current = path
- while True:
- paths.add(current)
- if current == self.project_config.workspace_root or current.parent == current:
- break
- current = current.parent
- for path in sorted(paths, key=lambda item: (len(item.parts), str(item))):
- if path.exists():
- continue
- path.mkdir(exist_ok=True)
-
- def _session_dir(self, session_id: str) -> Path:
- return self.project_config.sessions_path / session_id
-
- def _write_project_manifest(self) -> None:
- payload = {
- "project_slug": self.project_config.project_slug,
- "project_name": self.project_config.project_name,
- "repo_path": str(self.project_config.project_path),
- "workspace_root": str(self.project_config.workspace_root),
- "db_path": str(self.project_config.db_path),
- "vault_path": str(self.project_config.vault_path),
- "context_path": str(self.project_config.context_path),
- "sessions_path": str(self.project_config.sessions_path),
- "updated_at": utc_now(),
- }
- write_json_atomic(self.project_config.manifest_path, payload)
-
- def _write_session_metadata(self, session_id: str, payload: dict[str, Any]) -> None:
- session_dir = self._session_dir(session_id)
- session_dir.mkdir(parents=True, exist_ok=True)
- write_json_atomic(session_dir / "metadata.json", payload)
-
- def _append_session_jsonl(self, session_id: str, filename: str, payload: dict[str, Any]) -> None:
- session_dir = self._session_dir(session_id)
- session_dir.mkdir(parents=True, exist_ok=True)
- path = session_dir / filename
- existing = read_text_with_retry(path, default="") if path.exists() else ""
- line = json.dumps(payload, ensure_ascii=True)
- write_text_atomic(path, existing + line + "\n")
-
- def _append_session_markdown(self, session_id: str, filename: str, entry: str, heading: str) -> None:
- session_dir = self._session_dir(session_id)
- session_dir.mkdir(parents=True, exist_ok=True)
- path = session_dir / filename
- existing = read_text_with_retry(path, default=f"# {heading}\n\n") if path.exists() else f"# {heading}\n\n"
- if not existing.endswith("\n"):
- existing += "\n"
- write_text_atomic(path, existing + entry.rstrip() + "\n\n")
-
- def _connect(self) -> sqlite3.Connection:
- return self.database.connect()
-
- def _parse_utc(self, value: str | None) -> datetime | None:
- if not value:
- return None
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
-
- def _normalize_project_file_path(self, file_path: str) -> str:
- candidate = Path(file_path)
- if candidate.is_absolute():
- try:
- return str(candidate.resolve().relative_to(self.project_config.project_path.resolve())).replace("\\", "/")
- except ValueError:
- return str(candidate.resolve()).replace("\\", "/")
- return file_path.replace("\\", "/")
-
- def _bootstrap(self) -> None:
- now = utc_now()
- with self._connect() as connection:
- for section, content in DEFAULT_BRIEF_SECTIONS.items():
- connection.execute(
- """
- INSERT OR IGNORE INTO project_brief_sections (section, content, updated_at)
- VALUES (?, ?, ?)
- """,
- (section, content, now),
- )
- connection.execute(
- """
- INSERT OR IGNORE INTO project_state (key, value, updated_at)
- VALUES ('current_task_id', '', ?)
- """,
- (now,),
- )
- for tmpl in DEFAULT_TASK_TEMPLATES:
- connection.execute(
- """
- INSERT OR IGNORE INTO task_templates
- (name, title_template, description_template, priority, tags, created_at)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (
- tmpl["name"],
- tmpl["title_template"],
- tmpl["description_template"],
- tmpl["priority"],
- json.dumps(tmpl["tags"]),
- now,
- ),
- )
- connection.execute(
- """
- UPDATE scan_jobs
- SET status = 'interrupted',
- finished_at = COALESCE(finished_at, ?),
- error_text = CASE
- WHEN error_text = '' THEN 'Server restarted before the scan job finished.'
- ELSE error_text
- END,
- progress_message = CASE
- WHEN progress_message = '' THEN 'Interrupted by server restart.'
- ELSE progress_message
- END
- WHERE status IN ('queued', 'running')
- """,
- (now,),
- )
- connection.commit()
- self._write_project_manifest()
-
- def _fetchone_dict(self, connection: sqlite3.Connection, query: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
- row = connection.execute(query, params).fetchone()
- return dict(row) if row else None
-
- def _fetchall_dicts(self, connection: sqlite3.Connection, query: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
- rows = connection.execute(query, params).fetchall()
- return [dict(row) for row in rows]
-
- def _normalize_task(self, task: dict[str, Any] | None) -> dict[str, Any] | None:
- if not task:
- return None
- task["relevant_files"] = parse_json(task.get("relevant_files"), [])
- task["tags"] = parse_json(task.get("tags"), [])
- return task
-
- def _normalize_session(self, session: dict[str, Any] | None) -> dict[str, Any] | None:
- if not session:
- return None
- session["session_label"] = (session.get("session_label") or session.get("id") or "").strip()
- session["workstream_key"] = (session.get("workstream_key") or "").strip()
- session["workstream_title"] = (session.get("workstream_title") or session.get("session_label") or session.get("id") or "").strip()
- return session
-
- def _normalize_work_log(self, row: dict[str, Any]) -> dict[str, Any]:
- row["files"] = parse_json(row.get("files"), [])
- return row
-
- def _normalize_checkpoint(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["files"] = parse_json(row.get("files"), [])
- return row
-
- def _extract_expected_checkpoint_ids(self, task: dict[str, Any] | None) -> list[str]:
- if not task:
- return []
- text = "\n".join([str(task.get("title", "")), str(task.get("description", ""))])
- expected: list[str] = []
- seen: set[str] = set()
- for match in CHECKPOINT_TOKEN_RE.finditer(text):
- checkpoint_id = match.group(1)
- if checkpoint_id in seen:
- continue
- seen.add(checkpoint_id)
- expected.append(checkpoint_id)
- return expected
-
- def _checkpoint_phase_key(self, checkpoint_id: str) -> str:
- if "-" in checkpoint_id:
- return checkpoint_id.split("-", 1)[0]
- match = CHECKPOINT_PHASE_RE.match(checkpoint_id)
- if match:
- return match.group(1)
- return checkpoint_id
-
- def _normalize_semantic_index_row(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["feature_tags"] = parse_json(row.get("feature_tags"), [])
- row["source_files"] = parse_json(row.get("source_files"), [])
- row["metadata"] = parse_json(row.get("metadata"), {})
- return row
-
- def _normalize_semantic_description(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["file"] = row.get("file_path", "")
- row["related_files"] = parse_json(row.get("related_files"), [])
- row["related_decisions"] = parse_json(row.get("related_decisions"), [])
- row["related_tasks"] = parse_json(row.get("related_tasks"), [])
- row["related_symbols"] = parse_json(row.get("related_symbols"), [])
- row["metadata"] = parse_json(row.get("metadata"), {})
- row["freshness"] = "stale" if row.get("stale") else "fresh"
- row["llm_generated"] = bool(row.get("llm_generated"))
- return row
-
- def _normalize_scan_job(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["force_refresh"] = bool(row.get("force_refresh"))
- row["result"] = parse_json(row.get("result_json"), {})
- return row
-
- def _normalize_context_artifact(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["metadata"] = parse_json(row.get("metadata"), {})
- return row
-
- def _normalize_token_usage_event(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["metadata"] = parse_json(row.get("metadata"), {})
- return row
-
- def _normalize_raw_output_capture(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["metadata"] = parse_json(row.get("metadata"), {})
- return row
-
- def _normalize_command_event(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- row["files_changed"] = parse_json(row.get("files_changed"), [])
- row["metadata"] = parse_json(row.get("metadata"), {})
- row["raw_output_available"] = bool(row.get("raw_output_available"))
- return row
-
- def _record_activity(self, connection: sqlite3.Connection, actor: str, action: str, task_id: str | None, payload: dict[str, Any]) -> None:
- connection.execute(
- """
- INSERT INTO agent_activity (actor, action, task_id, payload, created_at)
- VALUES (?, ?, ?, ?, ?)
- """,
- (actor, action, task_id, json.dumps(payload), utc_now()),
- )
-
- def _record_session_event(
- self,
- connection: sqlite3.Connection,
- session_id: str,
- actor: str,
- event_type: str,
- payload: dict[str, Any],
- ) -> None:
- connection.execute(
- """
- INSERT INTO session_events (session_id, actor, event_type, payload, created_at)
- VALUES (?, ?, ?, ?, ?)
- """,
- (session_id, actor, event_type, json.dumps(payload), utc_now()),
- )
-
- def _touch_session_write(
- self,
- connection: sqlite3.Connection,
- session_id: str | None,
- actor: str,
- event_type: str,
- payload: dict[str, Any],
- ) -> None:
- if not session_id:
- return
- now = utc_now()
- connection.execute(
- """
- UPDATE sessions
- SET write_count = write_count + 1,
- last_write_at = ?,
- heartbeat_at = ?
- WHERE id = ?
- """,
- (now, now, session_id),
- )
- self._record_session_event(connection, session_id, actor, event_type, payload)
-
- def get_project_brief(self) -> dict[str, str]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- "SELECT section, content FROM project_brief_sections ORDER BY section ASC",
- )
- return {row["section"]: row["content"] for row in rows}
-
- def update_project_brief_section(
- self,
- section: str,
- content: str,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, str]:
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO project_brief_sections (section, content, updated_at)
- VALUES (?, ?, ?)
- ON CONFLICT(section) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
- """,
- (section, content, now),
- )
- self._record_activity(connection, actor, "update_project_brief_section", None, {"section": section})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="update_project_brief_section",
- payload={"section": section},
- )
- connection.commit()
- return self.get_project_brief()
-
- def create_task(
- self,
- title: str,
- description: str,
- priority: str = "medium",
- owner: str | None = None,
- relevant_files: list[str] | None = None,
- tags: list[str] | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, Any]:
- task_id = f"TASK-{uuid.uuid4().hex[:8].upper()}-{slugify(title, max_length=18)}"
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO tasks (
- id, title, description, status, priority, owner, relevant_files, tags, created_at, updated_at
- ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
- """,
- (
- task_id,
- title,
- description,
- priority,
- owner,
- json.dumps(relevant_files or []),
- json.dumps(tags or []),
- now,
- now,
- ),
- )
- self._record_activity(connection, actor, "create_task", task_id, {"title": title})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="create_task",
- payload={"task_id": task_id, "title": title},
- )
- connection.commit()
- return self.get_task(task_id)
-
- def update_task(self, task_id: str, actor: str = "unknown", session_id: str | None = None, **fields: Any) -> dict[str, Any]:
- allowed = {"title", "description", "status", "priority", "owner", "relevant_files", "tags"}
- updates: list[str] = []
- values: list[Any] = []
- for key, value in fields.items():
- if key not in allowed or value is None:
- continue
- if key in {"relevant_files", "tags"}:
- value = json.dumps(value)
- updates.append(f"{key} = ?")
- values.append(value)
-
- if not updates:
- return self.get_task(task_id)
-
- now = utc_now()
- updates.append("updated_at = ?")
- values.append(now)
-
- status = fields.get("status")
- if status == "in_progress":
- updates.append("started_at = COALESCE(started_at, ?)")
- values.append(now)
- elif status == "done":
- updates.append("completed_at = ?")
- values.append(now)
-
- values.append(task_id)
- with self._connect() as connection:
- connection.execute(f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?", tuple(values))
- self._record_activity(connection, actor, "update_task", task_id, fields)
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="update_task",
- payload={"task_id": task_id, "fields": list(fields.keys())},
- )
- connection.commit()
- return self.get_task(task_id)
-
- def get_task(self, task_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- task = self._fetchone_dict(connection, "SELECT * FROM tasks WHERE id = ?", (task_id,))
- return self._normalize_task(task)
-
- def set_current_task(self, task_id: str, actor: str = "unknown", session_id: str | None = None) -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO project_state (key, value, updated_at)
- VALUES ('current_task_id', ?, ?)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
- """,
- (task_id, now),
- )
- connection.execute(
- """
- UPDATE tasks
- SET status = CASE WHEN status = 'open' THEN 'in_progress' ELSE status END,
- updated_at = ?,
- started_at = COALESCE(started_at, ?)
- WHERE id = ?
- """,
- (now, now, task_id),
- )
- self._record_activity(connection, actor, "set_current_task", task_id, {})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="set_current_task",
- payload={"task_id": task_id},
- )
- connection.commit()
- return self.get_task(task_id)
-
- def get_current_task(self) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT value FROM project_state WHERE key = 'current_task_id'")
- if not row or not row["value"]:
- return None
- task = self._fetchone_dict(connection, "SELECT * FROM tasks WHERE id = ?", (row["value"],))
- return self._normalize_task(task)
-
- def get_active_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM tasks
- WHERE status IN ('open', 'in_progress', 'blocked')
- ORDER BY
- CASE status
- WHEN 'in_progress' THEN 0
- WHEN 'blocked' THEN 1
- ELSE 2
- END,
- updated_at DESC
- LIMIT ?
- """,
- (limit,),
- )
- return [self._normalize_task(row) for row in rows]
-
- def log_work(
- self,
- message: str,
- actor: str = "unknown",
- task_id: str | None = None,
- summary: str | None = None,
- files: list[str] | None = None,
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO work_logs (task_id, actor, message, summary, files, created_at)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (task_id, actor, message, summary, json.dumps(files or []), now),
- )
- work_log_id = cursor.lastrowid
- self._record_activity(connection, actor, "log_work", task_id, {"summary": summary, "files": files or []})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="log_work",
- payload={"task_id": task_id, "message": message, "files": files or []},
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM work_logs WHERE id = ?", (work_log_id,))
- payload = self._normalize_work_log(row or {})
- if session_id:
- self._append_session_markdown(
- session_id,
- "worklog.md",
- f"- {payload.get('created_at', utc_now())} [{actor}] {message}",
- "Session Worklog",
- )
- return payload
-
- def get_recent_work(self, limit: int = 10, after_id: int | None = None) -> list[dict[str, Any]]:
- params: list[Any] = []
- if after_id is not None:
- params.append(after_id)
- with self._connect() as connection:
- if after_id is not None:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM work_logs
- WHERE id < ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (after_id, limit),
- )
- else:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM work_logs
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (limit,),
- )
- return [self._normalize_work_log(row) for row in rows]
-
- def log_checkpoint(
- self,
- task_id: str,
- checkpoint_id: str,
- title: str,
- message: str = "",
- status: str = "completed",
- files: list[str] | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- normalized_files = [self._normalize_project_file_path(item) for item in (files or [])]
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO checkpoints (task_id, checkpoint_id, title, status, message, files, actor, session_id, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(task_id, checkpoint_id) DO UPDATE SET
- title = excluded.title,
- status = excluded.status,
- message = excluded.message,
- files = excluded.files,
- actor = excluded.actor,
- session_id = excluded.session_id,
- created_at = excluded.created_at
- """,
- (task_id, checkpoint_id, title, status, message, json.dumps(normalized_files), actor, session_id, now),
- )
- self._record_activity(
- connection,
- actor,
- "log_checkpoint",
- task_id,
- {"checkpoint_id": checkpoint_id, "title": title, "status": status, "files": normalized_files},
- )
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="log_checkpoint",
- payload={"task_id": task_id, "checkpoint_id": checkpoint_id, "title": title, "status": status},
- )
- connection.commit()
- row = self._fetchone_dict(
- connection,
- "SELECT * FROM checkpoints WHERE task_id = ? AND checkpoint_id = ?",
- (task_id, checkpoint_id),
- )
- payload = self._normalize_checkpoint(row)
- if session_id and payload:
- self._append_session_markdown(
- session_id,
- "worklog.md",
- f"- {payload.get('created_at', now)} [{actor}] Checkpoint {checkpoint_id}: {title}",
- "Session Worklog",
- )
- return payload or {}
-
- def get_checkpoints_for_task(self, task_id: str, limit: int = 50) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM checkpoints
- WHERE task_id = ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (task_id, limit),
- )
- return [self._normalize_checkpoint(row) for row in rows if row]
-
- def get_recent_checkpoints(self, limit: int = 20, task_id: str | None = None) -> list[dict[str, Any]]:
- with self._connect() as connection:
- if task_id:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM checkpoints
- WHERE task_id = ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (task_id, limit),
- )
- else:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM checkpoints
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (limit,),
- )
- return [self._normalize_checkpoint(row) for row in rows if row]
-
- def get_checkpoint_progress(self, task_id: str) -> dict[str, Any]:
- task = self.get_task(task_id)
- expected_ids = self._extract_expected_checkpoint_ids(task)
- checkpoints = self.get_checkpoints_for_task(task_id, limit=500)
- completed_items = [item for item in checkpoints if item.get("status") == "completed"]
- completed_ids = {item["checkpoint_id"] for item in completed_items}
- latest_completed_at = completed_items[0]["created_at"] if completed_items else None
-
- if expected_ids:
- expected_set = set(expected_ids)
- completed_expected = [checkpoint_id for checkpoint_id in expected_ids if checkpoint_id in completed_ids]
- remaining_ids = [checkpoint_id for checkpoint_id in expected_ids if checkpoint_id not in completed_ids]
- completed_count = len(completed_expected)
- total_count: int | None = len(expected_ids)
- else:
- completed_count = len(completed_ids)
- total_count = None
- remaining_ids = []
-
- phase_rollups: dict[str, dict[str, Any]] = {}
- phase_order: list[str] = []
- for checkpoint_id in expected_ids or sorted(completed_ids):
- phase_key = self._checkpoint_phase_key(checkpoint_id)
- if phase_key not in phase_rollups:
- phase_rollups[phase_key] = {
- "phase_key": phase_key,
- "completed_count": 0,
- "total_count": 0 if expected_ids else None,
- "complete": False,
- "remaining_checkpoints": [],
- }
- phase_order.append(phase_key)
- rollup = phase_rollups[phase_key]
- if expected_ids:
- rollup["total_count"] = int(rollup["total_count"] or 0) + 1
- if checkpoint_id in completed_ids:
- rollup["completed_count"] += 1
- else:
- rollup["remaining_checkpoints"].append(checkpoint_id)
-
- if not expected_ids:
- for checkpoint_id in sorted(completed_ids):
- phase_key = self._checkpoint_phase_key(checkpoint_id)
- if phase_key not in phase_rollups:
- phase_rollups[phase_key] = {
- "phase_key": phase_key,
- "completed_count": 0,
- "total_count": None,
- "complete": False,
- "remaining_checkpoints": [],
- }
- phase_order.append(phase_key)
- phase_rollups[phase_key]["completed_count"] += 1
-
- grouped_progress: list[dict[str, Any]] = []
- for phase_key in phase_order:
- rollup = phase_rollups[phase_key]
- total = rollup["total_count"]
- complete = bool(total is not None and total > 0 and rollup["completed_count"] >= total)
- grouped_progress.append(
- {
- **rollup,
- "complete": complete,
- "completion_ratio": (rollup["completed_count"] / total) if total else None,
- }
- )
-
- all_expected_complete = bool(total_count is not None and total_count > 0 and completed_count >= total_count)
- return {
- "task_id": task_id,
- "completed_count": completed_count,
- "latest_completed_at": latest_completed_at,
- "expected_checkpoints": expected_ids,
- "remaining_checkpoints": remaining_ids,
- "total_count": total_count,
- "all_expected_complete": all_expected_complete,
- "completion_ratio": (completed_count / total_count) if total_count else None,
- "phase_rollups": grouped_progress,
- }
-
- def log_decision(
- self,
- title: str,
- decision: str,
- rationale: str = "",
- impact: str = "",
- task_id: str | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO decisions (title, decision, rationale, impact, task_id, actor, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- (title, decision, rationale, impact, task_id, actor, now),
- )
- decision_id = cursor.lastrowid
- self._record_activity(connection, actor, "log_decision", task_id, {"title": title})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="log_decision",
- payload={"task_id": task_id, "title": title},
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM decisions WHERE id = ?", (decision_id,))
- return row or {}
-
- def get_decisions(self, limit: int = 10, after_id: int | None = None) -> list[dict[str, Any]]:
- with self._connect() as connection:
- if after_id is not None:
- return self._fetchall_dicts(
- connection,
- "SELECT * FROM decisions WHERE id < ? ORDER BY created_at DESC LIMIT ?",
- (after_id, limit),
- )
- return self._fetchall_dicts(
- connection,
- "SELECT * FROM decisions ORDER BY created_at DESC LIMIT ?",
- (limit,),
- )
-
- def log_blocker(
- self,
- title: str,
- description: str,
- task_id: str | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO blockers (title, description, task_id, actor, status, created_at)
- VALUES (?, ?, ?, ?, 'open', ?)
- """,
- (title, description, task_id, actor, now),
- )
- blocker_id = cursor.lastrowid
- if task_id:
- connection.execute("UPDATE tasks SET status = 'blocked', updated_at = ? WHERE id = ?", (now, task_id))
- self._record_activity(connection, actor, "log_blocker", task_id, {"title": title})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="log_blocker",
- payload={"task_id": task_id, "title": title},
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM blockers WHERE id = ?", (blocker_id,))
- return row or {}
-
- def resolve_blocker(self, blocker_id: int, resolution_note: str, actor: str = "unknown") -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- blocker = self._fetchone_dict(connection, "SELECT * FROM blockers WHERE id = ?", (blocker_id,))
- if not blocker:
- return None
- connection.execute(
- """
- UPDATE blockers
- SET status = 'resolved', resolution_note = ?, resolved_at = ?
- WHERE id = ?
- """,
- (resolution_note, now, blocker_id),
- )
- if blocker.get("task_id"):
- connection.execute(
- """
- UPDATE tasks
- SET status = CASE WHEN status = 'blocked' THEN 'in_progress' ELSE status END,
- updated_at = ?
- WHERE id = ?
- """,
- (now, blocker["task_id"]),
- )
- self._record_activity(connection, actor, "resolve_blocker", blocker.get("task_id"), {"blocker_id": blocker_id})
- connection.commit()
- return self._fetchone_dict(connection, "SELECT * FROM blockers WHERE id = ?", (blocker_id,))
-
- def get_blockers(self, open_only: bool = True, limit: int = 20, after_id: int | None = None) -> list[dict[str, Any]]:
- query = "SELECT * FROM blockers"
- params: list[Any] = []
- conditions: list[str] = []
- if open_only:
- conditions.append("status = 'open'")
- if after_id is not None:
- conditions.append("id < ?")
- params.append(after_id)
- if conditions:
- query += " WHERE " + " AND ".join(conditions)
- query += " ORDER BY created_at DESC LIMIT ?"
- params.append(limit)
- with self._connect() as connection:
- return self._fetchall_dicts(connection, query, params)
-
- def create_handoff(
- self,
- summary: str,
- next_steps: str = "",
- open_questions: str = "",
- note: str = "",
- task_id: str | None = None,
- from_actor: str = "unknown",
- to_actor: str = "next-agent",
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- session = self._fetchone_dict(connection, "SELECT * FROM sessions WHERE id = ?", (session_id,)) if session_id else None
- cursor = connection.execute(
- """
- INSERT INTO handoffs (
- task_id, from_actor, to_actor, summary, next_steps, open_questions, note, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (task_id, from_actor, to_actor, summary, next_steps, open_questions, note, now, now),
- )
- handoff_id = cursor.lastrowid
- connection.execute(
- """
- INSERT INTO session_summaries (session_label, summary, actor, created_at)
- VALUES (?, ?, ?, ?)
- """,
- ((session or {}).get("session_label") or "handoff", summary, from_actor, now),
- )
- self._record_activity(connection, from_actor, "create_handoff", task_id, {"to_actor": to_actor})
- if session_id:
- connection.execute(
- """
- UPDATE sessions
- SET write_count = write_count + 1,
- last_write_at = ?,
- heartbeat_at = ?,
- last_handoff_at = ?,
- handoff_created = 1
- WHERE id = ?
- """,
- (now, now, now, session_id),
- )
- self._record_session_event(
- connection,
- session_id=session_id,
- actor=from_actor,
- event_type="create_handoff",
- payload={"task_id": task_id, "to_actor": to_actor},
- )
- connection.commit()
- handoff = self._fetchone_dict(connection, "SELECT * FROM handoffs WHERE id = ?", (handoff_id,)) or {}
- if session_id and handoff:
- handoff_lines = [
- "# Handoff",
- "",
- f"- ID: {handoff['id']}",
- f"- From: {handoff['from_actor']}",
- f"- To: {handoff['to_actor']}",
- f"- Task: {handoff['task_id'] or 'unassigned'}",
- f"- Created: {handoff['created_at']}",
- "",
- "## Summary",
- "",
- handoff["summary"],
- "",
- "## Next Steps",
- "",
- handoff["next_steps"] or "None recorded.",
- "",
- "## Open Questions",
- "",
- handoff["open_questions"] or "None recorded.",
- "",
- "## Notes",
- "",
- handoff["note"] or "None recorded.",
- "",
- ]
- write_text_atomic(self._session_dir(session_id) / "handoff.md", "\n".join(handoff_lines))
- return handoff
-
- def append_handoff_note(self, handoff_id: int, note: str, actor: str = "unknown") -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- current = self._fetchone_dict(connection, "SELECT * FROM handoffs WHERE id = ?", (handoff_id,))
- if not current:
- return None
- updated_note = f"{current['note'].rstrip()}\n\n{note}".strip()
- connection.execute(
- "UPDATE handoffs SET note = ?, updated_at = ? WHERE id = ?",
- (updated_note, now, handoff_id),
- )
- self._record_activity(connection, actor, "append_handoff_note", current.get("task_id"), {"handoff_id": handoff_id})
- connection.commit()
- return self._fetchone_dict(connection, "SELECT * FROM handoffs WHERE id = ?", (handoff_id,))
-
- def get_latest_handoff(self) -> dict[str, Any] | None:
- with self._connect() as connection:
- return self._fetchone_dict(connection, "SELECT * FROM handoffs ORDER BY created_at DESC LIMIT 1")
-
- def get_handoff(self, handoff_id: int) -> dict[str, Any] | None:
- with self._connect() as connection:
- return self._fetchone_dict(connection, "SELECT * FROM handoffs WHERE id = ?", (handoff_id,))
-
- def get_recent_handoffs(self, limit: int = 5) -> list[dict[str, Any]]:
- with self._connect() as connection:
- return self._fetchall_dicts(
- connection,
- "SELECT * FROM handoffs ORDER BY created_at DESC LIMIT ?",
- (limit,),
- )
-
- def create_daily_note_entry(
- self,
- entry: str,
- actor: str = "unknown",
- note_date: str | None = None,
- session_id: str | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- note_date = note_date or now[:10]
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO daily_entries (note_date, actor, entry, created_at)
- VALUES (?, ?, ?, ?)
- """,
- (note_date, actor, entry, now),
- )
- entry_id = cursor.lastrowid
- self._record_activity(connection, actor, "create_daily_note_entry", None, {"note_date": note_date})
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="create_daily_note_entry",
- payload={"note_date": note_date, "entry": entry},
- )
- connection.commit()
- return self._fetchone_dict(connection, "SELECT * FROM daily_entries WHERE id = ?", (entry_id,)) or {}
-
- def get_daily_entries(self, note_date: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
- query = "SELECT * FROM daily_entries"
- params: tuple[Any, ...] = ()
- if note_date:
- query += " WHERE note_date = ?"
- params = (note_date,)
- query += " ORDER BY created_at DESC LIMIT ?"
- params = params + (limit,)
- with self._connect() as connection:
- return self._fetchall_dicts(connection, query, params)
-
- def create_session_summary(self, summary: str, actor: str = "unknown", session_label: str = "manual") -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO session_summaries (session_label, summary, actor, created_at)
- VALUES (?, ?, ?, ?)
- """,
- (session_label, summary, actor, now),
- )
- summary_id = cursor.lastrowid
- self._record_activity(connection, actor, "create_session_summary", None, {"session_label": session_label})
- connection.commit()
- return self._fetchone_dict(connection, "SELECT * FROM session_summaries WHERE id = ?", (summary_id,)) or {}
-
- def get_latest_session_summary(self) -> dict[str, Any] | None:
- with self._connect() as connection:
- return self._fetchone_dict(connection, "SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT 1")
-
- def open_session(
- self,
- actor: str,
- client_name: str = "",
- model_name: str = "",
- session_label: str = "",
- workstream_key: str = "",
- workstream_title: str = "",
- project_path: str = "",
- initial_request: str = "",
- session_goal: str = "",
- task_id: str | None = None,
- require_heartbeat: bool = True,
- require_work_log: bool = True,
- heartbeat_interval_seconds: int = 900,
- work_log_interval_seconds: int = 1800,
- min_work_logs: int = 1,
- handoff_required: bool = True,
- ide_name: str = "",
- ide_version: str = "",
- ide_platform: str = "",
- os_name: str = "",
- os_version: str = "",
- ) -> dict[str, Any]:
- now = utc_now()
- session_id = f"SESSION-{uuid.uuid4().hex[:12].upper()}"
- effective_project_path = project_path or self.project_root
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO sessions (
- id, actor, client_name, model_name, session_label, workstream_key, workstream_title,
- project_path, initial_request, session_goal, task_id,
- status, opened_at, heartbeat_at, require_heartbeat, require_work_log,
- heartbeat_interval_seconds, work_log_interval_seconds, min_work_logs, write_count,
- handoff_required, handoff_created, closure_summary, last_error,
- ide_name, ide_version, ide_platform, os_name, os_version
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, '', '', ?, ?, ?, ?, ?)
- """,
- (
- session_id,
- actor,
- client_name,
- model_name,
- session_label,
- workstream_key,
- workstream_title,
- effective_project_path,
- initial_request,
- session_goal,
- task_id,
- now,
- now,
- 1 if require_heartbeat else 0,
- 1 if require_work_log else 0,
- heartbeat_interval_seconds,
- work_log_interval_seconds,
- min_work_logs,
- 1 if handoff_required else 0,
- ide_name,
- ide_version,
- ide_platform,
- os_name,
- os_version,
- ),
- )
- self._record_session_event(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="session_open",
- payload={
- "task_id": task_id,
- "client_name": client_name,
- "model_name": model_name,
- "session_label": session_label,
- "workstream_key": workstream_key,
- "workstream_title": workstream_title,
- "ide_name": ide_name,
- },
- )
- self._record_activity(connection, actor, "session_open", task_id, {"session_id": session_id})
- connection.commit()
- session = self.get_session(session_id) or {}
- self._write_session_metadata(session_id, session)
- self._append_session_jsonl(
- session_id,
- "heartbeat.jsonl",
- {
- "time": now,
- "event": "session_open",
- "actor": actor,
- "task_id": task_id,
- "project_path": effective_project_path,
- "session_label": session_label,
- "workstream_key": workstream_key,
- "ide_name": ide_name,
- },
- )
- return session
-
- def find_resumable_session(
- self,
- actor: str,
- project_path: str,
- client_name: str = "",
- model_name: str = "",
- workstream_key: str = "",
- max_age_seconds: int = 86400,
- ) -> dict[str, Any] | None:
- query = [
- "SELECT * FROM sessions WHERE status = 'open' AND actor = ? AND project_path = ?",
- ]
- params: list[Any] = [actor, project_path]
- if client_name:
- query.append("AND client_name = ?")
- params.append(client_name)
- if model_name:
- query.append("AND model_name = ?")
- params.append(model_name)
- if workstream_key:
- query.append("AND workstream_key = ?")
- params.append(workstream_key)
- query.append("ORDER BY heartbeat_at DESC LIMIT 10")
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, " ".join(query), tuple(params))
- now = datetime.now(timezone.utc)
- for row in rows:
- heartbeat_at = self._parse_utc(row.get("heartbeat_at"))
- if heartbeat_at is None:
- continue
- if int((now - heartbeat_at).total_seconds()) <= max_age_seconds:
- return row
- return None
-
- def resume_session(
- self,
- session_id: str,
- actor: str,
- *,
- client_name: str = "",
- model_name: str = "",
- session_label: str = "",
- workstream_key: str = "",
- workstream_title: str = "",
- task_id: str | None = None,
- initial_request: str = "",
- session_goal: str = "",
- ) -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- session = self._fetchone_dict(connection, "SELECT * FROM sessions WHERE id = ?", (session_id,))
- if not session:
- return None
- connection.execute(
- """
- UPDATE sessions
- SET heartbeat_at = ?,
- task_id = COALESCE(?, task_id),
- client_name = CASE WHEN ? <> '' THEN ? ELSE client_name END,
- model_name = CASE WHEN ? <> '' THEN ? ELSE model_name END,
- session_label = CASE WHEN ? <> '' THEN ? ELSE session_label END,
- workstream_key = CASE WHEN ? <> '' THEN ? ELSE workstream_key END,
- workstream_title = CASE WHEN ? <> '' THEN ? ELSE workstream_title END,
- initial_request = CASE WHEN ? <> '' THEN ? ELSE initial_request END,
- session_goal = CASE WHEN ? <> '' THEN ? ELSE session_goal END,
- last_error = ''
- WHERE id = ?
- """,
- (
- now,
- task_id,
- client_name,
- client_name,
- model_name,
- model_name,
- session_label,
- session_label,
- workstream_key,
- workstream_key,
- workstream_title,
- workstream_title,
- initial_request,
- initial_request,
- session_goal,
- session_goal,
- session_id,
- ),
- )
- self._record_session_event(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="session_resume",
- payload={
- "task_id": task_id,
- "client_name": client_name,
- "model_name": model_name,
- "session_label": session_label,
- "workstream_key": workstream_key,
- "workstream_title": workstream_title,
- },
- )
- self._record_activity(connection, actor, "session_resume", task_id or session.get("task_id"), {"session_id": session_id})
- connection.commit()
- payload = self.get_session(session_id)
- self._write_session_metadata(session_id, payload or {})
- self._append_session_jsonl(
- session_id,
- "heartbeat.jsonl",
- {
- "time": now,
- "event": "session_resume",
- "actor": actor,
- "task_id": task_id or (payload or {}).get("task_id"),
- "session_label": (payload or {}).get("session_label", ""),
- "workstream_key": (payload or {}).get("workstream_key", ""),
- },
- )
- return payload
-
- def heartbeat_session(
- self,
- session_id: str,
- actor: str,
- status_note: str = "",
- task_id: str | None = None,
- files: list[str] | None = None,
- create_work_log: bool = False,
- ) -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- session = self._fetchone_dict(connection, "SELECT * FROM sessions WHERE id = ?", (session_id,))
- if not session:
- return None
- effective_task_id = task_id or session.get("task_id")
- connection.execute(
- """
- UPDATE sessions
- SET heartbeat_at = ?, task_id = COALESCE(?, task_id)
- WHERE id = ?
- """,
- (now, effective_task_id, session_id),
- )
- self._record_session_event(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="session_heartbeat",
- payload={"status_note": status_note, "task_id": effective_task_id, "files": files or []},
- )
- self._record_activity(connection, actor, "session_heartbeat", effective_task_id, {"session_id": session_id})
- if create_work_log and status_note.strip():
- cursor = connection.execute(
- """
- INSERT INTO work_logs (task_id, actor, message, summary, files, created_at)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (
- effective_task_id,
- actor,
- status_note,
- "Heartbeat work log",
- json.dumps(files or []),
- now,
- ),
- )
- work_log_id = cursor.lastrowid
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="heartbeat_log_work",
- payload={"task_id": effective_task_id, "message": status_note, "files": files or []},
- )
- connection.commit()
- session_payload = self.get_session(session_id)
- work_log_payload = self._normalize_work_log(
- self._fetchone_dict(connection, "SELECT * FROM work_logs WHERE id = ?", (work_log_id,)) or {}
- )
- self._write_session_metadata(session_id, session_payload or {})
- self._append_session_jsonl(
- session_id,
- "heartbeat.jsonl",
- {"time": now, "event": "session_heartbeat", "actor": actor, "task_id": effective_task_id, "status_note": status_note},
- )
- self._append_session_markdown(
- session_id,
- "worklog.md",
- f"- {now} [{actor}] {status_note}",
- "Session Worklog",
- )
- return {"session": session_payload, "work_log": work_log_payload}
- connection.commit()
- session_payload = self.get_session(session_id)
- self._write_session_metadata(session_id, session_payload or {})
- self._append_session_jsonl(
- session_id,
- "heartbeat.jsonl",
- {"time": now, "event": "session_heartbeat", "actor": actor, "task_id": effective_task_id, "status_note": status_note},
- )
- return session_payload
-
- def close_session(
- self,
- session_id: str,
- actor: str,
- summary: str = "",
- create_handoff: bool = True,
- existing_handoff_id: int | None = None,
- handoff_summary: str = "",
- handoff_next_steps: str = "",
- handoff_open_questions: str = "",
- handoff_note: str = "",
- handoff_to_actor: str = "next-agent",
- ) -> dict[str, Any] | None:
- now = utc_now()
- with self._connect() as connection:
- session = self._fetchone_dict(connection, "SELECT * FROM sessions WHERE id = ?", (session_id,))
- if not session:
- return None
-
- handoff_id: int | None = existing_handoff_id
- if create_handoff and not existing_handoff_id and (handoff_summary.strip() or summary.strip()):
- effective_summary = handoff_summary.strip() or summary.strip()
- cursor = connection.execute(
- """
- INSERT INTO handoffs (
- task_id, from_actor, to_actor, summary, next_steps, open_questions, note, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- session.get("task_id"),
- actor,
- handoff_to_actor,
- effective_summary,
- handoff_next_steps,
- handoff_open_questions,
- handoff_note,
- now,
- now,
- ),
- )
- handoff_id = cursor.lastrowid
- connection.execute(
- """
- INSERT INTO session_summaries (session_label, summary, actor, created_at)
- VALUES (?, ?, ?, ?)
- """,
- (session.get("session_label") or "handoff", effective_summary, actor, now),
- )
- if summary.strip():
- connection.execute(
- """
- INSERT INTO session_summaries (session_label, summary, actor, created_at)
- VALUES (?, ?, ?, ?)
- """,
- (session.get("session_label") or "session_close", summary.strip(), actor, now),
- )
-
- connection.execute(
- """
- UPDATE sessions
- SET status = 'closed',
- closed_at = ?,
- heartbeat_at = ?,
- closure_summary = ?,
- handoff_created = CASE WHEN ? THEN 1 ELSE handoff_created END,
- last_handoff_at = CASE WHEN ? THEN ? ELSE last_handoff_at END
- WHERE id = ?
- """,
- (
- now,
- now,
- summary,
- 1 if handoff_id else 0,
- 1 if handoff_id else 0,
- now,
- session_id,
- ),
- )
- self._record_session_event(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="session_close",
- payload={"summary": summary, "handoff_id": handoff_id},
- )
- self._record_activity(connection, actor, "session_close", session.get("task_id"), {"session_id": session_id})
- connection.commit()
- session_payload = self.get_session(session_id)
- self._write_session_metadata(session_id, session_payload or {})
- self._append_session_jsonl(
- session_id,
- "heartbeat.jsonl",
- {"time": now, "event": "session_close", "actor": actor, "summary": summary, "handoff_id": handoff_id},
- )
- if summary.strip():
- self._append_session_markdown(
- session_id,
- "worklog.md",
- f"- {now} [{actor}] Session closed: {summary.strip()}",
- "Session Worklog",
- )
- if handoff_id:
- handoff = self.get_handoff(handoff_id)
- if handoff:
- handoff_lines = [
- "# Handoff",
- "",
- f"- ID: {handoff['id']}",
- f"- From: {handoff['from_actor']}",
- f"- To: {handoff['to_actor']}",
- f"- Task: {handoff['task_id'] or 'unassigned'}",
- f"- Created: {handoff['created_at']}",
- "",
- "## Summary",
- "",
- handoff["summary"],
- "",
- "## Next Steps",
- "",
- handoff["next_steps"] or "None recorded.",
- "",
- "## Open Questions",
- "",
- handoff["open_questions"] or "None recorded.",
- "",
- "## Notes",
- "",
- handoff["note"] or "None recorded.",
- "",
- ]
- write_text_atomic(self._session_dir(session_id) / "handoff.md", "\n".join(handoff_lines))
- return session_payload
-
- def get_session(self, session_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM sessions WHERE id = ?", (session_id,))
- return self._normalize_session(row)
-
- def get_active_sessions(self, limit: int = 20, after_heartbeat_at: str | None = None, after_id: str | None = None) -> list[dict[str, Any]]:
- with self._connect() as connection:
- if after_heartbeat_at is not None and after_id is not None:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM sessions
- WHERE status = 'open'
- AND (heartbeat_at < ? OR (heartbeat_at = ? AND id < ?))
- ORDER BY heartbeat_at DESC, id DESC
- LIMIT ?
- """,
- (after_heartbeat_at, after_heartbeat_at, after_id, limit),
- )
- elif after_heartbeat_at is not None:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM sessions
- WHERE status = 'open' AND heartbeat_at < ?
- ORDER BY heartbeat_at DESC
- LIMIT ?
- """,
- (after_heartbeat_at, limit),
- )
- else:
- rows = self._fetchall_dicts(
- connection,
- "SELECT * FROM sessions WHERE status = 'open' ORDER BY heartbeat_at DESC LIMIT ?",
- (limit,),
- )
- return [self._normalize_session(row) for row in rows]
-
- def upsert_session_env_info(
- self,
- session_id: str,
- ide_name: str = "",
- ide_version: str = "",
- ide_platform: str = "",
- os_name: str = "",
- os_version: str = "",
- env_variables: dict[str, str] | None = None,
- startup_context: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- """Store or update IDE/environment metadata for a session."""
- now = utc_now()
- env_json = json.dumps(env_variables or {}, ensure_ascii=True)
- startup_json = json.dumps(startup_context or {}, ensure_ascii=True)
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO session_env_info (
- session_id, ide_name, ide_version, ide_platform, os_name, os_version,
- env_variables, startup_context, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(session_id) DO UPDATE SET
- ide_name = excluded.ide_name,
- ide_version = excluded.ide_version,
- ide_platform = excluded.ide_platform,
- os_name = excluded.os_name,
- os_version = excluded.os_version,
- env_variables = excluded.env_variables,
- startup_context = excluded.startup_context,
- updated_at = excluded.updated_at
- """,
- (session_id, ide_name, ide_version, ide_platform, os_name, os_version, env_json, startup_json, now, now),
- )
- connection.commit()
- return self.get_session_env_info(session_id)
-
- def get_session_env_info(self, session_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM session_env_info WHERE session_id = ?", (session_id,))
- if not row:
- return None
- result = dict(row)
- result["env_variables"] = parse_json(result.get("env_variables") or "{}", {})
- result["startup_context"] = parse_json(result.get("startup_context") or "{}", {})
- return result
-
- def create_cross_tool_handoff(
- self,
- handoff_id: int,
- target_tool: str,
- target_env: str,
- structured_payload: dict[str, Any],
- ) -> dict[str, Any]:
- """Create a structured cross-tool handoff variant for a specific target environment."""
- now = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO cross_tool_handoffs (
- handoff_id, target_tool, target_env, structured_payload, created_at
- ) VALUES (?, ?, ?, ?, ?)
- """,
- (handoff_id, target_tool, target_env, json.dumps(structured_payload, ensure_ascii=True), now),
- )
- cross_id = int(cursor.lastrowid)
- connection.commit()
- return self._fetchone_dict(connection, "SELECT * FROM cross_tool_handoffs WHERE id = ?", (cross_id)) or {}
-
- def get_cross_tool_handoffs(self, handoff_id: int | None = None, target_tool: str | None = None) -> list[dict[str, Any]]:
- query = "SELECT * FROM cross_tool_handoffs WHERE 1=1"
- params: list[Any] = []
- if handoff_id is not None:
- query += " AND handoff_id = ?"
- params.append(handoff_id)
- if target_tool:
- query += " AND target_tool = ?"
- params.append(target_tool)
- query += " ORDER BY created_at DESC"
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, query, params)
- return [self._normalize_cross_tool_handoff(row) for row in rows]
-
- def _normalize_cross_tool_handoff(self, row: dict[str, Any] | None) -> dict[str, Any] | None:
- if not row:
- return None
- result = dict(row)
- result["structured_payload"] = parse_json(result.get("structured_payload") or "{}", {})
- return result
-
- def upsert_session_lineage(
- self,
- session_id: str,
- parent_session_id: str | None = None,
- continuation_session_id: str | None = None,
- lineage_depth: int = 0,
- ) -> dict[str, Any] | None:
- """Record or update the lineage chain for a session."""
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO session_lineage (
- session_id, parent_session_id, continuation_session_id, lineage_depth, created_at
- ) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(session_id) DO UPDATE SET
- parent_session_id = COALESCE(excluded.parent_session_id, session_lineage.parent_session_id),
- continuation_session_id = COALESCE(excluded.continuation_session_id, session_lineage.continuation_session_id),
- lineage_depth = excluded.lineage_depth
- """,
- (session_id, parent_session_id, continuation_session_id, lineage_depth, now),
- )
- connection.commit()
- return self.get_session_lineage(session_id)
-
- def get_session_lineage(self, session_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM session_lineage WHERE session_id = ?", (session_id,))
- return row
-
- def get_session_lineage_chain(self, session_id: str, max_depth: int = 10) -> list[dict[str, Any]]:
- """Traverse lineage chain from session_id up to max_depth ancestors."""
- chain: list[dict[str, Any]] = []
- current_id: str | None = session_id
- visited: set[str] = set()
- while current_id and len(chain) < max_depth:
- if current_id in visited:
- break
- visited.add(current_id)
- lineage = self.get_session_lineage(current_id)
- if not lineage:
- break
- session = self.get_session(current_id)
- chain.append({
- "session_id": current_id,
- "parent_session_id": lineage.get("parent_session_id"),
- "lineage_depth": lineage.get("lineage_depth", 0),
- "created_at": lineage.get("created_at"),
- "actor": (session or {}).get("actor", "unknown"),
- "status": (session or {}).get("status", "unknown"),
- })
- current_id = lineage.get("parent_session_id")
- return chain
-
- def detect_missing_writeback(self, include_closed: bool = False) -> list[dict[str, Any]]:
- query = "SELECT * FROM sessions"
- if not include_closed:
- query += " WHERE status = 'open'"
- query += " ORDER BY opened_at DESC"
- now = datetime.now(timezone.utc)
- issues: list[dict[str, Any]] = []
- with self._connect() as connection:
- sessions = self._fetchall_dicts(connection, query)
-
- for session in sessions:
- heartbeat_at = self._parse_utc(session.get("heartbeat_at"))
- opened_at = self._parse_utc(session.get("opened_at"))
- last_write_at = self._parse_utc(session.get("last_write_at"))
- if heartbeat_at is None or opened_at is None:
- continue
- heartbeat_age = int((now - heartbeat_at).total_seconds())
- open_age = int((now - opened_at).total_seconds())
- write_age = int((now - last_write_at).total_seconds()) if last_write_at else None
-
- if session["status"] == "open" and session["require_heartbeat"] and heartbeat_age > session["heartbeat_interval_seconds"]:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "heartbeat_overdue",
- "severity": "high",
- "details": f"No heartbeat for {heartbeat_age} seconds.",
- "recommended_action": "Call session_heartbeat immediately.",
- }
- )
-
- if session["require_work_log"]:
- if session["write_count"] < session["min_work_logs"] and open_age > session["work_log_interval_seconds"]:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "missing_initial_writeback",
- "severity": "high",
- "details": "Session has stayed open beyond the work-log interval without required write-back.",
- "recommended_action": "Call log_work or a structured write tool with the session_id.",
- }
- )
- elif last_write_at and write_age is not None and write_age > session["work_log_interval_seconds"]:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "writeback_overdue",
- "severity": "medium",
- "details": f"Last write-back was {write_age} seconds ago.",
- "recommended_action": "Write a progress log, decision, blocker, or task update.",
- }
- )
-
- stale_threshold = max(session["heartbeat_interval_seconds"] * 2, 1800)
- abandoned_threshold = max(session["heartbeat_interval_seconds"] * 4, 7200)
- if session["status"] == "open" and heartbeat_age > stale_threshold:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "stale_open_session",
- "severity": "high" if heartbeat_age > abandoned_threshold else "medium",
- "details": f"Session has been idle for {heartbeat_age} seconds without a new heartbeat.",
- "recommended_action": "Resume the session or create a recovery handoff before continuing in another tool.",
- }
- )
- if session["status"] == "open" and heartbeat_age > abandoned_threshold:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "abandoned_session",
- "severity": "high",
- "details": "Session appears abandoned and should be recovered before more work continues elsewhere.",
- "recommended_action": "Call recover_session to generate an emergency handoff and resume packet.",
- }
- )
-
- if session["status"] == "closed" and session["handoff_required"] and not session["handoff_created"]:
- issues.append(
- {
- "session_id": session["id"],
- "issue": "missing_handoff_on_close",
- "severity": "high",
- "details": "Session was closed without a recorded handoff.",
- "recommended_action": "Create a handoff or reopen the session and close it with a handoff.",
- }
- )
-
- normalized_issues: list[dict[str, Any]] = []
- for issue in issues:
- session = self.get_session(issue["session_id"]) or {}
- normalized_issues.append(
- {
- **issue,
- "session_label": session.get("session_label", issue["session_id"]),
- "workstream_key": session.get("workstream_key", ""),
- "workstream_title": session.get("workstream_title", session.get("session_label", issue["session_id"])),
- "task_id": session.get("task_id"),
- }
- )
- return normalized_issues
-
- def get_relevant_files(self, task_id: str | None = None, limit: int = 20) -> list[str]:
- task = self.get_task(task_id) if task_id else self.get_current_task()
- seen: list[str] = []
- if task:
- for file_path in task.get("relevant_files", []):
- if file_path not in seen:
- seen.append(file_path)
-
- for work_log in self.get_recent_work(limit=limit):
- if task and work_log.get("task_id") not in {None, task["id"]}:
- continue
- for file_path in work_log.get("files", []):
- if file_path not in seen:
- seen.append(file_path)
- if len(seen) >= limit:
- break
- for event in self.list_command_events(limit=limit, task_id=task["id"] if task else task_id):
- for file_path in event.get("files_changed", []):
- if file_path not in seen:
- seen.append(file_path)
- if len(seen) >= limit:
- break
- if len(seen) >= limit:
- break
- return seen[:limit]
-
- def get_table_schema(self, table_name: str) -> dict[str, Any]:
- if not re.match(r"^[A-Za-z0-9_]+$", table_name):
- raise ValueError("Invalid table name.")
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, f"PRAGMA table_info({table_name})")
- return {"table": table_name, "columns": rows}
-
- def get_project_status_snapshot(self) -> dict[str, Any]:
- current_task = self.get_current_task()
- current_task_id = current_task["id"] if current_task else None
- return {
- "app_name": self.config.app_name,
- "project_path": self.project_root,
- "current_task": current_task,
- "active_tasks": self.get_active_tasks(limit=10),
- "blockers": self.get_blockers(open_only=True, limit=10),
- "recent_work": self.get_recent_work(limit=8),
- "recent_checkpoints": self.get_recent_checkpoints(limit=self.config.checkpoints.render_limit, task_id=current_task_id),
- "current_task_progress": self.get_checkpoint_progress(current_task_id) if current_task_id else None,
- "latest_handoff": self.get_latest_handoff(),
- "decisions": self.get_decisions(limit=8),
- "relevant_files": self.get_relevant_files(current_task_id),
- "recent_commands": self.list_command_events(limit=8, task_id=current_task_id),
- "active_sessions": self.get_active_sessions(limit=10),
- "session_audit": self.detect_missing_writeback(),
- }
-
- def get_context_state_version(self, include_semantic: bool = False) -> str:
- with self._connect() as connection:
- parts = [
- ("tasks", "SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), '') AS ts FROM tasks"),
- ("work_logs", "SELECT COUNT(*) AS count, COALESCE(MAX(created_at), '') AS ts FROM work_logs"),
- ("decisions", "SELECT COUNT(*) AS count, COALESCE(MAX(created_at), '') AS ts FROM decisions"),
- ("blockers", "SELECT COUNT(*) AS count, COALESCE(MAX(COALESCE(resolved_at, created_at)), '') AS ts FROM blockers"),
- ("handoffs", "SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), '') AS ts FROM handoffs"),
- ("sessions", "SELECT COUNT(*) AS count, COALESCE(MAX(COALESCE(closed_at, heartbeat_at)), '') AS ts FROM sessions"),
- ("project_brief_sections", "SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), '') AS ts FROM project_brief_sections"),
- ("daily_entries", "SELECT COUNT(*) AS count, COALESCE(MAX(created_at), '') AS ts FROM daily_entries"),
- ("checkpoints", "SELECT COUNT(*) AS count, COALESCE(MAX(created_at), '') AS ts FROM checkpoints"),
- ("project_state", "SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), '') AS ts FROM project_state"),
- ("command_events", "SELECT COUNT(*) AS count, COALESCE(MAX(created_at), '') AS ts FROM command_events"),
- ]
- if include_semantic:
- parts.append(("semantic_descriptions", "SELECT COUNT(*) AS count, COALESCE(MAX(verified_at), '') AS ts FROM semantic_descriptions"))
- parts.append(("semantic_symbol_index", "SELECT COUNT(*) AS count, COALESCE(MAX(updated_at), '') AS ts FROM semantic_symbol_index"))
- tokens: list[str] = []
- for label, query in parts:
- row = self._fetchone_dict(connection, query) or {}
- tokens.append(f"{label}:{row.get('count', 0)}:{row.get('ts', '')}")
- return "|".join(tokens)
-
- def get_context_artifact(self, artifact_type: str, scope_key: str, params_signature: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(
- connection,
- """
- SELECT * FROM context_artifacts
- WHERE artifact_type = ? AND scope_key = ? AND params_signature = ?
- """,
- (artifact_type, scope_key, params_signature),
- )
- return self._normalize_context_artifact(row)
-
- def upsert_context_artifact(
- self,
- *,
- artifact_key: str,
- artifact_type: str,
- scope_key: str,
- params_signature: str,
- state_version: str,
- content: str,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- generated_at = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO context_artifacts (
- artifact_key, artifact_type, scope_key, params_signature,
- state_version, content, metadata, generated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(artifact_key) DO UPDATE SET
- artifact_type=excluded.artifact_type,
- scope_key=excluded.scope_key,
- params_signature=excluded.params_signature,
- state_version=excluded.state_version,
- content=excluded.content,
- metadata=excluded.metadata,
- generated_at=excluded.generated_at
- """,
- (
- artifact_key,
- artifact_type,
- scope_key,
- params_signature,
- state_version,
- content,
- json.dumps(metadata or {}, ensure_ascii=True),
- generated_at,
- ),
- )
- connection.commit()
- return self.get_context_artifact(artifact_type, scope_key, params_signature)
-
- def get_context_chunk(
- self,
- scope_key: str,
- params_signature: str,
- chunk_index: int,
- ) -> dict[str, Any] | None:
- """Retrieve a specific chunk of a context artifact by composite key."""
- chunk_key = f"{scope_key}:{params_signature}:chunk:{chunk_index}"
- with self._connect() as connection:
- row = self._fetchone_dict(
- connection,
- "SELECT * FROM context_artifacts WHERE artifact_key = ?",
- (f"context_chunk:{chunk_key}",),
- )
- return self._normalize_context_artifact(row)
-
- def upsert_context_chunk(
- self,
- *,
- scope_key: str,
- params_signature: str,
- chunk_index: int,
- total_chunks: int,
- state_version: str,
- content: str,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- """Store a context chunk artifact."""
- chunk_key = f"{scope_key}:{params_signature}:chunk:{chunk_index}"
- generated_at = utc_now()
- meta = dict(metadata or {})
- meta["chunk_index"] = chunk_index
- meta["total_chunks"] = total_chunks
- meta["is_last"] = chunk_index == total_chunks - 1
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO context_artifacts (
- artifact_key, artifact_type, scope_key, params_signature,
- state_version, content, metadata, generated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(artifact_key) DO UPDATE SET
- state_version=excluded.state_version,
- content=excluded.content,
- metadata=excluded.metadata,
- generated_at=excluded.generated_at
- """,
- (
- f"context_chunk:{chunk_key}",
- "context_chunk",
- scope_key,
- params_signature,
- state_version,
- content,
- json.dumps(meta, ensure_ascii=True),
- generated_at,
- ),
- )
- connection.commit()
- return self.get_context_chunk(scope_key, params_signature, chunk_index)
-
- def record_token_usage_event(
- self,
- *,
- event_type: str,
- operation: str,
- actor: str = "system",
- session_id: str | None = None,
- task_id: str | None = None,
- model_name: str = "",
- provider: str = "",
- client_name: str = "",
- raw_input_tokens: int = 0,
- raw_output_tokens: int = 0,
- estimated_input_tokens: int = 0,
- estimated_output_tokens: int = 0,
- compact_input_tokens: int = 0,
- compact_output_tokens: int = 0,
- saved_tokens: int = 0,
- cache_creation_input_tokens: int = 0,
- cache_read_input_tokens: int = 0,
- raw_chars: int = 0,
- compact_chars: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- created_at = utc_now()
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO token_usage_events (
- event_type, operation, actor, session_id, task_id,
- model_name, provider, client_name,
- raw_input_tokens, raw_output_tokens,
- estimated_input_tokens, estimated_output_tokens,
- compact_input_tokens, compact_output_tokens, saved_tokens,
- cache_creation_input_tokens, cache_read_input_tokens,
- raw_chars, compact_chars, metadata, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- event_type,
- operation,
- actor,
- session_id,
- task_id,
- model_name,
- provider,
- client_name,
- max(int(raw_input_tokens or 0), 0),
- max(int(raw_output_tokens or 0), 0),
- max(int(estimated_input_tokens or 0), 0),
- max(int(estimated_output_tokens or 0), 0),
- max(int(compact_input_tokens or 0), 0),
- max(int(compact_output_tokens or 0), 0),
- max(int(saved_tokens or 0), 0),
- max(int(cache_creation_input_tokens or 0), 0),
- max(int(cache_read_input_tokens or 0), 0),
- max(int(raw_chars or 0), 0),
- max(int(compact_chars or 0), 0),
- json.dumps(metadata or {}, ensure_ascii=True),
- created_at,
- ),
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM token_usage_events WHERE id = ?", (cursor.lastrowid,))
- return self._normalize_token_usage_event(row)
-
- def list_token_usage_events(
- self,
- *,
- limit: int = 50,
- operation: str | None = None,
- event_type: str | None = None,
- session_id: str | None = None,
- ) -> list[dict[str, Any]]:
- clauses: list[str] = []
- params: list[Any] = []
- if operation:
- clauses.append("operation = ?")
- params.append(operation)
- if event_type:
- clauses.append("event_type = ?")
- params.append(event_type)
- if session_id:
- clauses.append("session_id = ?")
- params.append(session_id)
- where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- f"""
- SELECT * FROM token_usage_events
- {where}
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (*params, limit),
- )
- return [self._normalize_token_usage_event(row) for row in rows]
-
- def get_token_usage_stats(
- self,
- *,
- limit: int = 200,
- operation: str | None = None,
- session_id: str | None = None,
- ) -> dict[str, Any]:
- events = self.list_token_usage_events(limit=limit, operation=operation, session_id=session_id)
- totals = {
- "raw_input_tokens": 0,
- "raw_output_tokens": 0,
- "estimated_input_tokens": 0,
- "estimated_output_tokens": 0,
- "compact_input_tokens": 0,
- "compact_output_tokens": 0,
- "saved_tokens": 0,
- "cache_creation_input_tokens": 0,
- "cache_read_input_tokens": 0,
- "raw_chars": 0,
- "compact_chars": 0,
- }
- by_operation: dict[str, dict[str, Any]] = {}
- for event in events:
- for key in totals:
- totals[key] += int(event.get(key) or 0)
- op = event.get("operation", "unknown")
- entry = by_operation.setdefault(
- op,
- {
- "operation": op,
- "event_count": 0,
- "saved_tokens": 0,
- "compact_output_tokens": 0,
- "estimated_output_tokens": 0,
- },
- )
- entry["event_count"] += 1
- entry["saved_tokens"] += int(event.get("saved_tokens") or 0)
- entry["compact_output_tokens"] += int(event.get("compact_output_tokens") or 0)
- entry["estimated_output_tokens"] += int(event.get("estimated_output_tokens") or 0)
- return {
- "event_count": len(events),
- "limit": limit,
- "filters": {
- "operation": operation,
- "session_id": session_id,
- },
- "totals": totals,
- "by_operation": sorted(by_operation.values(), key=lambda item: (-item["saved_tokens"], item["operation"])),
- "recent_events": events[: min(10, len(events))],
- }
-
- def create_raw_output_capture(
- self,
- *,
- capture_id: str,
- actor: str,
- command_text: str,
- profile: str,
- reason: str,
- output_path: str,
- preview: str = "",
- session_id: str | None = None,
- task_id: str | None = None,
- exit_code: int = 0,
- raw_chars: int = 0,
- raw_tokens_est: int = 0,
- metadata: dict[str, Any] | None = None,
- ) -> dict[str, Any] | None:
- created_at = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO raw_output_captures (
- capture_id, session_id, task_id, actor, command_text, profile,
- reason, exit_code, output_path, preview, raw_chars, raw_tokens_est,
- metadata, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- capture_id,
- session_id,
- task_id,
- actor,
- command_text,
- profile,
- reason,
- int(exit_code or 0),
- output_path,
- preview,
- max(int(raw_chars or 0), 0),
- max(int(raw_tokens_est or 0), 0),
- json.dumps(metadata or {}, ensure_ascii=True),
- created_at,
- ),
- )
- connection.commit()
- return self.get_raw_output_capture(capture_id)
-
- def get_raw_output_capture(self, capture_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM raw_output_captures WHERE capture_id = ?", (capture_id,))
- return self._normalize_raw_output_capture(row)
-
- def record_command_event(
- self,
- *,
- actor: str,
- command_text: str,
- cwd: str = "",
- event_kind: str = "completed",
- status: str = "completed",
- risk_level: str = "normal",
- exit_code: int = 0,
- duration_ms: int = 0,
- summary: str = "",
- stdout_summary: str = "",
- stderr_summary: str = "",
- output_profile: str = "",
- raw_capture_id: str | None = None,
- raw_output_available: bool = False,
- files_changed: list[str] | None = None,
- metadata: dict[str, Any] | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- ) -> dict[str, Any] | None:
- created_at = utc_now()
- normalized_files = [
- self._normalize_project_file_path(path)
- for path in (files_changed or [])
- if isinstance(path, str) and path.strip()
- ]
- normalized_cwd = self._normalize_project_file_path(cwd) if cwd else ""
- payload = {
- "command_text": command_text,
- "status": status,
- "exit_code": int(exit_code or 0),
- "duration_ms": max(int(duration_ms or 0), 0),
- "raw_capture_id": raw_capture_id,
- "files_changed": normalized_files,
- }
- with self._connect() as connection:
- cursor = connection.execute(
- """
- INSERT INTO command_events (
- session_id, task_id, actor, command_text, cwd, event_kind, status,
- risk_level, exit_code, duration_ms, summary, stdout_summary, stderr_summary,
- output_profile, raw_capture_id, raw_output_available, files_changed, metadata, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- session_id,
- task_id,
- actor,
- command_text,
- normalized_cwd,
- event_kind or "completed",
- status or "completed",
- risk_level or "normal",
- int(exit_code or 0),
- max(int(duration_ms or 0), 0),
- summary or "",
- stdout_summary or "",
- stderr_summary or "",
- output_profile or "",
- raw_capture_id,
- 1 if raw_output_available else 0,
- json.dumps(normalized_files, ensure_ascii=True),
- json.dumps(metadata or {}, ensure_ascii=True),
- created_at,
- ),
- )
- event_id = cursor.lastrowid
- self._record_activity(connection, actor, "record_command_event", task_id, payload)
- self._touch_session_write(
- connection,
- session_id=session_id,
- actor=actor,
- event_type="record_command_event",
- payload={"task_id": task_id, **payload},
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM command_events WHERE id = ?", (event_id,))
- if session_id:
- self._append_session_markdown(
- session_id,
- "commands.md",
- f"- {created_at} [{status}] `{command_text}`"
- + (f" (exit {int(exit_code or 0)})" if int(exit_code or 0) else "")
- + (f"\n - Summary: {summary}" if summary else ""),
- "Session Commands",
- )
- return self._normalize_command_event(row)
-
- def get_command_event(self, event_id: int) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM command_events WHERE id = ?", (event_id,))
- return self._normalize_command_event(row)
-
- def list_command_events(
- self,
- *,
- limit: int = 20,
- after_id: int | None = None,
- session_id: str | None = None,
- task_id: str | None = None,
- status: str | None = None,
- actor: str | None = None,
- ) -> list[dict[str, Any]]:
- clauses: list[str] = []
- params: list[Any] = []
- if session_id:
- clauses.append("session_id = ?")
- params.append(session_id)
- if task_id:
- clauses.append("task_id = ?")
- params.append(task_id)
- if status:
- clauses.append("status = ?")
- params.append(status)
- if actor:
- clauses.append("actor = ?")
- params.append(actor)
- if after_id is not None:
- clauses.append("id < ?")
- params.append(after_id)
- where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- f"""
- SELECT * FROM command_events
- {where}
- ORDER BY created_at DESC, id DESC
- LIMIT ?
- """,
- (*params, limit),
- )
- return [self._normalize_command_event(row) for row in rows]
-
- def get_last_command_result(
- self,
- *,
- session_id: str | None = None,
- task_id: str | None = None,
- actor: str | None = None,
- ) -> dict[str, Any] | None:
- events = self.list_command_events(limit=1, session_id=session_id, task_id=task_id, actor=actor)
- return events[0] if events else None
-
- def get_command_failures(
- self,
- *,
- limit: int = 20,
- session_id: str | None = None,
- task_id: str | None = None,
- actor: str | None = None,
- ) -> list[dict[str, Any]]:
- with self._connect() as connection:
- clauses = ["(status = 'failed' OR exit_code <> 0)"]
- params: list[Any] = []
- if session_id:
- clauses.append("session_id = ?")
- params.append(session_id)
- if task_id:
- clauses.append("task_id = ?")
- params.append(task_id)
- if actor:
- clauses.append("actor = ?")
- params.append(actor)
- rows = self._fetchall_dicts(
- connection,
- f"""
- SELECT * FROM command_events
- WHERE {' AND '.join(clauses)}
- ORDER BY created_at DESC, id DESC
- LIMIT ?
- """,
- (*params, limit),
- )
- return [self._normalize_command_event(row) for row in rows]
-
- def get_command_events_since(self, since_timestamp: str, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM command_events
- WHERE created_at >= ?
- ORDER BY created_at DESC, id DESC
- LIMIT ?
- """,
- (since_timestamp, limit),
- )
- return [self._normalize_command_event(row) for row in rows]
-
- def get_file_fingerprints(self) -> dict[str, dict[str, Any]]:
- """Return current file fingerprints keyed by file path."""
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, "SELECT * FROM semantic_file_fingerprints")
- return {row["file_path"]: dict(row) for row in rows}
-
- def is_context_stale(self, cached_artifact: dict[str, Any]) -> bool:
- """Check if a cached context artifact is stale based on file fingerprint changes.
-
- Compares the fingerprint set stored on the artifact against the current
- file fingerprints. Returns True if any tracked file has changed.
- Returns False if there is no fingerprint data to compare against (empty table)
- so that the state_version check is the authoritative staleness signal.
- """
- meta = cached_artifact.get("metadata") or {}
- stored_fingerprints: dict[str, str] = meta.get("fingerprint_set") or {}
- if not stored_fingerprints:
- return False
- current = self.get_file_fingerprints()
- if not current:
- return False
- for file_path, stored_fingerprint in stored_fingerprints.items():
- current_entry = current.get(file_path)
- if not current_entry:
- return True
- if current_entry.get("fingerprint") != stored_fingerprint:
- return True
- return False
-
- def get_recent_work_since(self, since_timestamp: str, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM work_logs
- WHERE created_at >= ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (since_timestamp, limit),
- )
- return [self._normalize_work_log(row) for row in rows]
-
- def get_decisions_since(self, since_timestamp: str, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM decisions
- WHERE created_at >= ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (since_timestamp, limit),
- )
- return rows
-
- def get_blockers_since(self, since_timestamp: str, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM blockers
- WHERE created_at >= ? OR COALESCE(resolved_at, '') >= ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (since_timestamp, since_timestamp, limit),
- )
- return rows
-
- def get_handoffs_since(self, since_timestamp: str, limit: int = 10) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM handoffs
- WHERE created_at >= ?
- ORDER BY created_at DESC
- LIMIT ?
- """,
- (since_timestamp, limit),
- )
- return rows
-
- def get_tasks_updated_since(self, since_timestamp: str, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM tasks
- WHERE updated_at >= ? OR created_at >= ?
- ORDER BY updated_at DESC
- LIMIT ?
- """,
- (since_timestamp, since_timestamp, limit),
- )
- return [self._normalize_task(row) for row in rows]
-
- # =========================================================================
- # Semantic Knowledge
- # =========================================================================
-
- def replace_semantic_index(self, entities: list[dict[str, Any]], file_fingerprints: list[dict[str, Any]]) -> dict[str, Any]:
- with self._connect() as connection:
- previous_rows = self._fetchall_dicts(connection, "SELECT file_path, fingerprint FROM semantic_file_fingerprints")
- previous = {row["file_path"]: row["fingerprint"] for row in previous_rows}
-
- connection.execute("DELETE FROM semantic_symbol_index")
- connection.execute("DELETE FROM semantic_file_fingerprints")
-
- for item in file_fingerprints:
- connection.execute(
- """
- INSERT INTO semantic_file_fingerprints (file_path, fingerprint, file_size, modified_at, scanned_at)
- VALUES (?, ?, ?, ?, ?)
- """,
- (item["file_path"], item["fingerprint"], item["file_size"], item["modified_at"], item["scanned_at"]),
- )
-
- seen_entity_keys: set[str] = set()
- for entity in entities:
- entity_key = entity["entity_key"]
- if entity_key in seen_entity_keys:
- continue
- seen_entity_keys.add(entity_key)
- connection.execute(
- """
- INSERT INTO semantic_symbol_index (
- entity_key, entity_type, name, file_path, symbol_path, signature, line_number,
- feature_tags, source_files, source_fingerprint, summary_hint, metadata, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- entity_key,
- entity["entity_type"],
- entity["name"],
- entity["file_path"],
- entity["symbol_path"],
- entity["signature"],
- entity["line_number"],
- json.dumps(entity.get("feature_tags", [])),
- json.dumps(entity.get("source_files", [])),
- entity["source_fingerprint"],
- entity.get("summary_hint", ""),
- json.dumps(entity.get("metadata", {})),
- entity["updated_at"],
- ),
- )
-
- current_rows = self._fetchall_dicts(connection, "SELECT entity_key, source_fingerprint FROM semantic_symbol_index")
- current_fingerprints = {row["entity_key"]: row["source_fingerprint"] for row in current_rows}
- description_rows = self._fetchall_dicts(connection, "SELECT entity_key, source_fingerprint FROM semantic_descriptions")
- stale_count = 0
- for row in description_rows:
- current_fingerprint = current_fingerprints.get(row["entity_key"])
- if current_fingerprint != row["source_fingerprint"]:
- connection.execute("UPDATE semantic_descriptions SET stale = 1 WHERE entity_key = ?", (row["entity_key"],))
- stale_count += 1
- connection.commit()
-
- changed_files = [path for path, fingerprint in previous.items() if any(item["file_path"] == path and item["fingerprint"] != fingerprint for item in file_fingerprints)]
- added_files = [item["file_path"] for item in file_fingerprints if item["file_path"] not in previous]
- return {
- "entity_count": len(entities),
- "file_count": len(file_fingerprints),
- "changed_files": sorted(changed_files),
- "added_files": sorted(added_files),
- "stale_descriptions": stale_count,
- }
-
- def get_symbol_index_entry(self, entity_key: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM semantic_symbol_index WHERE entity_key = ?", (entity_key,))
- return self._normalize_semantic_index_row(row)
-
- def get_module_index(self, module_path: str) -> dict[str, Any] | None:
- normalized = self._normalize_project_file_path(module_path)
- with self._connect() as connection:
- row = self._fetchone_dict(
- connection,
- "SELECT * FROM semantic_symbol_index WHERE entity_type = 'module' AND file_path = ?",
- (normalized,),
- )
- return self._normalize_semantic_index_row(row)
-
- def get_feature_index(self, feature_name: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(
- connection,
- "SELECT * FROM semantic_symbol_index WHERE entity_type = 'feature' AND lower(name) = lower(?)",
- (feature_name,),
- )
- return self._normalize_semantic_index_row(row)
-
- def get_symbol_candidates(self, symbol_name: str, module_path: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
- query = "SELECT * FROM semantic_symbol_index WHERE entity_type IN ('function', 'class') AND name = ?"
- params: list[Any] = [symbol_name]
- if module_path:
- query += " AND file_path = ?"
- params.append(self._normalize_project_file_path(module_path))
- query += " ORDER BY file_path ASC, line_number ASC LIMIT ?"
- params.append(limit)
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, query, tuple(params))
- return [self._normalize_semantic_index_row(row) for row in rows]
-
- def search_semantic_index(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
- like = f"%{query.lower()}%"
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM semantic_symbol_index
- WHERE lower(name) LIKE ? OR lower(file_path) LIKE ? OR lower(symbol_path) LIKE ? OR lower(summary_hint) LIKE ?
- ORDER BY
- CASE
- WHEN lower(name) = lower(?) THEN 0
- WHEN lower(file_path) = lower(?) THEN 1
- ELSE 2
- END,
- entity_type ASC,
- file_path ASC
- LIMIT ?
- """,
- (like, like, like, like, query, query, limit),
- )
- return [self._normalize_semantic_index_row(row) for row in rows]
-
- def get_related_symbols(self, entity_key: str, limit: int = 8) -> list[dict[str, Any]]:
- origin = self.get_symbol_index_entry(entity_key)
- if not origin:
- return []
- with self._connect() as connection:
- same_file = self._fetchall_dicts(
- connection,
- """
- SELECT * FROM semantic_symbol_index
- WHERE entity_key != ? AND file_path = ? AND entity_type IN ('module', 'function', 'class')
- ORDER BY entity_type ASC, line_number ASC
- LIMIT ?
- """,
- (entity_key, origin["file_path"], limit),
- )
- rows = [self._normalize_semantic_index_row(row) for row in same_file]
- if len(rows) >= limit:
- return rows[:limit]
- feature_tags = origin.get("feature_tags", [])
- if not feature_tags:
- return rows[:limit]
- with self._connect() as connection:
- others = self._fetchall_dicts(
- connection,
- "SELECT * FROM semantic_symbol_index WHERE entity_key != ? ORDER BY updated_at DESC",
- (entity_key,),
- )
- seen = {row["entity_key"] for row in rows}
- for row in others:
- normalized = self._normalize_semantic_index_row(row)
- if normalized["entity_key"] in seen:
- continue
- if set(normalized.get("feature_tags", [])) & set(feature_tags):
- rows.append(normalized)
- seen.add(normalized["entity_key"])
- if len(rows) >= limit:
- break
- return rows[:limit]
-
- def get_symbol_index_stats(self) -> dict[str, Any]:
- with self._connect() as connection:
- counts = self._fetchall_dicts(
- connection,
- "SELECT entity_type, COUNT(*) AS count FROM semantic_symbol_index GROUP BY entity_type ORDER BY entity_type ASC",
- )
- file_count_row = self._fetchone_dict(connection, "SELECT COUNT(*) AS count FROM semantic_file_fingerprints")
- return {
- "entity_counts": {row["entity_type"]: row["count"] for row in counts},
- "tracked_files": (file_count_row or {}).get("count", 0),
- }
-
- def upsert_semantic_description(self, description: dict[str, Any]) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO semantic_descriptions (
- entity_key, entity_type, name, file_path, symbol_path, signature,
- purpose, why_it_exists, how_it_is_used, inputs_outputs, side_effects, risks,
- related_files, related_decisions, related_tasks, related_symbols,
- source_fingerprint, generated_at, verified_at, stale, metadata,
- llm_model, llm_latency_ms, llm_input_tokens, llm_output_tokens, llm_generated,
- language
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(entity_key) DO UPDATE SET
- entity_type=excluded.entity_type,
- name=excluded.name,
- file_path=excluded.file_path,
- symbol_path=excluded.symbol_path,
- signature=excluded.signature,
- purpose=excluded.purpose,
- why_it_exists=excluded.why_it_exists,
- how_it_is_used=excluded.how_it_is_used,
- inputs_outputs=excluded.inputs_outputs,
- side_effects=excluded.side_effects,
- risks=excluded.risks,
- related_files=excluded.related_files,
- related_decisions=excluded.related_decisions,
- related_tasks=excluded.related_tasks,
- related_symbols=excluded.related_symbols,
- source_fingerprint=excluded.source_fingerprint,
- generated_at=excluded.generated_at,
- verified_at=excluded.verified_at,
- stale=excluded.stale,
- metadata=excluded.metadata,
- llm_model=excluded.llm_model,
- llm_latency_ms=excluded.llm_latency_ms,
- llm_input_tokens=excluded.llm_input_tokens,
- llm_output_tokens=excluded.llm_output_tokens,
- llm_generated=excluded.llm_generated,
- language=excluded.language
- """,
- (
- description["entity_key"],
- description["entity_type"],
- description["name"],
- description["file"],
- description.get("symbol_path", description["name"]),
- description.get("signature", ""),
- description["purpose"],
- description["why_it_exists"],
- description["how_it_is_used"],
- description["inputs_outputs"],
- description["side_effects"],
- description["risks"],
- json.dumps(description.get("related_files", [])),
- json.dumps(description.get("related_decisions", [])),
- json.dumps(description.get("related_tasks", [])),
- json.dumps(description.get("related_symbols", [])),
- description["source_fingerprint"],
- description.get("generated_at", now),
- description.get("verified_at", now),
- 0 if description.get("freshness", "fresh") == "fresh" else 1,
- json.dumps(description.get("metadata", {})),
- description.get("llm_model"),
- description.get("llm_latency_ms"),
- description.get("llm_input_tokens"),
- description.get("llm_output_tokens"),
- description.get("llm_model") is not None,
- description.get("language"),
- ),
- )
- connection.commit()
- return self.get_semantic_description(description["entity_key"]) or {}
-
- def get_semantic_description(self, entity_key: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM semantic_descriptions WHERE entity_key = ?", (entity_key,))
- return self._normalize_semantic_description(row)
-
- def get_cached_semantic_descriptions(
- self,
- entity_type: str | None = None,
- fresh_only: bool = False,
- limit: int = 50,
- ) -> list[dict[str, Any]]:
- conditions: list[str] = []
- params: list[Any] = []
- if entity_type:
- conditions.append("entity_type = ?")
- params.append(entity_type)
- if fresh_only:
- conditions.append("stale = 0")
- where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
- with self._connect() as connection:
- rows = self._fetchall_dicts(
- connection,
- f"SELECT * FROM semantic_descriptions {where} ORDER BY verified_at DESC LIMIT ?",
- tuple([*params, limit]),
- )
- return [self._normalize_semantic_description(row) for row in rows]
-
- def invalidate_semantic_cache(
- self,
- entity_key: str | None = None,
- file_paths: list[str] | None = None,
- ) -> dict[str, Any]:
- normalized_paths = [self._normalize_project_file_path(item) for item in (file_paths or [])]
- with self._connect() as connection:
- if entity_key:
- cursor = connection.execute("UPDATE semantic_descriptions SET stale = 1 WHERE entity_key = ?", (entity_key,))
- elif normalized_paths:
- placeholders = ",".join("?" * len(normalized_paths))
- cursor = connection.execute(
- f"UPDATE semantic_descriptions SET stale = 1 WHERE file_path IN ({placeholders})",
- tuple(normalized_paths),
- )
- else:
- cursor = connection.execute("UPDATE semantic_descriptions SET stale = 1")
- connection.commit()
- return {"invalidated": cursor.rowcount, "entity_key": entity_key, "file_paths": normalized_paths}
-
- def get_tasks_for_files(self, file_paths: list[str], limit: int = 10) -> list[dict[str, Any]]:
- normalized = {self._normalize_project_file_path(item) for item in file_paths if item}
- if not normalized:
- return []
- matches: list[dict[str, Any]] = []
- for task in self.get_active_tasks(limit=200):
- task_files = {self._normalize_project_file_path(item) for item in task.get("relevant_files", [])}
- if task_files & normalized:
- matches.append(task)
- if len(matches) >= limit:
- break
- return matches[:limit]
-
- def get_related_decisions_for_files(self, file_paths: list[str], limit: int = 10) -> list[dict[str, Any]]:
- tasks = self.get_tasks_for_files(file_paths, limit=20)
- task_ids = {item["id"] for item in tasks}
- decisions = self.get_decisions(limit=100)
- related = [item for item in decisions if item.get("task_id") in task_ids]
- return related[:limit]
-
- def get_recent_file_activity(self, limit: int = 10) -> list[str]:
- seen: list[str] = []
- for row in self.get_recent_work(limit=100):
- for file_path in row.get("files", []):
- normalized = self._normalize_project_file_path(file_path)
- if normalized not in seen:
- seen.append(normalized)
- if len(seen) >= limit:
- return seen
- return seen
-
- def create_scan_job(
- self,
- job_id: str,
- *,
- job_type: str,
- project_path: str,
- requested_by: str,
- force_refresh: bool,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO scan_jobs (
- id, job_type, project_path, status, requested_by, force_refresh,
- requested_at, started_at, finished_at, progress_message, result_json, error_text
- )
- VALUES (?, ?, ?, 'queued', ?, ?, ?, NULL, NULL, '', '{}', '')
- """,
- (job_id, job_type, project_path, requested_by, int(force_refresh), now),
- )
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM scan_jobs WHERE id = ?", (job_id,))
- return self._normalize_scan_job(row) or {}
-
- def update_scan_job(
- self,
- job_id: str,
- *,
- status: str | None = None,
- started_at: str | None = None,
- finished_at: str | None = None,
- progress_message: str | None = None,
- result: dict[str, Any] | None = None,
- error_text: str | None = None,
- ) -> dict[str, Any] | None:
- updates: list[str] = []
- values: list[Any] = []
- if status is not None:
- updates.append("status = ?")
- values.append(status)
- if started_at is not None:
- updates.append("started_at = ?")
- values.append(started_at)
- if finished_at is not None:
- updates.append("finished_at = ?")
- values.append(finished_at)
- if progress_message is not None:
- updates.append("progress_message = ?")
- values.append(progress_message)
- if result is not None:
- updates.append("result_json = ?")
- values.append(json.dumps(result, ensure_ascii=True))
- if error_text is not None:
- updates.append("error_text = ?")
- values.append(error_text)
- if not updates:
- return self.get_scan_job(job_id)
- values.append(job_id)
- with self._connect() as connection:
- connection.execute(f"UPDATE scan_jobs SET {', '.join(updates)} WHERE id = ?", tuple(values))
- connection.commit()
- row = self._fetchone_dict(connection, "SELECT * FROM scan_jobs WHERE id = ?", (job_id,))
- return self._normalize_scan_job(row)
-
- def get_scan_job(self, job_id: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM scan_jobs WHERE id = ?", (job_id,))
- return self._normalize_scan_job(row)
-
- def get_active_scan_job(self, *, job_type: str = "code_atlas") -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(
- connection,
- """
- SELECT * FROM scan_jobs
- WHERE project_path = ? AND job_type = ? AND status IN ('queued', 'running')
- ORDER BY requested_at DESC
- LIMIT 1
- """,
- (self.project_root, job_type),
- )
- return self._normalize_scan_job(row)
-
- def list_scan_jobs(self, status: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
- with self._connect() as connection:
- if status:
- rows = self._fetchall_dicts(
- connection,
- "SELECT * FROM scan_jobs WHERE project_path = ? AND status = ? ORDER BY requested_at DESC LIMIT ?",
- (self.project_root, status, limit),
- )
- else:
- rows = self._fetchall_dicts(
- connection,
- "SELECT * FROM scan_jobs WHERE project_path = ? ORDER BY requested_at DESC LIMIT ?",
- (self.project_root, limit),
- )
- return [self._normalize_scan_job(row) for row in rows]
-
- def search_notes(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
- results: list[dict[str, Any]] = []
- normalized = query.lower()
- vault_root = self.project_config.vault_path
- for note_path in sorted(vault_root.rglob("*.md")):
- try:
- content = note_path.read_text(encoding="utf-8")
- except OSError:
- continue
- if normalized not in content.lower() and normalized not in note_path.name.lower():
- continue
- line_number = 1
- excerpt = ""
- for index, line in enumerate(content.splitlines(), start=1):
- if normalized in line.lower():
- line_number = index
- excerpt = line.strip()
- break
- results.append(
- {
- "path": str(note_path.relative_to(vault_root)).replace("\\", "/"),
- "line": line_number,
- "excerpt": excerpt,
- }
- )
- if len(results) >= limit:
- break
- return results
-
- def read_note(self, path: str) -> dict[str, Any]:
- vault_root = self.project_config.vault_path.resolve()
- note_path = (vault_root / path).resolve()
- if vault_root not in note_path.parents and note_path != vault_root:
- raise ValueError("Requested note is outside the Obsidian vault.")
- if not note_path.exists():
- raise FileNotFoundError(path)
- return {
- "path": str(note_path.relative_to(vault_root)).replace("\\", "/"),
- "content": note_path.read_text(encoding="utf-8"),
- }
-
- # =========================================================================
- # Phase 1: Task Templates
- # =========================================================================
-
- def get_task_templates(self) -> list[dict[str, Any]]:
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, "SELECT * FROM task_templates ORDER BY name ASC")
- for row in rows:
- row["tags"] = parse_json(row.get("tags"), [])
- return rows
-
- def get_task_template(self, name: str) -> dict[str, Any] | None:
- with self._connect() as connection:
- row = self._fetchone_dict(connection, "SELECT * FROM task_templates WHERE name = ?", (name,))
- if row:
- row["tags"] = parse_json(row.get("tags"), [])
- return row
-
- def create_task_template(
- self,
- name: str,
- title_template: str,
- description_template: str,
- priority: str = "medium",
- tags: list[str] | None = None,
- ) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as connection:
- connection.execute(
- """
- INSERT INTO task_templates
- (name, title_template, description_template, priority, tags, created_at)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (name, title_template, description_template, priority, json.dumps(tags or []), now),
- )
- connection.commit()
- return self.get_task_template(name) or {}
-
- def delete_task_template(self, name: str) -> bool:
- with self._connect() as connection:
- cursor = connection.execute("DELETE FROM task_templates WHERE name = ?", (name,))
- connection.commit()
- return cursor.rowcount > 0
-
- def create_task_from_template(
- self,
- template_name: str,
- variables: dict[str, str] | None = None,
- actor: str = "unknown",
- session_id: str | None = None,
- ) -> dict[str, Any] | None:
- template = self.get_task_template(template_name)
- if not template:
- raise ValueError(f"Template '{template_name}' not found. Available: {[t['name'] for t in self.get_task_templates()]}")
-
- variables = variables or {}
-
- # Replace template variables
- def apply_template(text: str) -> str:
- result = text
- for key, value in variables.items():
- result = result.replace(f"{{{key}}}", value)
- # Fail if any placeholder remains
- import re
-
- remaining = re.findall(r"\{(\w+)\}", result)
- if remaining:
- raise ValueError(f"Missing template variables: {remaining}. Provide values for: {list(variables.keys())}")
- return result
-
- title = apply_template(template["title_template"])
- description = apply_template(template["description_template"])
-
- return self.create_task(
- title=title,
- description=description,
- priority=template["priority"],
- tags=template["tags"],
- actor=actor,
- session_id=session_id,
- )
-
- # =========================================================================
- # Phase 1: Quick Log
- # =========================================================================
-
- def quick_log(self, message: str, files: list[str] | None = None, actor: str = "quick-log", session_id: str | None = None) -> dict[str, Any]:
- current_task = self.get_current_task()
- task_id = current_task["id"] if current_task else None
- return self.log_work(message=message, task_id=task_id, actor=actor, files=files, session_id=session_id)
-
- # =========================================================================
- # Phase 1: Audit Log
- # =========================================================================
-
- def get_audit_log(
- self,
- actor: str | None = None,
- task_id: str | None = None,
- action_type: str | None = None,
- from_date: str | None = None,
- to_date: str | None = None,
- limit: int = 100,
- after_id: int | None = None,
- include_ai_only: bool = False,
- ) -> dict[str, Any]:
- conditions: list[str] = []
- params: list[Any] = []
-
- if actor:
- conditions.append("actor = ?")
- params.append(actor)
- if task_id:
- conditions.append("task_id = ?")
- params.append(task_id)
- if action_type:
- conditions.append("action = ?")
- params.append(action_type)
- if from_date:
- conditions.append("created_at >= ?")
- params.append(from_date)
- if to_date:
- conditions.append("created_at <= ?")
- params.append(to_date)
- if include_ai_only:
- conditions.append("actor NOT IN ('ctx', 'manual', 'human')")
- if after_id is not None:
- conditions.append("id < ?")
- params.append(after_id)
-
- where_clause = " AND ".join(conditions) if conditions else "1=1"
- query = f"SELECT * FROM agent_activity WHERE {where_clause} ORDER BY created_at DESC, id DESC LIMIT ?"
- params.append(limit)
-
- with self._connect() as connection:
- rows = self._fetchall_dicts(connection, query, tuple(params))
-
- # Summarize by actor and action type
- by_actor: dict[str, int] = {}
- by_action: dict[str, int] = {}
- for row in rows:
- a = row["actor"]
- ac = row["action"]
- by_actor[a] = by_actor.get(a, 0) + 1
- by_action[ac] = by_action.get(ac, 0) + 1
-
- events = []
- for seq, row in enumerate(rows, start=1):
- payload = parse_json(row.get("payload"), {})
- summary = ""
- if row["action"] == "log_work":
- summary = payload.get("message", "") or payload.get("summary", "")
- elif row["action"] == "create_task":
- summary = f"Created task: {payload.get('title', '')}"
- elif row["action"] == "update_task":
- fields = payload.get("fields", [])
- summary = f"Updated task fields: {', '.join(fields) if fields else 'unknown'}"
- elif row["action"] == "session_open":
- summary = f"Session opened by {payload.get('client_name', '')}"
- elif row["action"] == "session_close":
- summary = "Session closed"
- elif row["action"] == "log_decision":
- summary = f"Decision: {payload.get('title', '')}"
- elif row["action"] == "log_blocker":
- summary = f"Blocker: {payload.get('title', '')}"
- elif row["action"] == "resolve_blocker":
- summary = f"Resolved blocker #{payload.get('blocker_id', '')}"
- elif row["action"] == "create_handoff":
- summary = f"Handoff to {payload.get('to_actor', '')}"
- events.append(
- {
- "seq": seq,
- "id": row["id"],
- "actor": row["actor"],
- "action": row["action"],
- "task_id": row["task_id"],
- "time": row["created_at"],
- "summary": summary,
- "payload": payload,
- }
- )
-
- return {
- "total_events": len(events),
- "events": events,
- "by_actor": dict(sorted(by_actor.items(), key=lambda x: -x[1])),
- "by_action": dict(sorted(by_action.items(), key=lambda x: -x[1])),
- "has_more": len(events) == limit,
- "next_cursor": events[-1]["id"] if events else None,
- }
-
- # =========================================================================
- # Phase 2: Reset Project
- # =========================================================================
-
- VALID_RESET_SCOPES = {"tasks", "blockers", "sessions", "work_logs", "decisions", "handoffs", "full"}
-
- def reset_project(self, scope: str, actor: str = "unknown") -> dict[str, Any]:
- """Wipe project data by scope. Always creates audit trail before wiping."""
- if scope not in self.VALID_RESET_SCOPES:
- raise ValueError(f"Invalid scope '{scope}'. Valid: {sorted(self.VALID_RESET_SCOPES)}")
-
- now = utc_now()
- counts: dict[str, int] = {}
-
- with self._connect() as connection:
- if scope in {"tasks", "full"}:
- # Count before delete
- counts["tasks"] = connection.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
- counts["current_task"] = connection.execute("SELECT COUNT(*) FROM project_state WHERE key='current_task_id'").fetchone()[0]
- if counts["tasks"]:
- connection.execute("DELETE FROM tasks")
- connection.execute("UPDATE project_state SET value='', updated_at=? WHERE key='current_task_id'", (now,))
-
- if scope in {"blockers", "full"}:
- counts["blockers"] = connection.execute("SELECT COUNT(*) FROM blockers").fetchone()[0]
- if counts["blockers"]:
- connection.execute("DELETE FROM blockers")
-
- if scope in {"sessions", "full"}:
- counts["sessions"] = connection.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
- counts["session_events"] = connection.execute("SELECT COUNT(*) FROM session_events").fetchone()[0]
- if counts["sessions"]:
- connection.execute("DELETE FROM session_events")
- connection.execute("DELETE FROM sessions")
-
- if scope in {"work_logs", "full"}:
- counts["work_logs"] = connection.execute("SELECT COUNT(*) FROM work_logs").fetchone()[0]
- if counts["work_logs"]:
- connection.execute("DELETE FROM work_logs")
-
- if scope in {"decisions", "full"}:
- counts["decisions"] = connection.execute("SELECT COUNT(*) FROM decisions").fetchone()[0]
- if counts["decisions"]:
- connection.execute("DELETE FROM decisions")
-
- if scope in {"handoffs", "full"}:
- counts["handoffs"] = connection.execute("SELECT COUNT(*) FROM handoffs").fetchone()[0]
- if counts["handoffs"]:
- connection.execute("DELETE FROM handoffs")
-
- if scope == "full":
- # Keep brief sections and project state key only
- counts["brief_sections"] = 4 # Always 4 default sections
- connection.execute("DELETE FROM agent_activity")
- connection.execute("DELETE FROM session_summaries")
- connection.execute("DELETE FROM daily_entries")
-
- connection.commit()
-
- total_deleted = sum(v for k, v in counts.items() if k != "current_task" and k != "brief_sections")
-
- # Always record decision BEFORE wiping (survives the wipe)
- self.log_decision(
- title=f"Project reset: {scope}",
- decision=f"Reset scope '{scope}' — deleted {total_deleted} total records",
- rationale=f"Manual reset initiated by {actor}",
- impact=f"Counts: {counts}",
- actor=actor,
- )
-
- return {
- "scope": scope,
- "counts": counts,
- "total_deleted": total_deleted,
- "message": f"Project reset complete. Deleted {total_deleted} records across scopes: {scope}",
- }
-
- # =========================================================================
- # Phase 2: Bulk Task Operations
- # =========================================================================
-
- def bulk_task_ops(self, operations: list[dict[str, Any]], actor: str = "unknown") -> dict[str, Any]:
- """Execute multiple task operations atomically. All succeed or all fail."""
- if not operations:
- return {"results": [], "total": 0, "succeeded": 0, "failed": 0}
-
- VALID_ACTIONS = {"create", "update", "close", "delete", "set_current"}
- results: list[dict[str, Any]] = []
-
- # Pre-validate all operations
- for i, op in enumerate(operations):
- action = op.get("action")
- if action not in VALID_ACTIONS:
- raise ValueError(f"Operation {i}: invalid action '{action}'. Valid: {VALID_ACTIONS}")
- if action in {"update", "close", "delete", "set_current"} and not op.get("task_id"):
- raise ValueError(f"Operation {i}: action '{action}' requires task_id")
-
- with self._connect() as connection:
- for i, op in enumerate(operations):
- try:
- action = op["action"]
- task_id = op.get("task_id")
-
- if action == "create":
- result = self.create_task(
- title=op["title"],
- description=op.get("description", ""),
- priority=op.get("priority", "medium"),
- owner=op.get("owner"),
- relevant_files=op.get("relevant_files"),
- tags=op.get("tags"),
- actor=actor,
- )
- results.append({"seq": i + 1, "action": action, "status": "success", "result": result})
-
- elif action == "update":
- # Extract allowed fields
- fields = {k: v for k, v in op.items() if k in {"title", "description", "status", "priority", "owner", "relevant_files", "tags"} and v is not None}
- result = self.update_task(task_id=task_id, actor=actor, **fields)
- results.append({"seq": i + 1, "action": action, "task_id": task_id, "status": "success", "result": result})
-
- elif action == "close":
- result = self.update_task(task_id=task_id, actor=actor, status="done")
- results.append({"seq": i + 1, "action": action, "task_id": task_id, "status": "success", "result": result})
-
- elif action == "delete":
- cursor = connection.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
- connection.commit()
- deleted = cursor.rowcount > 0
- results.append({"seq": i + 1, "action": action, "task_id": task_id, "status": "success" if deleted else "not_found", "deleted": deleted})
-
- elif action == "set_current":
- result = self.set_current_task(task_id=task_id, actor=actor)
- results.append({"seq": i + 1, "action": action, "task_id": task_id, "status": "success", "result": result})
-
- except Exception as e:
- # Atomic: rollback all on any failure
- connection.rollback()
- results.append({"seq": i + 1, "action": op.get("action"), "task_id": op.get("task_id"), "status": "error", "error": str(e)})
- return {
- "results": results,
- "total": len(operations),
- "succeeded": sum(1 for r in results if r["status"] == "success"),
- "failed": len(operations),
- "message": "Bulk operation failed — atomic rollback performed. No changes were made.",
- }
-
- return {
- "results": results,
- "total": len(operations),
- "succeeded": sum(1 for r in results if r["status"] == "success"),
- "failed": sum(1 for r in results if r["status"] != "success"),
- "message": f"Bulk ops complete. {sum(1 for r in results if r['status'] == 'success')}/{len(operations)} succeeded.",
- }
-
- # =========================================================================
- # Phase 2: Project Export
- # =========================================================================
-
- def export_project(self, format: str = "json") -> dict[str, Any]:
- """Export full project state as JSON and/or Markdown bundle."""
- import gzip
-
- if format not in {"json", "markdown", "both"}:
- raise ValueError(f"Invalid format '{format}'. Valid: json, markdown, both")
-
- now = utc_now()
- timestamp = now.replace(":", "-").replace("Z", "")
- export_dir = self.project_config.export_dir / f"obsmcp-export-{timestamp}"
- export_dir.mkdir(parents=True, exist_ok=True)
-
- # Gather all state
- export_data = {
- "project_name": self.project_config.project_name,
- "project_slug": self.project_config.project_slug,
- "project_path": self.project_root,
- "exported_at": now,
- "brief": self.get_project_brief(),
- "current_task": self.get_current_task(),
- "active_tasks": self.get_active_tasks(limit=999),
- "blockers": self.get_blockers(open_only=False, limit=999),
- "decisions": self.get_decisions(limit=999),
- "handoffs": [],
- "work_logs": self.get_recent_work(limit=999),
- "sessions": self.get_active_sessions(limit=999),
- "audit": self.get_audit_log(limit=999),
- }
-
- # Get handoffs
- with self._connect() as conn:
- export_data["handoffs"] = self._fetchall_dicts(conn, "SELECT * FROM handoffs ORDER BY created_at DESC LIMIT 999")
-
- exported_files: list[str] = []
-
- # JSON export
- if format in {"json", "both"}:
- json_path = export_dir / "obsmcp-export.json"
- with gzip.open(json_path.with_suffix(".json.gz"), "wt", encoding="utf-8") as f:
- import json as _json
-
- f.write(_json.dumps(export_data, indent=2, ensure_ascii=False))
- exported_files.append(str(json_path.with_suffix(".json.gz")))
-
- # Markdown export
- if format in {"markdown", "both"}:
- # Project Brief
- brief_path = export_dir / "00_Project_Brief.md"
- brief_lines = ["# Project Brief\n"]
- for section, content in export_data["brief"].items():
- brief_lines.append(f"## {section}\n{content}\n")
- brief_path.write_text("\n".join(brief_lines), encoding="utf-8")
- exported_files.append(str(brief_path))
-
- # Tasks
- tasks_path = export_dir / "01_Tasks.md"
- task_lines = ["# Tasks\n"]
- for task in export_data["active_tasks"]:
- task_lines.append(f"## {task['id']}: {task['title']}\n- Status: {task['status']}\n- Priority: {task['priority']}\n- Created: {task['created_at']}\n{task['description']}\n")
- tasks_path.write_text("\n".join(task_lines), encoding="utf-8")
- exported_files.append(str(tasks_path))
-
- # Decisions
- decisions_path = export_dir / "02_Decisions.md"
- decision_lines = ["# Decisions\n"]
- for dec in export_data["decisions"]:
- decision_lines.append(f"## {dec['title']}\n- {dec['created_at']}\n**Decision:** {dec['decision']}\n**Rationale:** {dec['rationale']}\n**Impact:** {dec['impact']}\n")
- decisions_path.write_text("\n".join(decision_lines), encoding="utf-8")
- exported_files.append(str(decisions_path))
-
- # Work Logs
- logs_path = export_dir / "03_Work_Logs.md"
- log_lines = ["# Work Logs\n"]
- for log in export_data["work_logs"]:
- log_lines.append(f"- [{log['created_at']}] [{log['actor']}] {log['message']}\n")
- logs_path.write_text("\n".join(log_lines), encoding="utf-8")
- exported_files.append(str(logs_path))
-
- # Handoffs
- handoffs_path = export_dir / "04_Handoffs.md"
- handoff_lines = ["# Handoffs\n"]
- for h in export_data["handoffs"]:
- handoff_lines.append(f"## {h['from_actor']} → {h['to_actor']}\n- {h['created_at']}\n**Summary:** {h['summary']}\n**Next Steps:** {h['next_steps']}\n")
- handoffs_path.write_text("\n".join(handoff_lines), encoding="utf-8")
- exported_files.append(str(handoffs_path))
-
- # Manifest
- manifest_path = export_dir / "MANIFEST.md"
- manifest_lines = [
- f"# obsmcp Export — {timestamp}\n",
- f"**Exported:** {now}\n",
- f"**Project:** {self.project_config.project_name}\n",
- f"**Project Slug:** {self.project_config.project_slug}\n",
- f"**Repo Path:** {self.project_root}\n",
- "## Files\n",
- ]
- for fpath in sorted(exported_files):
- manifest_lines.append(f"- `{Path(fpath).name}`")
- manifest_path.write_text("\n".join(manifest_lines), encoding="utf-8")
- exported_files.append(str(manifest_path))
-
- # Record the decision
- self.log_decision(
- title=f"Project export: {format}",
- decision=f"Exported project state as {format} to {export_dir.name}",
- rationale=f"Manual export by user",
- impact=f"{len(exported_files)} files created",
- actor="export",
- )
-
- return {
- "format": format,
- "export_dir": str(export_dir),
- "files": exported_files,
- "file_count": len(exported_files),
- "exported_at": now,
- "message": f"Export complete. {len(exported_files)} files written to {export_dir}",
- }
-
- # =========================================================================
- # Phase 3: Work Log Expiry
- # =========================================================================
-
- def get_log_expiry_days(self) -> int:
- with self._connect() as conn:
- row = self._fetchone_dict(conn, "SELECT value FROM project_state WHERE key='log_expiry_days'")
- return int(row["value"]) if row and row["value"] else 0
-
- def configure_log_expiry(self, days: int, actor: str = "unknown") -> dict[str, Any]:
- now = utc_now()
- with self._connect() as conn:
- conn.execute(
- "INSERT INTO project_state (key, value, updated_at) VALUES ('log_expiry_days', ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
- (str(days), now),
- )
- conn.commit()
- return {"log_expiry_days": days, "message": f"Log expiry set to {days} days. 0 = disabled."}
-
- def expire_old_logs(self, actor: str = "unknown") -> dict[str, Any]:
- days = self.get_log_expiry_days()
- if days <= 0:
- return {"deleted": 0, "message": "Log expiry is disabled (days=0). No logs purged."}
-
- from datetime import timedelta
-
- cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
- with self._connect() as conn:
- # Never delete logs from open sessions
- open_session_ids = [r["id"] for r in self._fetchall_dicts(conn, "SELECT id FROM sessions WHERE status='open'")]
- if open_session_ids:
- placeholders = ",".join("?" * len(open_session_ids))
- cursor = conn.execute(
- f"DELETE FROM work_logs WHERE created_at < ? AND task_id NOT IN (SELECT task_id FROM sessions WHERE id IN ({placeholders})) AND task_id IS NOT NULL",
- (cutoff,) + tuple(open_session_ids),
- )
- else:
- cursor = conn.execute("DELETE FROM work_logs WHERE created_at < ?", (cutoff,))
- deleted = cursor.rowcount
- conn.commit()
-
- self.log_decision(
- title=f"Work log expiry: {days}-day retention",
- decision=f"Purged {deleted} work logs older than {days} days",
- rationale="Automatic log expiry cleanup",
- impact=f"{deleted} old records removed",
- actor=actor,
- )
-
- return {"deleted": deleted, "cutoff_date": cutoff, "retention_days": days, "message": f"Purged {deleted} old work logs."}
-
- def get_log_stats(self) -> dict[str, Any]:
- from datetime import timedelta
-
- now_dt = datetime.now(timezone.utc)
- week_ago = (now_dt - timedelta(days=7)).isoformat().replace("+00:00", "Z")
- month_ago = (now_dt - timedelta(days=30)).isoformat().replace("+00:00", "Z")
- three_months_ago = (now_dt - timedelta(days=90)).isoformat().replace("+00:00", "Z")
-
- with self._connect() as conn:
- total = conn.execute("SELECT COUNT(*) FROM work_logs").fetchone()[0]
- today_count = conn.execute("SELECT COUNT(*) FROM work_logs WHERE created_at >= ?", (now_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),)).fetchone()[0]
- week_count = conn.execute("SELECT COUNT(*) FROM work_logs WHERE created_at >= ? AND created_at < ?", (week_ago, now_dt.strftime("%Y-%m-%dT%H:%M:%SZ"))).fetchone()[0]
- month_count = conn.execute("SELECT COUNT(*) FROM work_logs WHERE created_at >= ? AND created_at < ?", (month_ago, week_ago)).fetchone()[0]
- three_month_count = conn.execute("SELECT COUNT(*) FROM work_logs WHERE created_at >= ? AND created_at < ?", (three_months_ago, month_ago)).fetchone()[0]
- older_count = conn.execute("SELECT COUNT(*) FROM work_logs WHERE created_at < ?", (three_months_ago,)).fetchone()[0]
-
- return {
- "total_logs": total,
- "expiry_days": self.get_log_expiry_days(),
- "buckets": {
- "today": today_count,
- "this_week": week_count,
- "this_month": month_count,
- "last_3_months": three_month_count,
- "older": older_count,
- },
- "message": f"Total: {total} logs. Older than 90 days: {older_count}",
- }
-
- # =========================================================================
- # Phase 3: Session Replay
- # =========================================================================
-
- def session_replay(self, session_id: str | None = None) -> dict[str, Any]:
- # If no session_id, get the most recent
- if not session_id:
- with self._connect() as conn:
- row = self._fetchone_dict(conn, "SELECT id FROM sessions ORDER BY opened_at DESC LIMIT 1")
- if not row:
- return {"error": "No sessions found."}
- session_id = row["id"]
-
- with self._connect() as conn:
- session = self._fetchone_dict(conn, "SELECT * FROM sessions WHERE id = ?", (session_id,))
- if not session:
- return {"error": f"Session '{session_id}' not found."}
-
- events = self._fetchall_dicts(conn, "SELECT * FROM session_events WHERE session_id = ? ORDER BY created_at ASC", (session_id,))
-
- if not session:
- return {"error": f"Session '{session_id}' not found."}
-
- # Parse session timestamps
- opened = datetime.fromisoformat(session["opened_at"].replace("Z", "+00:00"))
- closed = datetime.fromisoformat(session["closed_at"].replace("Z", "+00:00")) if session["closed_at"] else datetime.now(timezone.utc)
- duration = int((closed - opened).total_seconds())
-
- # Build event timeline
- event_list = []
- events_by_type: dict[str, int] = {}
- warnings: list[str] = []
- prev_event_time = opened
-
- for i, evt in enumerate(events):
- evt_time = datetime.fromisoformat(evt["created_at"].replace("Z", "+00:00"))
- gap = int((evt_time - prev_event_time).total_seconds())
-
- if gap > 900: # > 15 minutes
- warnings.append(f"Gap of {gap}s before {evt['event_type']} at {evt['created_at']}")
-
- payload = parse_json(evt.get("payload"), {})
- events_by_type[evt["event_type"]] = events_by_type.get(evt["event_type"], 0) + 1
-
- event_list.append({
- "seq": i + 1,
- "time": evt["created_at"],
- "actor": evt["actor"],
- "event_type": evt["event_type"],
- "payload": payload,
- "gap_seconds": gap,
- })
- prev_event_time = evt_time
-
- # Check for missed heartbeats
- heartbeat_interval = session["heartbeat_interval_seconds"]
- last_hb = datetime.fromisoformat(session["heartbeat_at"].replace("Z", "+00:00"))
- overdue = int((closed - last_hb).total_seconds())
- if session["status"] == "open" and overdue > heartbeat_interval:
- warnings.append(f"Heartbeat overdue by {overdue}s (interval: {heartbeat_interval}s)")
- if session["status"] == "closed" and not session.get("handoff_created"):
- warnings.append("Session closed without a recorded handoff.")
-
- # Render as Markdown
- md_lines = [
- f"# Session Replay: {session_id}",
- "",
- f"**Actor:** {session['actor']} ",
- f"**Client:** {session['client_name']} ",
- f"**Model:** {session['model_name']} ",
- f"**Goal:** {session['session_goal']}",
- f"**Opened:** {session['opened_at']} ",
- f"**Closed:** {session['closed_at'] or 'still open'} ",
- f"**Duration:** {duration}s ({duration // 60}m {duration % 60}s)",
- "",
- "## Timeline",
- "",
- ]
- for evt in event_list:
- payload_str = ""
- payload = evt["payload"]
- if evt["event_type"] == "log_work":
- payload_str = f" — {payload.get('message', '')[:80]}"
- elif evt["event_type"] == "create_task":
- payload_str = f" — {payload.get('title', '')}"
- elif evt["event_type"] == "session_open":
- payload_str = f" — {payload.get('client_name', '')} / {payload.get('model_name', '')}"
- gap_note = f" (gap: {evt['gap_seconds']}s)" if evt["gap_seconds"] > 60 else ""
- md_lines.append(f"{evt['seq']}. **{evt['time']}** [{evt['actor']}] {evt['event_type']}{gap_note}{payload_str}")
-
- if warnings:
- md_lines.extend(["", "## Warnings", ""])
- for w in warnings:
- md_lines.append(f"- ⚠️ {w}")
-
- md_lines.extend(["", "## Statistics", ""])
- md_lines.append(f"- Total events: {len(event_list)}")
- md_lines.append(f"- Write count: {session['write_count']}")
- md_lines.append(f"- Duration: {duration}s")
- md_lines.append(f"- Events by type: {events_by_type}")
-
- return {
- "session_id": session_id,
- "session": session,
- "events": event_list,
- "statistics": {
- "total_events": len(event_list),
- "events_by_type": events_by_type,
- "total_duration_seconds": duration,
- "write_count": session["write_count"],
- "warnings": warnings,
- },
- "timeline_markdown": "\n".join(md_lines),
- }
-
- # =========================================================================
- # Phase 3: Task Dependencies
- # =========================================================================
-
- def add_task_dependency(self, task_id: str, blocked_by: list[str] | None = None, blocks: list[str] | None = None) -> dict[str, Any]:
- now = utc_now()
- blocked_by = blocked_by or []
- blocks = blocks or []
-
- # Validate task exists
- task = self.get_task(task_id)
- if not task:
- raise ValueError(f"Task '{task_id}' not found.")
-
- for dep_id in blocked_by + blocks:
- dep = self.get_task(dep_id)
- if not dep:
- raise ValueError(f"Dependency task '{dep_id}' not found.")
-
- # Check for cycles
- if self._would_create_cycle(task_id, blocked_by, blocks):
- raise ValueError(f"Adding these dependencies would create a circular dependency for task '{task_id}'.")
-
- with self._connect() as conn:
- # Get existing dependencies
- existing = self._fetchone_dict(conn, "SELECT * FROM task_dependencies WHERE task_id = ?", (task_id,))
- existing_blocked_by = parse_json(existing["blocked_by"], []) if existing else []
- existing_blocks = parse_json(existing["blocks"], []) if existing else []
-
- new_blocked_by = list(set(existing_blocked_by + blocked_by))
- new_blocks = list(set(existing_blocks + blocks))
-
- conn.execute(
- """
- INSERT INTO task_dependencies (task_id, blocked_by, blocks, updated_at)
- VALUES (?, ?, ?, ?)
- ON CONFLICT(task_id) DO UPDATE SET blocked_by=excluded.blocked_by, blocks=excluded.blocks, updated_at=excluded.updated_at
- """,
- (task_id, json.dumps(new_blocked_by), json.dumps(new_blocks), now),
- )
- conn.commit()
-
- return self.get_task_dependency(task_id) or {}
-
- def remove_task_dependency(self, task_id: str, blocked_by: list[str] | None = None, blocks: list[str] | None = None) -> dict[str, Any]:
- now = utc_now()
- with self._connect() as conn:
- existing = self._fetchone_dict(conn, "SELECT * FROM task_dependencies WHERE task_id = ?", (task_id,))
- if not existing:
- return {"task_id": task_id, "blocked_by": [], "blocks": [], "message": "No dependencies found."}
-
- existing_blocked_by = parse_json(existing["blocked_by"], [])
- existing_blocks = parse_json(existing["blocks"], [])
-
- new_blocked_by = [x for x in existing_blocked_by if x not in (blocked_by or [])]
- new_blocks = [x for x in existing_blocks if x not in (blocks or [])]
-
- if not new_blocked_by and not new_blocks:
- conn.execute("DELETE FROM task_dependencies WHERE task_id = ?", (task_id,))
- else:
- conn.execute(
- "UPDATE task_dependencies SET blocked_by=?, blocks=?, updated_at=? WHERE task_id=?",
- (json.dumps(new_blocked_by), json.dumps(new_blocks), now, task_id),
- )
- conn.commit()
-
- return {"task_id": task_id, "blocked_by": new_blocked_by, "blocks": new_blocks}
-
- def get_task_dependency(self, task_id: str) -> dict[str, Any] | None:
- with self._connect() as conn:
- row = self._fetchone_dict(conn, "SELECT * FROM task_dependencies WHERE task_id = ?", (task_id,))
- if not row:
- return None
- return {
- "task_id": row["task_id"],
- "blocked_by": parse_json(row["blocked_by"], []),
- "blocks": parse_json(row["blocks"], []),
- "updated_at": row["updated_at"],
- }
-
- def get_all_dependencies(self) -> list[dict[str, Any]]:
- with self._connect() as conn:
- rows = self._fetchall_dicts(conn, "SELECT * FROM task_dependencies")
- result = []
- for row in rows:
- result.append({
- "task_id": row["task_id"],
- "blocked_by": parse_json(row["blocked_by"], []),
- "blocks": parse_json(row["blocks"], []),
- "updated_at": row["updated_at"],
- })
- return result
-
- def get_blocked_tasks(self) -> list[dict[str, Any]]:
- """Return tasks that are blocked by unresolved task IDs."""
- all_deps = self.get_all_dependencies()
- active_task_ids = {t["id"] for t in self.get_active_tasks(limit=999)}
- blocked_tasks = []
-
- for dep in all_deps:
- unresolved_blockers = [bid for bid in dep["blocked_by"] if bid in active_task_ids]
- if unresolved_blockers:
- task = self.get_task(dep["task_id"])
- if task:
- task_copy = dict(task)
- task_copy["unresolved_blockers"] = unresolved_blockers
- blocked_tasks.append(task_copy)
-
- return blocked_tasks
-
- def validate_dependencies(self) -> dict[str, Any]:
- """Check for circular dependencies and broken references."""
- issues: list[str] = []
- all_deps = self.get_all_dependencies()
- dep_map: dict[str, set[str]] = {}
-
- for dep in all_deps:
- dep_map[dep["task_id"]] = set(dep["blocked_by"])
-
- # Check for broken references
- all_task_ids = {t["id"] for t in self.get_active_tasks(limit=9999)}
- for dep in all_deps:
- for bid in dep["blocked_by"]:
- if bid not in all_task_ids:
- issues.append(f"Task '{dep['task_id']}' references non-existent blocker '{bid}'")
-
- # Check for cycles using DFS
- visited: set[str] = set()
- rec_stack: set[str] = set()
-
- def has_cycle(task_id: str, path: list[str]) -> bool:
- visited.add(task_id)
- rec_stack.add(task_id)
- path.append(task_id)
-
- for blocker in dep_map.get(task_id, []):
- if blocker not in visited:
- if has_cycle(blocker, path[:]):
- return True
- elif blocker in rec_stack:
- issues.append(f"Circular dependency detected: {' -> '.join(path + [blocker])}")
- return True
-
- rec_stack.remove(task_id)
- return False
-
- for task_id in dep_map:
- if task_id not in visited:
- has_cycle(task_id, [])
-
- return {
- "valid": len(issues) == 0,
- "issues": issues,
- "total_dependencies": len(all_deps),
- "message": f"Dependency validation: {'PASSED — no issues' if not issues else f'{len(issues)} issue(s) found'}",
- }
-
- def _would_create_cycle(self, task_id: str, blocked_by: list[str], blocks: list[str]) -> bool:
- """Check if adding blocked_by/blocks to task_id would create a cycle."""
- all_deps = self.get_all_dependencies()
-
- # Build graph: key = task, value = set of tasks it depends on (i.e., blocked by)
- # From blocked_by: X.blocked_by=[A,B] → X depends on A,B → A→X, B→X
- # From blocks: X.blocks=[P,Q] → P depends on X, Q depends on X → P→X, Q→X
- # Note: blocked_by is the canonical direction; blocks is the inverse relationship
- dep_map: dict[str, set[str]] = {}
- for dep in all_deps:
- tid = dep["task_id"]
- dep_map.setdefault(tid, set()).update(dep["blocked_by"])
- for blocked in dep["blocks"]:
- dep_map.setdefault(blocked, set()).add(tid)
-
- # Temporarily add the new edges
- # blocked_by: A.blocked_by=[B] → A depends on B → A→B
- dep_map.setdefault(task_id, set()).update(blocked_by)
- # blocks: A.blocks=[B] → B depends on A → B→A
- for blocked_task in blocks:
- dep_map.setdefault(blocked_task, set()).add(task_id)
-
- # DFS cycle detection
- def dfs(tid: str, path: set[str]) -> bool:
- if tid in path:
- return True
- path.add(tid)
- for dep_tid in dep_map.get(tid, []):
- if dfs(dep_tid, path):
- return True
- path.discard(tid)
- return False
-
- return dfs(task_id, set())
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
new file mode 100644
index 0000000..24d6cb6
--- /dev/null
+++ b/server/tests/conftest.py
@@ -0,0 +1,33 @@
+"""Shared fixtures for backend tests."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Iterator
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture()
+def client(tmp_path, monkeypatch) -> Iterator[TestClient]:
+ db_path = tmp_path / "obsmcp.db"
+ monkeypatch.setenv("OBSMCP_DB_PATH", str(db_path))
+ monkeypatch.setenv("OBSMCP_API_TOKEN", "")
+ # Force config + db re-init per-test
+ from obsmcp_server import config as cfg_module
+ from obsmcp_server import db as db_module
+
+ cfg_module.reset_config_cache()
+ db_module._state["db_path"] = None # type: ignore[attr-defined]
+ db_module._tls = __import__("threading").local() # type: ignore[attr-defined]
+
+ from obsmcp_server.main import create_app
+
+ app = create_app()
+ with TestClient(app) as c:
+ yield c
+
+ # Clean up env to avoid leaking
+ for k in ("OBSMCP_DB_PATH", "OBSMCP_API_TOKEN"):
+ os.environ.pop(k, None)
diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py
new file mode 100644
index 0000000..a3e0fa8
--- /dev/null
+++ b/server/tests/test_auth.py
@@ -0,0 +1,40 @@
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture()
+def secure_client(tmp_path, monkeypatch):
+ db_path = tmp_path / "obsmcp.db"
+ monkeypatch.setenv("OBSMCP_DB_PATH", str(db_path))
+ monkeypatch.setenv("OBSMCP_API_TOKEN", "secret123")
+ from obsmcp_server import config as cfg_module
+ from obsmcp_server import db as db_module
+
+ cfg_module.reset_config_cache()
+ db_module._state["db_path"] = None # type: ignore[attr-defined]
+ db_module._tls = __import__("threading").local() # type: ignore[attr-defined]
+
+ from obsmcp_server.main import create_app
+
+ app = create_app()
+ with TestClient(app) as c:
+ yield c
+
+
+def test_public_health_when_token_set(secure_client):
+ assert secure_client.get("/healthz").status_code == 200
+
+
+def test_api_requires_token(secure_client):
+ res = secure_client.get("/api/tasks")
+ assert res.status_code == 401
+
+
+def test_api_accepts_bearer(secure_client):
+ res = secure_client.get("/api/tasks", headers={"Authorization": "Bearer secret123"})
+ assert res.status_code == 200
+
+
+def test_api_accepts_query_token(secure_client):
+ res = secure_client.get("/api/tasks?token=secret123")
+ assert res.status_code == 200
diff --git a/server/tests/test_health.py b/server/tests/test_health.py
new file mode 100644
index 0000000..aefc273
--- /dev/null
+++ b/server/tests/test_health.py
@@ -0,0 +1,18 @@
+def test_healthz(client):
+ res = client.get("/healthz")
+ assert res.status_code == 200
+ assert res.json() == {"status": "ok"}
+
+
+def test_runtime_discovery(client):
+ res = client.get("/runtime-discovery")
+ assert res.status_code == 200
+ body = res.json()
+ assert body["version"] == "0.1.0"
+ assert "tasks" in body["features"]
+
+
+def test_mode_local(client):
+ res = client.get("/mode")
+ assert res.status_code == 200
+ assert res.json() == {"mode": "local"}
diff --git a/server/tests/test_knowledge_graph.py b/server/tests/test_knowledge_graph.py
new file mode 100644
index 0000000..7f2c3ee
--- /dev/null
+++ b/server/tests/test_knowledge_graph.py
@@ -0,0 +1,23 @@
+def test_nodes_and_edges(client):
+ n1 = client.post(
+ "/api/knowledge-graph/nodes",
+ json={"node_type": "module", "name": "foo"},
+ ).json()
+ n2 = client.post(
+ "/api/knowledge-graph/nodes",
+ json={"node_type": "module", "name": "bar"},
+ ).json()
+ assert n1["id"] and n2["id"]
+
+ e = client.post(
+ "/api/knowledge-graph/edges",
+ json={"from_node_id": n1["id"], "to_node_id": n2["id"], "edge_type": "imports"},
+ ).json()
+ assert e["edge_type"] == "imports"
+
+ graph = client.get("/api/knowledge-graph").json()
+ assert len(graph["nodes"]) == 2
+ assert len(graph["edges"]) == 1
+
+ query = client.get(f"/api/knowledge-graph/query?node_id={n1['id']}&depth=1").json()
+ assert {n["id"] for n in query["nodes"]} == {n1["id"], n2["id"]}
diff --git a/server/tests/test_tasks.py b/server/tests/test_tasks.py
new file mode 100644
index 0000000..8244846
--- /dev/null
+++ b/server/tests/test_tasks.py
@@ -0,0 +1,43 @@
+def test_task_crud_flow(client):
+ res = client.post("/api/tasks", json={"title": "Write docs"})
+ assert res.status_code == 200
+ task = res.json()
+ task_id = task["id"]
+ assert task["status"] == "open"
+
+ res = client.get("/api/tasks")
+ assert res.status_code == 200
+ assert any(t["id"] == task_id for t in res.json())
+
+ res = client.put(f"/api/tasks/{task_id}", json={"status": "done"})
+ assert res.status_code == 200
+ assert res.json()["status"] == "done"
+
+ res = client.delete(f"/api/tasks/{task_id}")
+ assert res.status_code == 200
+ assert res.json()["ok"] is True
+
+
+def test_task_bulk(client):
+ ids = []
+ for i in range(3):
+ res = client.post("/api/tasks", json={"title": f"t{i}"})
+ ids.append(res.json()["id"])
+ ops = [{"id": ids[0], "action": "update", "data": {"status": "in_progress"}}, {"id": ids[1], "action": "delete"}]
+ res = client.post("/api/tasks/bulk", json={"operations": ops})
+ assert res.status_code == 200
+ remaining = client.get("/api/tasks").json()
+ remaining_ids = {t["id"] for t in remaining}
+ assert ids[0] in remaining_ids
+ assert ids[1] not in remaining_ids
+ assert ids[2] in remaining_ids
+
+
+def test_stats(client):
+ client.post("/api/tasks", json={"title": "one", "status": "open"})
+ client.post("/api/tasks", json={"title": "two", "status": "done"})
+ res = client.get("/api/stats")
+ assert res.status_code == 200
+ stats = res.json()
+ assert stats["tasks"]["total"] >= 2
+ assert stats["tasks"]["done"] >= 1
diff --git a/server/utils.py b/server/utils.py
deleted file mode 100644
index 2b1d69a..0000000
--- a/server/utils.py
+++ /dev/null
@@ -1,199 +0,0 @@
-from __future__ import annotations
-
-import json
-import logging
-from logging.handlers import RotatingFileHandler
-import os
-import re
-import hashlib
-import socket
-import tempfile
-import time
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-
-from .observability import JSONFormatter
-
-
-def utc_now() -> str:
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
-
-
-def slugify(value: str, max_length: int = 48) -> str:
- cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
- return cleaned[:max_length] or "item"
-
-
-def ensure_parent(path: Path) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
-
-
-def read_text_with_retry(
- path: Path,
- *,
- encoding: str = "utf-8",
- errors: str | None = None,
- attempts: int = 12,
- delay_seconds: float = 0.05,
- default: str | None = None,
-) -> str:
- last_error: Exception | None = None
- for attempt in range(attempts):
- try:
- if errors is None:
- return path.read_text(encoding=encoding)
- return path.read_text(encoding=encoding, errors=errors)
- except FileNotFoundError:
- if default is not None:
- return default
- raise
- except PermissionError as exc:
- last_error = exc
- if attempt == attempts - 1:
- break
- time.sleep(delay_seconds)
- if default is not None:
- return default
- if last_error is not None:
- raise last_error
- raise FileNotFoundError(path)
-
-
-def write_text_atomic(path: Path, content: str, encoding: str = "utf-8") -> None:
- ensure_parent(path)
- with tempfile.NamedTemporaryFile("w", delete=False, encoding=encoding, dir=path.parent) as handle:
- handle.write(content)
- temp_name = handle.name
- temp_path = Path(temp_name)
- for _ in range(8):
- try:
- temp_path.replace(path)
- return
- except PermissionError:
- time.sleep(0.1)
- try:
- with path.open("w", encoding=encoding) as handle:
- handle.write(content)
- finally:
- try:
- if temp_path.exists():
- temp_path.unlink()
- except OSError:
- pass
-
-
-def write_json_atomic(path: Path, payload: Any) -> None:
- write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=True) + "\n")
-
-
-def read_json_with_retry(path: Path, default: Any) -> Any:
- if not path.exists():
- return default
- try:
- text = read_text_with_retry(path, default=None)
- except FileNotFoundError:
- return default
- try:
- return json.loads(text)
- except json.JSONDecodeError:
- return default
-
-
-def parse_json(value: str | None, default: Any) -> Any:
- if not value:
- return default
- try:
- return json.loads(value)
- except json.JSONDecodeError:
- return default
-
-
-def is_port_open(host: str, port: int, timeout: float = 0.5) -> bool:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(timeout)
- return sock.connect_ex((host, port)) == 0
-
-
-def file_fingerprint(path: str | Path) -> dict[str, Any]:
- file_path = Path(path)
- stat = file_path.stat()
- digest = hashlib.sha1()
- with file_path.open("rb") as handle:
- while True:
- chunk = handle.read(1024 * 1024)
- if not chunk:
- break
- digest.update(chunk)
- modified_at = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
- return {
- "file_path": str(file_path),
- "fingerprint": digest.hexdigest(),
- "file_size": stat.st_size,
- "modified_at": modified_at,
- "scanned_at": utc_now(),
- }
-
-
-def configure_logging(
- log_dir: Path,
- debug: bool = False,
- json_output: bool = False,
- json_output_path: str | None = None,
- include_traceback: bool = False,
- console_output: bool = True,
-) -> logging.Logger:
- log_dir.mkdir(parents=True, exist_ok=True)
- logger = logging.getLogger("obsmcp")
- if logger.handlers:
- return logger
-
- logger.setLevel(logging.DEBUG if debug else logging.INFO)
- formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
-
- file_handler = RotatingFileHandler(
- log_dir / "obsmcp.log",
- maxBytes=2_000_000,
- backupCount=5,
- encoding="utf-8",
- )
- file_handler.setFormatter(formatter)
- file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
-
- error_handler = RotatingFileHandler(
- log_dir / "obsmcp-error.log",
- maxBytes=2_000_000,
- backupCount=5,
- encoding="utf-8",
- )
- error_handler.setFormatter(formatter)
- error_handler.setLevel(logging.ERROR)
-
- logger.addHandler(file_handler)
- logger.addHandler(error_handler)
-
- # Optional structured JSON log file
- if json_output and json_output_path:
- json_handler = RotatingFileHandler(
- log_dir / json_output_path,
- maxBytes=5_000_000,
- backupCount=5,
- encoding="utf-8",
- )
- json_handler.setFormatter(JSONFormatter(include_traceback=include_traceback))
- json_handler.setLevel(logging.DEBUG)
- logger.addHandler(json_handler)
-
- if console_output and not env_bool("OBSMCP_NO_CONSOLE", False):
- console_handler = logging.StreamHandler()
- console_handler.setFormatter(formatter)
- console_handler.setLevel(logging.DEBUG if debug else logging.INFO)
- logger.addHandler(console_handler)
- return logger
-
-
-def env_bool(name: str, default: bool) -> bool:
- value = os.getenv(name)
- if value is None:
- return default
- return value.strip().lower() in {"1", "true", "yes", "on"}
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..dfac8e8
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,32 @@
+@echo off
+setlocal
+
+if not exist "%USERPROFILE%\.obsmcp\config.json" (
+ echo ================================================
+ echo OBSMCP First-Run Setup
+ echo ================================================
+ echo.
+ set /p PROJECT_PATH="Enter project path (e.g. D:\Projects\MyProject): "
+ echo.
+ echo Optional: cloud sync configuration ^(leave blank for standalone mode^)
+ echo.
+ set /p BACKEND_URL="Enter backend URL (blank = standalone): "
+ set /p API_TOKEN="Enter API token (blank = no auth): "
+ echo.
+
+ python -m obsmcp.obsmcp_setup --configure --project "%PROJECT_PATH%" --url "%BACKEND_URL%" --token "%API_TOKEN%"
+ if errorlevel 1 (
+ echo Configuration failed. Please check your inputs and try again.
+ pause
+ exit /b 1
+ )
+ echo.
+ if "%BACKEND_URL%"=="" (
+ echo Mode: STANDALONE ^(all data stored locally^)
+ ) else (
+ echo Mode: CLOUD SYNC ^(data syncing to %BACKEND_URL%^)
+ )
+ echo.
+)
+
+python -m obsmcp
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..640016a
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CONFIG="$HOME/.obsmcp/config.json"
+
+if [[ ! -f "$CONFIG" ]]; then
+ echo "================================================"
+ echo " OBSMCP First-Run Setup"
+ echo "================================================"
+ echo
+ read -r -p "Enter project path: " PROJECT_PATH
+ echo
+ echo "Optional: cloud sync configuration (leave blank for standalone mode)"
+ echo
+ read -r -p "Enter backend URL (blank = standalone): " BACKEND_URL
+ read -r -p "Enter API token (blank = no auth): " API_TOKEN
+ echo
+
+ python -m obsmcp.obsmcp_setup --configure \
+ --project "$PROJECT_PATH" \
+ --url "$BACKEND_URL" \
+ --token "$API_TOKEN"
+
+ if [[ -z "$BACKEND_URL" ]]; then
+ echo "Mode: STANDALONE (all data stored locally)"
+ else
+ echo "Mode: CLOUD SYNC (data syncing to $BACKEND_URL)"
+ fi
+ echo
+fi
+
+exec python -m obsmcp "$@"
diff --git a/start_obsmcp.bat b/start_obsmcp.bat
deleted file mode 100644
index 5fc08f2..0000000
--- a/start_obsmcp.bat
+++ /dev/null
@@ -1,19 +0,0 @@
-@echo off
-setlocal
-cd /d "%~dp0"
-
-if exist "scripts\launch_obsmcp.vbs" (
- wscript.exe //nologo "scripts\launch_obsmcp.vbs"
- timeout /t 3 /nobreak >nul
- powershell -NoProfile -Command "$client = New-Object System.Net.Sockets.TcpClient; try { $client.Connect('127.0.0.1', 9300); exit 0 } catch { exit 1 } finally { $client.Dispose() }"
- if %ERRORLEVEL% EQU 0 (
- echo obsmcp started on http://127.0.0.1:9300
- exit /b 0
- )
-)
-
-if exist ".venv\Scripts\python.exe" (
- ".venv\Scripts\python.exe" scripts\launch_obsmcp.py
-) else (
- py -3 scripts\launch_obsmcp.py
-)
diff --git a/stop_obsmcp.bat b/stop_obsmcp.bat
deleted file mode 100644
index 5c99d7e..0000000
--- a/stop_obsmcp.bat
+++ /dev/null
@@ -1,10 +0,0 @@
-@echo off
-setlocal
-cd /d "%~dp0"
-
-if exist ".venv\Scripts\python.exe" (
- ".venv\Scripts\python.exe" scripts\stop_obsmcp.py
-) else (
- py -3 scripts\stop_obsmcp.py
-)
-
diff --git a/templates/context/universal_prompt_template.md b/templates/context/universal_prompt_template.md
deleted file mode 100644
index 735417d..0000000
--- a/templates/context/universal_prompt_template.md
+++ /dev/null
@@ -1,15 +0,0 @@
-Read these files first:
-
-1. `.context/PROJECT_CONTEXT.md`
-2. `.context/CURRENT_TASK.json`
-3. `.context/HANDOFF.md`
-4. `.context/DECISIONS.md`
-5. `.context/BLOCKERS.json`
-6. `.context/RELEVANT_FILES.json`
-7. `.context/SESSION_SUMMARY.md`
-
-Continue the existing project without resetting assumptions.
-Preserve cross-model continuity.
-When you make meaningful progress, update the shared state through `ctx.bat` or the obsmcp MCP tools.
-Before you stop, create a fresh handoff.
-
diff --git a/templates/obsidian/daily_note_template.md b/templates/obsidian/daily_note_template.md
deleted file mode 100644
index 6b7370f..0000000
--- a/templates/obsidian/daily_note_template.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# YYYY-MM-DD
-
-- HH:MM Actor entry
-
diff --git a/templates/obsidian/handoff_template.md b/templates/obsidian/handoff_template.md
deleted file mode 100644
index 99b478c..0000000
--- a/templates/obsidian/handoff_template.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Handoff Template
-
-## Summary
-
-## Next Steps
-
-## Open Questions
-
-## Notes
-
diff --git a/templates/obsidian/project_brief_template.md b/templates/obsidian/project_brief_template.md
deleted file mode 100644
index 5a61bd7..0000000
--- a/templates/obsidian/project_brief_template.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Project Brief Template
-
-## Mission
-
-## Success Criteria
-
-## Architecture
-
-## Working Agreements
-
diff --git a/test_phases.py b/test_phases.py
deleted file mode 100644
index 82f9967..0000000
--- a/test_phases.py
+++ /dev/null
@@ -1,205 +0,0 @@
-"""Comprehensive verification of Phases 1-3."""
-import sys
-sys.stdout.reconfigure(encoding='utf-8')
-from server.store import StateStore
-from server.service import ObsmcpService
-from server.config import load_config
-
-config = load_config()
-store = StateStore(config)
-service = ObsmcpService(config)
-
-print("=== PHASE 1: COMPREHENSIVE TESTS ===")
-
-# Template: missing variable should error
-print()
-print("[TEST] Template: missing variable should error")
-try:
- store.create_task_from_template("bug", {}, actor="test")
- print("[FAIL] Should have raised ValueError")
-except ValueError as e:
- print(f"[PASS] ValueError: {e}")
-
-# Template: partial variables should error
-print()
-print("[TEST] Template: partial variables should error")
-try:
- store.create_task_from_template("bug", {"summary": "Test"}, actor="test")
- print("[FAIL] Should have raised ValueError")
-except ValueError as e:
- print(f"[PASS] ValueError: {e}")
-
-# Template: all variables provided should work
-print()
-print("[TEST] Template: all variables provided should work")
-task = store.create_task_from_template("bug", {
- "summary": "Login crash",
- "steps": "1.Open 2.Click",
- "expected": "Success",
- "actual": "Crashes"
-}, actor="test")
-print(f"[PASS] Created: {task['id']} - {task['title']} - priority={task['priority']}, tags={task['tags']}")
-
-# Template: non-existent template should error
-print()
-print("[TEST] Template: non-existent template should error")
-try:
- store.create_task_from_template("nonexistent", {}, actor="test")
- print("[FAIL] Should have raised ValueError")
-except ValueError as e:
- print(f"[PASS] ValueError: {e}")
-
-# Delete template
-print()
-print("[TEST] Delete template")
-deleted = store.delete_task_template("test")
-templates = store.get_task_templates()
-print(f"[PASS] Delete: {deleted}, remaining: {[t['name'] for t in templates]}")
-
-# Re-create it
-store.create_task_template("test", "Test: {name}", "Description {desc}", priority="medium", tags=["test"])
-print(f"[PASS] Re-created: {[t['name'] for t in store.get_task_templates()]}")
-
-# Audit log
-audit = store.get_audit_log(limit=5)
-print(f"[PASS] Audit log: {audit['total_events']} events, by_action={audit['by_action']}")
-
-# Audit log: include_ai_only
-audit2 = store.get_audit_log(include_ai_only=True, limit=5)
-print(f"[PASS] Audit log (ai-only): {audit2['total_events']} events")
-
-# Quick log: no current task scenario
-print()
-print("[TEST] Quick log with no current task")
-with store._connect() as conn:
- conn.execute("UPDATE project_state SET value='' WHERE key='current_task_id'")
-log = store.quick_log("Log with no task", actor="test")
-print(f"[PASS] Quick log: task_id={log['task_id']} (None is OK)")
-
-print()
-print("=== PHASE 2: COMPREHENSIVE TESTS ===")
-
-# Reset: invalid scope should error
-print()
-print("[TEST] Reset: invalid scope should error")
-try:
- store.reset_project("invalid_scope", actor="test")
- print("[FAIL] Should have raised ValueError")
-except ValueError as e:
- print(f"[PASS] ValueError: {e}")
-
-# Bulk ops: atomic failure
-print()
-print("[TEST] Bulk ops: atomic failure")
-ops = [
- {"action": "create", "title": "Good task", "description": "This works"},
- {"action": "update", "task_id": "INVALID-ID-XYZ", "status": "done"},
-]
-result = service.bulk_task_ops(operations=ops, actor="test")
-print(f"[PASS] Atomic failure: failed={result['failed']}, message={result['message'][:60]}")
-all_tasks = store.get_active_tasks(limit=100)
-good_tasks = [t for t in all_tasks if "Good task" in t["title"]]
-print(f"[PASS] No tasks created (rolled back): {len(good_tasks)} == 0")
-
-# Export: verify files created
-print()
-print("[TEST] Export markdown files")
-exp = store.export_project(format="markdown")
-import os
-files_exist = all(os.path.exists(f) for f in exp["files"])
-print(f"[PASS] Export: {exp['file_count']} files, all exist={files_exist}")
-print(f" Files: {[os.path.basename(f) for f in exp['files']]}")
-
-print()
-print("=== PHASE 3: COMPREHENSIVE TESTS ===")
-
-# Cycle detection
-print("[TEST] Cycle detection: A blocks B, B blocks A should error")
-tA = store.create_task("Cycle A", "A blocks B", actor="cycle-test")
-tB = store.create_task("Cycle B", "B blocks A", actor="cycle-test")
-store.add_task_dependency(task_id=tA["id"], blocks=[tB["id"]])
-try:
- store.add_task_dependency(task_id=tB["id"], blocks=[tA["id"]])
- print("[FAIL] Should have detected cycle")
-except ValueError as e:
- print(f"[PASS] Cycle detected: {str(e)[:60]}")
-
-# Broken reference
-print()
-print("[TEST] Broken dependency reference should error")
-try:
- store.add_task_dependency(task_id=tA["id"], blocked_by=["TASK-INVALID-999"])
- print("[FAIL] Should have errored")
-except ValueError as e:
- print(f"[PASS] Broken ref error: {e}")
-
-# Validate deps
-print()
-print("[TEST] Validate dependencies")
-validate = store.validate_dependencies()
-print(f"[PASS] Valid: {validate['valid']}, issues: {validate['issues']}")
-
-# Session replay: no events case
-print()
-print("[TEST] Session replay: no events")
-replay = store.session_replay("SESSION-NONEXISTENT")
-print(f"[PASS] Session not found: {'error' in replay}")
-
-# Log expiry: disable
-print()
-print("[TEST] Log expiry: disable (days=0)")
-cfg = store.configure_log_expiry(0, actor="test")
-print(f"[PASS] {cfg['message']}")
-
-# Log stats
-print()
-print("[TEST] Log stats")
-stats = store.get_log_stats()
-print(f"[PASS] Total logs: {stats['total_logs']}, buckets: {stats['buckets']}")
-
-# get_log_expiry_days returns correct value
-days = store.get_log_expiry_days()
-print(f"[PASS] get_log_expiry_days: {days}")
-
-print()
-print("=== TOOL DEFINITION CHECK ===")
-tools = service.list_tool_definitions()
-tool_names = [t["name"] for t in tools]
-expected = [
- "get_task_templates", "get_task_template", "create_task_template", "delete_task_template",
- "create_task_from_template", "quick_log", "get_audit_log",
- "reset_project", "bulk_task_ops", "export_project",
- "configure_log_expiry", "expire_old_logs", "get_log_stats",
- "session_replay",
- "add_task_dependency", "remove_task_dependency", "get_task_dependency",
- "get_all_dependencies", "get_blocked_tasks", "validate_dependencies",
-]
-for name in expected:
- status = "[PASS]" if name in tool_names else "[FAIL]"
- print(f" {status} {name}")
-
-print()
-print(f"Total tools registered: {len(tool_names)}")
-
-print()
-print("=== MCP TOOL CALL TESTS ===")
-for name, args in [
- ("get_task_templates", {}),
- ("get_task_template", {"name": "bug"}),
- ("quick_log", {"message": "Test"}),
- ("get_audit_log", {"limit": 3}),
- ("get_log_stats", {}),
- ("get_blocked_tasks", {}),
- ("validate_dependencies", {}),
- ("export_project", {"format": "markdown"}),
- ("session_replay", {}),
-]:
- try:
- r = service.call_tool(name, args)
- ok = "error" not in r and r is not None
- print(f"[PASS] {name}: OK" if ok else f"[FAIL] {name}: {r}")
- except Exception as e:
- print(f"[FAIL] {name}: {e}")
-
-print()
-print("ALL VERIFICATIONS COMPLETE")
diff --git a/tests/test_compression.py b/tests/test_compression.py
deleted file mode 100644
index 3cd71f0..0000000
--- a/tests/test_compression.py
+++ /dev/null
@@ -1,118 +0,0 @@
-from __future__ import annotations
-
-import unittest
-
-from server.compression import compress, compress_preserve_code
-
-
-class CompressionTestCase(unittest.TestCase):
- def test_empty_text_returns_empty(self) -> None:
- result = compress("")
- self.assertEqual(result.compressed, "")
- self.assertEqual(result.original_length, 0)
- self.assertFalse(result.was_compressed)
-
- def test_basic_lite_compression(self) -> None:
- text = "In order to complete the task, it is important to note that the function has been successfully completed."
- result = compress(text, level="lite")
- # Should have "To complete" and compression
- self.assertLess(len(result.compressed), len(text))
- self.assertGreater(result.saved_ratio, 0)
-
- def test_basic_full_compression(self) -> None:
- text = "The authentication middleware is handling the session token validation through the integrated JWT validation pipeline."
- result = compress(text, level="full")
- self.assertIn("Auth handles JWT validation", result.compressed)
-
- def test_ultra_compression_maximum(self) -> None:
- text = "The function has been successfully executed and the results are available. In order to complete the task."
- result = compress(text, level="ultra")
- # Ultra should compress more aggressively
- self.assertLess(len(result.compressed), len(text))
-
- def test_preserve_code_blocks(self) -> None:
- text = """Here is the code:
-
-```python
-def hello():
- return "Hello, World!"
-```
-
-The code above demonstrates the function."""
- result = compress_preserve_code(text, level="full")
- # Code block should be preserved
- self.assertIn('```python', result.compressed)
- self.assertIn('return "Hello, World!"', result.compressed)
-
- def test_preserve_urls(self) -> None:
- text = "Check out https://api.example.com/endpoint for more information."
- result = compress(text, level="full")
- self.assertIn("https://api.example.com/endpoint", result.compressed)
-
- def test_preserve_file_paths(self) -> None:
- text = "File created at D:\\Projects\\obsmcp\\server\\main.py"
- result = compress(text, level="full")
- self.assertIn("D:\\Projects\\obsmcp\\server\\main.py", result.compressed)
-
- def test_preserve_error_messages(self) -> None:
- text = "Error: Connection timeout after 30 seconds"
- result = compress(text, level="full")
- self.assertIn("Error: Connection timeout", result.compressed)
-
- def test_preserve_inline_code(self) -> None:
- text = "Use the `print()` function to output text."
- result = compress(text, level="full")
- self.assertIn("`print()`", result.compressed)
-
- def test_no_compression_when_no_patterns_match(self) -> None:
- text = "abc def ghi"
- result = compress(text, level="full")
- # Should still remove extra whitespace
- self.assertIn("abc def ghi", result.compressed)
-
- def test_removed_filler_words(self) -> None:
- text = "Obviously, the file has been created."
- result = compress(text, level="full")
- self.assertNotIn("Obviously,", result.compressed)
- # Should still have "the file has been created" but shorter
- self.assertLess(len(result.compressed), len(text))
-
- def test_saved_ratio_calculation(self) -> None:
- text = "In order to complete the task, it is important to note that the function has been successfully completed."
- result = compress(text, level="full")
- self.assertGreater(result.saved_ratio, 0)
- self.assertLessEqual(result.saved_ratio, 1.0)
- expected_saved = result.original_length - result.compressed_length
- self.assertAlmostEqual(result.saved_ratio, expected_saved / result.original_length, places=2)
-
- def test_was_compressed_flag(self) -> None:
- text = "In order to fix this, it is important to note that the authentication middleware is handling the session token validation through the integrated JWT validation pipeline."
- result = compress(text, level="full")
- self.assertTrue(result.was_compressed)
-
- # Short text with no patterns should not be marked as compressed
- result2 = compress("abc def ghi", level="full")
- self.assertFalse(result2.was_compressed)
-
- def test_multiple_pattern_applications(self) -> None:
- text = "In order to In order to In order to fix this."
- result = compress(text, level="full")
- # Multiple "In order to" should all be compressed
- self.assertNotIn("In order to In order to", result.compressed)
-
- def test_whitespace_normalization(self) -> None:
- text = "Hello world\n\n\n\nTest"
- result = compress(text, level="full")
- self.assertNotIn(" ", result.compressed) # No multiple spaces
- self.assertNotIn("\n\n\n", result.compressed) # No more than 2 newlines
-
- def test_full_vs_lite_different_savings(self) -> None:
- text = "The authentication middleware is handling the session token validation through the integrated JWT validation pipeline."
- lite_result = compress(text, level="lite")
- full_result = compress(text, level="full")
- # Full should compress more than lite
- self.assertLessEqual(full_result.compressed_length, lite_result.compressed_length)
-
-
-if __name__ == "__main__":
- unittest.main()
\ No newline at end of file
diff --git a/tests/test_config.py b/tests/test_config.py
deleted file mode 100644
index 7bbd16b..0000000
--- a/tests/test_config.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from __future__ import annotations
-
-import unittest
-
-from server.config import _parse_output_compression
-
-
-class ConfigParsingTestCase(unittest.TestCase):
- def test_output_compression_defaults_to_off_when_disabled(self) -> None:
- config = _parse_output_compression({"enabled": False, "level": "full"})
-
- self.assertFalse(config.enabled)
- self.assertEqual(config.mode, "off")
- self.assertTrue(config.preserve_patterns["code_blocks"])
- self.assertTrue(config.preserve_patterns["stack_traces"])
-
- def test_enabled_output_compression_defaults_to_prompt_only(self) -> None:
- config = _parse_output_compression({"enabled": True, "style": "concise_professional"})
-
- self.assertTrue(config.enabled)
- self.assertEqual(config.mode, "prompt_only")
- self.assertEqual(config.style, "concise_professional")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py
deleted file mode 100644
index bfe2a28..0000000
--- a/tests/test_mcp_protocol.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from __future__ import annotations
-
-import unittest
-from unittest.mock import Mock
-
-from server.mcp_protocol import handle_rpc
-
-
-class McpProtocolTestCase(unittest.TestCase):
- def test_tools_call_wraps_list_structured_content(self) -> None:
- service = Mock()
- service.call_tool.return_value = [{"id": 1}, {"id": 2}]
-
- response = handle_rpc(
- service,
- {
- "jsonrpc": "2.0",
- "id": 1,
- "method": "tools/call",
- "params": {"name": "get_active_tasks", "arguments": {}},
- },
- )
-
- assert response is not None
- self.assertEqual(response["result"]["structuredContent"], {"items": [{"id": 1}, {"id": 2}]})
- self.assertIn('"id": 1', response["result"]["content"][0]["text"])
-
- def test_tools_call_wraps_scalar_structured_content(self) -> None:
- service = Mock()
- service.call_tool.return_value = "ok"
-
- response = handle_rpc(
- service,
- {
- "jsonrpc": "2.0",
- "id": 2,
- "method": "tools/call",
- "params": {"name": "generate_compact_context", "arguments": {}},
- },
- )
-
- assert response is not None
- self.assertEqual(response["result"]["structuredContent"], {"value": "ok"})
- self.assertEqual(response["result"]["content"][0]["text"], "ok")
-
- def test_tools_call_keeps_dict_structured_content(self) -> None:
- service = Mock()
- service.call_tool.return_value = {"status": "ok"}
-
- response = handle_rpc(
- service,
- {
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tools/call",
- "params": {"name": "health_check", "arguments": {}},
- },
- )
-
- assert response is not None
- self.assertEqual(response["result"]["structuredContent"], {"status": "ok"})
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_opusmax_provider.py b/tests/test_opusmax_provider.py
deleted file mode 100644
index 1d569f1..0000000
--- a/tests/test_opusmax_provider.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from __future__ import annotations
-
-import os
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-from server.opusmax_provider import OpusMaxTextProvider, OpusMaxToolProvider
-
-
-class _FakeResponse:
- def __init__(self, payload: dict, status_code: int = 200) -> None:
- self._payload = payload
- self.status_code = status_code
- self.text = str(payload)
-
- def json(self) -> dict:
- return self._payload
-
-
-class OpusMaxProviderTestCase(unittest.TestCase):
- def test_text_provider_appends_response_contract_to_system_prompt(self) -> None:
- provider = OpusMaxTextProvider(api_key="test-key", base_url="https://api.opusmax.pro")
-
- with patch.object(provider._session, "post", return_value=_FakeResponse({"choices": [{"message": {"content": "{\"purpose\":\"p\",\"why_it_exists\":\"w\",\"how_it_is_used\":\"h\",\"inputs_outputs\":\"i\",\"side_effects\":\"s\",\"risks\":\"r\",\"language\":\"Python\"}"}}], "usage": {"prompt_tokens": 10, "completion_tokens": 20}})) as post:
- provider.generate_description(
- {"entity_type": "module", "name": "app.py", "file_path": "app.py", "signature": "", "feature_tags": [], "metadata": {}},
- "def run(): pass",
- response_contract="## Enforced Response Contract\n- Put the answer first.",
- )
-
- system_message = post.call_args.kwargs["json"]["messages"][0]["content"]
- self.assertIn("## Enforced Response Contract", system_message)
- self.assertIn("Put the answer first.", system_message)
-
- def test_web_search_uses_tools_endpoint(self) -> None:
- provider = OpusMaxToolProvider(api_key="test-key", base_url="https://api.opusmax.pro")
-
- with patch.object(provider._session, "post", return_value=_FakeResponse({"organic": [{"title": "obsmcp"}], "summary": "ok"}, status_code=201)) as post:
- result = provider.web_search("obsmcp")
-
- self.assertTrue(result["request_id"].startswith("ws_"))
- self.assertEqual(result["provider"], "opusmax")
- self.assertEqual(result["results"][0]["title"], "obsmcp")
- self.assertIn("/tools/web_search", post.call_args.args[0])
- self.assertEqual(post.call_args.kwargs["json"]["query"], "obsmcp")
-
- def test_web_search_rejects_invalid_bounds(self) -> None:
- provider = OpusMaxToolProvider(api_key="test-key", base_url="https://api.opusmax.pro")
- with self.assertRaises(ValueError):
- provider.web_search("x" * 1001)
- with self.assertRaises(ValueError):
- provider.web_search("obsmcp", max_results=0)
-
- def test_understand_image_converts_local_file_to_data_url(self) -> None:
- provider = OpusMaxToolProvider(api_key="test-key", base_url="https://api.opusmax.pro")
- temp_dir = Path(__file__).resolve().parent.parent / ".tmp-tests"
- temp_dir.mkdir(parents=True, exist_ok=True)
- image_path = temp_dir / "provider-image.png"
- image_path.write_bytes(b"\x89PNG\r\n\x1a\nfake")
- try:
- with patch.object(provider._session, "post", return_value=_FakeResponse({"analysis": "image ok"})) as post:
- result = provider.understand_image(prompt="Describe this image", image_path=str(image_path))
- finally:
- image_path.unlink(missing_ok=True)
-
- self.assertTrue(result["request_id"].startswith("img_"))
- self.assertEqual(result["provider"], "opusmax")
- self.assertEqual(result["analysis"], "image ok")
- payload = post.call_args.kwargs["json"]
- self.assertEqual(payload["prompt"], "Describe this image")
- self.assertTrue(payload["image_url"].startswith("data:image/png;base64,"))
- self.assertIn("/tools/understand_image", post.call_args.args[0])
-
- def test_understand_image_requires_an_image_source(self) -> None:
- provider = OpusMaxToolProvider(api_key="test-key", base_url="https://api.opusmax.pro")
- with self.assertRaises(ValueError):
- provider.understand_image(prompt="Describe this image")
-
- def test_understand_image_rejects_unsupported_mime_type(self) -> None:
- provider = OpusMaxToolProvider(api_key="test-key", base_url="https://api.opusmax.pro")
- with self.assertRaises(ValueError):
- provider.understand_image(prompt="Describe this image", image_base64="ZmFrZQ==", mime_type="image/gif")
-
- def test_provider_falls_back_to_claude_settings_env(self) -> None:
- with patch.dict(os.environ, {}, clear=True):
- with patch("server.opusmax_provider._read_claude_settings_env", return_value={
- "ANTHROPIC_AUTH_TOKEN": "settings-token",
- "ANTHROPIC_BASE_URL": "https://api.opusmax.pro",
- }):
- provider = OpusMaxToolProvider()
-
- self.assertEqual(provider.api_key, "settings-token")
- self.assertEqual(provider.base_url, "https://api.opusmax.pro")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_output_policy.py b/tests/test_output_policy.py
deleted file mode 100644
index bd48ace..0000000
--- a/tests/test_output_policy.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from __future__ import annotations
-
-import unittest
-
-from server.config import OutputCompressionConfig, TaskOutputOverrideConfig
-from server.output_policy import resolve_output_policy
-
-
-class OutputPolicyTestCase(unittest.TestCase):
- def test_review_task_uses_findings_first_contract(self) -> None:
- config = OutputCompressionConfig(
- enabled=True,
- mode="prompt_only",
- task_overrides={"review": TaskOutputOverrideConfig(style="terse_technical", level="full")},
- )
-
- policy = resolve_output_policy(
- config,
- task={"title": "Code review pagination regression", "description": "Review bug fix", "tags": ["review"]},
- )
-
- self.assertEqual(policy.mode, "prompt_only")
- self.assertEqual(policy.task_type, "review")
- self.assertEqual(policy.style, "terse_technical")
- self.assertIn("findings and risks", policy.prompt_contract)
-
- def test_detail_request_bypasses_compression(self) -> None:
- config = OutputCompressionConfig(enabled=True, mode="prompt_only", expand_on_request=True)
-
- policy = resolve_output_policy(config, detail_requested=True)
-
- self.assertEqual(policy.mode, "off")
- self.assertTrue(policy.bypassed)
- self.assertEqual(policy.bypass_reason, "detail_requested")
-
- def test_destructive_command_bypasses_compression(self) -> None:
- config = OutputCompressionConfig(enabled=True, mode="prompt_only")
-
- policy = resolve_output_policy(config, command="rm -rf build", operation_kind="dangerous_actions")
-
- self.assertEqual(policy.mode, "off")
- self.assertEqual(policy.task_type, "dangerous_actions")
- self.assertEqual(policy.bypass_reason, "destructive_actions")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_service.py b/tests/test_service.py
deleted file mode 100644
index 0c363ce..0000000
--- a/tests/test_service.py
+++ /dev/null
@@ -1,1967 +0,0 @@
-from __future__ import annotations
-
-import json
-import shutil
-import tempfile
-import time
-import unittest
-import uuid
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from typing import Any
-from unittest.mock import patch
-
-from server.config import AppConfig, ObsidianConfig, TaskOutputOverrideConfig
-from server.service import ObsmcpService
-
-
-class ServiceTestCase(unittest.TestCase):
- def setUp(self) -> None:
- local_temp_root = Path(__file__).resolve().parent.parent / ".tmp-tests"
- local_temp_root.mkdir(parents=True, exist_ok=True)
- self.temp_dir = local_temp_root / f"obsmcp-test-{uuid.uuid4().hex[:8]}"
- self.temp_dir.mkdir(parents=True, exist_ok=True)
- self.config = AppConfig(
- root_dir=self.temp_dir,
- app_name="obsmcp",
- description="test",
- host="127.0.0.1",
- port=9300,
- bind_local_only=True,
- database_path=self.temp_dir / "data" / "db" / "obsmcp.sqlite3",
- json_export_dir=self.temp_dir / "data" / "json",
- backup_dir=self.temp_dir / "data" / "backups",
- log_dir=self.temp_dir / "logs",
- context_dir=self.temp_dir / ".context",
- obsidian_vault_dir=self.temp_dir / "obsidian" / "vault",
- pid_file=self.temp_dir / "data" / "obsmcp.pid",
- max_recent_work_items=12,
- max_decisions=20,
- max_blockers=20,
- api_token=None,
- obsidian=ObsidianConfig(
- project_brief_note="Projects/Project Brief.md",
- current_task_note="Projects/Current Task.md",
- status_snapshot_note="Projects/Status Snapshot.md",
- latest_handoff_note="Handoffs/Latest Handoff.md",
- decision_index_note="Decisions/Decision Log.md",
- daily_notes_dir="Daily",
- session_note="Sessions/Latest Session Summary.md",
- code_atlas_note="Research/Code Atlas.md",
- ),
- )
- self.config.ensure_directories()
- self.service = ObsmcpService(self.config)
-
- def tearDown(self) -> None:
- shutil.rmtree(self.temp_dir, ignore_errors=True)
-
- def test_continuity_flow_syncs_context_and_obsidian(self) -> None:
- project_paths = self.service.get_project_workspace_paths(project_path=str(self.temp_dir))
- task = self.service.create_task(
- title="Implement continuity",
- description="Create shared state and sync outputs.",
- relevant_files=["server/main.py", "cli/main.py"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- self.service.log_work(
- message="Implemented shared continuity flow.",
- task_id=task["id"],
- files=["server/service.py"],
- actor="test",
- )
- self.service.log_decision(
- title="Use SQLite",
- decision="SQLite is the local source of truth.",
- rationale="Minimal ops burden.",
- impact="Single-file backup and recovery.",
- task_id=task["id"],
- actor="test",
- )
- self.service.create_handoff(
- summary="Core flow is implemented and synced.",
- next_steps="Add more client integrations.",
- task_id=task["id"],
- from_actor="test",
- to_actor="next-model",
- )
- self.service.create_daily_note_entry("Validated sync path.", actor="test")
-
- context_dir = Path(project_paths["context_path"])
- vault_dir = Path(project_paths["vault_path"])
- current_task = json.loads((context_dir / "CURRENT_TASK.json").read_text(encoding="utf-8"))
- self.assertEqual(current_task["id"], task["id"])
- self.assertTrue((context_dir / "HANDOFF.md").exists())
- self.assertTrue((context_dir / "RESUME_PACKET.md").exists())
- self.assertTrue((vault_dir / "Projects" / "Project Brief.md").exists())
- self.assertTrue((vault_dir / "Decisions" / "ADR-0001.md").exists())
-
- def test_generate_compact_context(self) -> None:
- task = self.service.create_task(
- title="Compact context",
- description="Keep token usage low.",
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- content = self.service.generate_compact_context()
- self.assertIn("Compact Context", content)
- self.assertIn(task["id"], content)
-
- def test_startup_does_not_bootstrap_default_project_by_default(self) -> None:
- default_repo = self.temp_dir / "default-repo"
- default_repo.mkdir()
- self.config.default_project_path = default_repo
-
- service = ObsmcpService(self.config)
-
- self.assertEqual(service._stores, {})
- self.assertIsNone(service.store)
- self.assertEqual(list((self.temp_dir / "projects").iterdir()), [])
-
- def test_health_check_without_project_does_not_create_default_project(self) -> None:
- default_repo = self.temp_dir / "default-repo"
- default_repo.mkdir()
- self.config.default_project_path = default_repo
-
- service = ObsmcpService(self.config)
- status = service.health_check()
-
- self.assertIsNone(status["project_path"])
- self.assertFalse(status["db_exists"])
- self.assertFalse(status["bootstrap_default_project_on_startup"])
- self.assertEqual(list((self.temp_dir / "projects").iterdir()), [])
-
- def test_startup_can_bootstrap_default_project_when_enabled(self) -> None:
- default_repo = self.temp_dir / "default-repo"
- default_repo.mkdir()
- self.config.default_project_path = default_repo
- self.config.bootstrap_default_project_on_startup = True
-
- service = ObsmcpService(self.config)
-
- self.assertIsNotNone(service.store)
- self.assertEqual(len(service._stores), 1)
- self.assertTrue(any((self.temp_dir / "projects").iterdir()))
-
- def test_generate_context_profile_caches_and_refreshes(self) -> None:
- task = self.service.create_task(
- title="Tiered context",
- description="Verify cached context profiles refresh on writes.",
- relevant_files=["server/service.py"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
-
- first = self.service.generate_context_profile(profile="balanced", task_id=task["id"], max_tokens=1800)
- self.assertFalse(first["cached"])
- self.assertIn(task["id"], first["markdown"])
-
- second = self.service.generate_context_profile(profile="balanced", task_id=task["id"], max_tokens=1800)
- self.assertTrue(second["cached"])
-
- self.service.log_work(
- message="Added a fresh work log to invalidate cached context.",
- task_id=task["id"],
- files=["server/store.py"],
- actor="test",
- )
-
- refreshed = self.service.generate_context_profile(profile="balanced", task_id=task["id"], max_tokens=1800)
- self.assertFalse(refreshed["cached"])
- self.assertIn("Added a fresh work log", refreshed["markdown"])
-
- def test_generate_delta_context_since_handoff(self) -> None:
- repo = self.temp_dir / "delta-repo"
- repo.mkdir()
- task = self.service.create_task(
- title="Delta context",
- description="Track only recent changes after a handoff.",
- relevant_files=[str(repo / "app.py")],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
- handoff = self.service.create_handoff(
- summary="Baseline handoff before new changes.",
- next_steps="Continue after new writes.",
- task_id=task["id"],
- from_actor="test",
- to_actor="next-agent",
- project_path=str(repo),
- )
- self.service.log_work(
- message="Implemented follow-up change after handoff.",
- task_id=task["id"],
- files=[str(repo / "app.py")],
- actor="test",
- project_path=str(repo),
- )
- self.service.log_decision(
- title="Prefer delta reads",
- decision="Use delta context for resumed sessions.",
- rationale="It reduces rereading unchanged project history.",
- impact="Faster startup for follow-up work.",
- task_id=task["id"],
- actor="test",
- project_path=str(repo),
- )
-
- delta = self.service.generate_delta_context(since_handoff_id=handoff["id"], task_id=task["id"], project_path=str(repo))
- self.assertFalse(delta["cached"])
- self.assertIn("Implemented follow-up change after handoff.", delta["markdown"])
- self.assertIn("Prefer delta reads", delta["markdown"])
- self.assertEqual(delta["metadata"]["counts"]["work_logs"], 1)
-
- def test_sync_context_writes_tiered_and_delta_artifacts(self) -> None:
- task = self.service.create_task(
- title="Sync context artifacts",
- description="Ensure hot/balanced/deep/delta outputs are written.",
- relevant_files=["server/service.py"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- self.service.log_work(
- message="Prepared context artifact sync verification.",
- task_id=task["id"],
- files=["server/service.py"],
- actor="test",
- )
- sync_result = self.service.sync_context(project_path=str(self.temp_dir))
- files = sync_result["files"]
- self.assertIn("HOT_CONTEXT.md", files)
- self.assertIn("BALANCED_CONTEXT.md", files)
- self.assertIn("DEEP_CONTEXT.md", files)
- self.assertIn("DELTA_CONTEXT.md", files)
- self.assertIn("RETRIEVAL_CONTEXT.md", files)
- self.assertIn("STABLE_CONTEXT.md", files)
- self.assertIn("DYNAMIC_CONTEXT.md", files)
- self.assertIn("prompt_segments.json", files)
- self.assertIn("retrieval_context.json", files)
- self.assertIn("token_usage_stats.json", files)
- self.assertTrue(Path(files["HOT_CONTEXT.md"]).exists())
- self.assertTrue(Path(files["DELTA_CONTEXT.md"]).exists())
- self.assertTrue(Path(files["RETRIEVAL_CONTEXT.md"]).exists())
- self.assertTrue(Path(files["STABLE_CONTEXT.md"]).exists())
- self.assertTrue(Path(files["DYNAMIC_CONTEXT.md"]).exists())
-
- def test_compact_tool_output_saves_raw_capture_and_metrics(self) -> None:
- output = "\n".join(
- [
- "============================= test session starts =============================",
- "collected 2 items",
- "tests/test_example.py::test_ok PASSED",
- "tests/test_example.py::test_fail FAILED",
- "E AssertionError: expected 1 == 2",
- "Traceback (most recent call last):",
- " File \"tests/test_example.py\", line 42, in test_fail",
- " assert 1 == 2",
- ]
- + [f"debug line {idx}" for idx in range(120)]
- )
- result = self.service.compact_tool_output(
- command="pytest -q",
- output=output,
- exit_code=1,
- actor="test",
- project_path=str(self.temp_dir),
- )
-
- self.assertEqual(result["profile"], "tests")
- self.assertTrue(result["was_compacted"])
- self.assertLess(result["compact_tokens_est"], result["raw_tokens_est"])
- self.assertIsNotNone(result["raw_capture"])
- capture = self.service.get_raw_output_capture(result["raw_capture"]["capture_id"], include_content=True, project_path=str(self.temp_dir))
- self.assertIsNotNone(capture)
- self.assertIn("AssertionError", capture["content"])
-
- stats = self.service.get_token_usage_stats(project_path=str(self.temp_dir))
- operations = {item["operation"] for item in stats["by_operation"]}
- self.assertIn("compact_tool_output", operations)
- self.assertGreater(stats["totals"]["saved_tokens"], 0)
-
- def test_record_command_event_persists_summaries_and_raw_capture(self) -> None:
- task = self.service.create_task(
- title="Bug: Command verification",
- description="Track terminal results without replaying raw output.",
- relevant_files=["server/service.py"],
- tags=["bug"],
- actor="test",
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Capture a failing command",
- session_goal="Store command history",
- task_id=task["id"],
- )
- stdout = "\n".join([f"collect line {idx}" for idx in range(40)])
- stderr = "\n".join(
- [
- "tests/test_cli.py::test_fail FAILED",
- "E AssertionError: expected ok",
- "Traceback (most recent call last):",
- " File \"tests/test_cli.py\", line 14, in test_fail",
- ]
- )
-
- event = self.service.record_command_event(
- command_text="pytest -q",
- stdout=stdout,
- stderr=stderr,
- exit_code=1,
- duration_ms=1850,
- actor="test-agent",
- session_id=session["id"],
- task_id=task["id"],
- files_changed=["tests/test_cli.py", "server/service.py"],
- project_path=str(self.temp_dir),
- )
-
- self.assertEqual(event["status"], "failed")
- self.assertEqual(event["exit_code"], 1)
- self.assertEqual(event["output_profile"], "tests")
- self.assertIn("AssertionError", event["stderr_summary"])
- self.assertIn("pytest -q", event["summary"])
- self.assertTrue(event["raw_output_available"])
- self.assertIsNotNone(event["raw_capture"])
- self.assertEqual(event["files_changed"], ["tests/test_cli.py", "server/service.py"])
-
- loaded = self.service.get_command_event(event["id"], project_path=str(self.temp_dir))
- self.assertIsNotNone(loaded)
- self.assertEqual(loaded["id"], event["id"])
-
- latest = self.service.get_last_command_result(session_id=session["id"], project_path=str(self.temp_dir))
- self.assertIsNotNone(latest)
- self.assertEqual(latest["id"], event["id"])
-
- failures = self.service.get_command_failures(session_id=session["id"], project_path=str(self.temp_dir))
- self.assertEqual(len(failures), 1)
- self.assertEqual(failures[0]["id"], event["id"])
-
- stats = self.service.get_token_usage_stats(project_path=str(self.temp_dir))
- operations = {item["operation"] for item in stats["by_operation"]}
- self.assertIn("record_command_event", operations)
-
- def test_command_history_fast_paths_and_delta_context(self) -> None:
- task = self.service.create_task(
- title="Feature: Command history",
- description="Expose recent commands through deterministic fast paths.",
- relevant_files=["server/service.py", "server/store.py"],
- tags=["feature"],
- actor="test",
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Track command history",
- session_goal="Use fast-path command lookup",
- task_id=task["id"],
- )
- handoff = self.service.create_handoff(
- summary="Baseline before command activity.",
- next_steps="Record command events after this point.",
- task_id=task["id"],
- from_actor="test",
- to_actor="next-agent",
- project_path=str(self.temp_dir),
- )
- self.service.record_command_event(
- command_text="rg TODO server",
- output="server/service.py:123: TODO improve batching\nserver/store.py:456: TODO cleanup\n",
- exit_code=0,
- actor="test-agent",
- session_id=session["id"],
- task_id=task["id"],
- files_changed=["server/service.py", "server/store.py"],
- project_path=str(self.temp_dir),
- )
- failure = self.service.record_command_event(
- command_text="pytest tests/test_service.py -q",
- stderr="FAILED tests/test_service.py::test_example\nAssertionError: boom\nTraceback\n",
- exit_code=1,
- actor="test-agent",
- session_id=session["id"],
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
-
- fast_recent = self.service.get_fast_path_response(
- kind="recent_commands",
- session_id=session["id"],
- as_markdown=True,
- project_path=str(self.temp_dir),
- )
- self.assertIn("Fast Path: Recent Commands", fast_recent["markdown"])
- self.assertIn("pytest tests/test_service.py -q", fast_recent["markdown"])
-
- fast_last = self.service.get_fast_path_response(
- kind="last_command",
- session_id=session["id"],
- project_path=str(self.temp_dir),
- )
- self.assertEqual(fast_last["json"]["id"], failure["id"])
-
- fast_failures = self.service.get_fast_path_response(
- kind="command_failures",
- session_id=session["id"],
- project_path=str(self.temp_dir),
- )
- self.assertEqual(len(fast_failures["json"]), 1)
- self.assertEqual(fast_failures["json"][0]["id"], failure["id"])
-
- status = self.service.get_project_status_snapshot(project_path=str(self.temp_dir))
- self.assertTrue(status["recent_commands"])
-
- delta = self.service.generate_delta_context(
- since_handoff_id=handoff["id"],
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertIn("## Command Activity", delta["markdown"])
- self.assertEqual(delta["metadata"]["counts"]["command_events"], 2)
-
- def test_hot_write_paths_can_defer_sync(self) -> None:
- task = self.service.create_task(
- title="Feature: Deferred sync",
- description="Avoid heavy sync work on hot command/session writes.",
- actor="test",
- )
- with patch.object(self.service, "_submit_deferred_sync") as submit_sync, patch.object(self.service, "sync_all") as sync_all:
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Open with deferred sync",
- session_goal="Stay on the fast path",
- task_id=task["id"],
- )
- self.assertEqual(session["sync"]["mode"], "deferred")
- submit_sync.assert_called_once()
- sync_all.assert_not_called()
-
- with patch.object(self.service, "_submit_deferred_sync") as submit_sync, patch.object(self.service, "sync_all") as sync_all:
- event = self.service.record_command_event(
- command_text="rg TODO server",
- output="server/service.py:10: TODO\n",
- actor="test-agent",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertEqual(event["sync"]["mode"], "deferred")
- submit_sync.assert_called_once()
- sync_all.assert_not_called()
-
- def test_command_policy_and_batch_recording(self) -> None:
- task = self.service.create_task(
- title="Feature: Command batches",
- description="Classify and batch safe terminal commands.",
- actor="test",
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Classify batchable commands",
- session_goal="Store a batch summary",
- task_id=task["id"],
- sync_mode="none",
- )
-
- read_policy = self.service.get_command_execution_policy(
- command="rg TODO server",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- install_policy = self.service.get_command_execution_policy(
- command="npm install",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertEqual(read_policy["risk_level"], "low")
- self.assertTrue(read_policy["can_batch"])
- self.assertEqual(install_policy["risk_level"], "high")
- self.assertTrue(install_policy["needs_model_review"])
-
- batch = self.service.record_command_batch(
- commands=[
- {"command_text": "rg TODO server", "output": "server/service.py:10: TODO\n"},
- {"command_text": "git status", "output": "On branch main\nnothing to commit\n"},
- ],
- actor="test-agent",
- session_id=session["id"],
- task_id=task["id"],
- sync_mode="none",
- project_path=str(self.temp_dir),
- )
- self.assertEqual(batch["command_count"], 2)
- self.assertEqual(batch["risk_counts"]["low"], 2)
- self.assertTrue(all(item["metadata"]["batch_id"] == batch["batch_id"] for item in batch["commands"]))
-
- def test_startup_context_prefers_command_history_and_cached_delta(self) -> None:
- task = self.service.create_task(
- title="Feature: Startup context",
- description="Resume with delta and recent command summaries.",
- relevant_files=["server/service.py"],
- actor="test",
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Prepare startup context",
- session_goal="Resume quickly",
- task_id=task["id"],
- sync_mode="none",
- )
- self.service.record_command_event(
- command_text="pytest -q",
- stderr="FAILED tests/test_service.py::test_example\nAssertionError: boom\n",
- exit_code=1,
- actor="test-agent",
- session_id=session["id"],
- task_id=task["id"],
- sync_mode="none",
- project_path=str(self.temp_dir),
- )
- self.service._run_precompute(str(self.temp_dir))
-
- startup = self.service.generate_startup_context(
- task_id=task["id"],
- session_id=session["id"],
- prefer_cached_delta=True,
- project_path=str(self.temp_dir),
- )
- self.assertTrue(startup["delta_cached"])
- self.assertIn("## Recent Commands", startup["markdown"])
- self.assertIn("## Recent Command Failures", startup["markdown"])
- self.assertIn("## Execution Policy Hint", startup["markdown"])
-
- fast = self.service.get_fast_path_response(
- kind="startup_context",
- session_id=session["id"],
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertIn("Startup Context", fast["markdown"])
-
- resume = self.service.generate_resume_packet(task_id=task["id"], project_path=str(self.temp_dir), write_files=False)
- self.assertIn("## Recent Commands", resume["markdown"])
- self.assertIn("## Recent Command Failures", resume["markdown"])
-
- startup_resource = self.service.get_resource("obsmcp://context/startup", project_path=str(self.temp_dir))
- self.assertIn("Startup Context", startup_resource["text"])
-
- def test_generate_prompt_segments_and_token_metrics(self) -> None:
- task = self.service.create_task(
- title="Prompt segments",
- description="Verify stable and dynamic prompt segments.",
- relevant_files=["server/service.py"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- self.service.log_work(
- message="Prepared prompt segment verification.",
- task_id=task["id"],
- files=["server/service.py"],
- actor="test",
- )
-
- segments = self.service.generate_prompt_segments(task_id=task["id"], project_path=str(self.temp_dir))
- self.assertIn("Stable Prompt Prefix", segments["stable_markdown"])
- self.assertIn("## Architecture", segments["stable_markdown"])
- self.assertIn(task["id"], segments["dynamic_markdown"])
- self.assertIn("## Latest Handoff", segments["dynamic_markdown"])
-
- self.service.record_token_usage(
- operation="claude_api_call",
- event_type="provider_usage",
- provider="opusmax",
- model_name="claude-opus",
- raw_input_tokens=1200,
- raw_output_tokens=240,
- cache_creation_input_tokens=600,
- cache_read_input_tokens=300,
- project_path=str(self.temp_dir),
- metadata={"source": "unit-test"},
- )
- stats = self.service.get_token_usage_stats(project_path=str(self.temp_dir))
- self.assertGreaterEqual(stats["event_count"], 2)
- self.assertGreater(stats["totals"]["cache_creation_input_tokens"], 0)
- self.assertGreater(stats["totals"]["cache_read_input_tokens"], 0)
-
- stable_resource = self.service.get_resource("obsmcp://context/stable", project_path=str(self.temp_dir))
- dynamic_resource = self.service.get_resource("obsmcp://context/dynamic", project_path=str(self.temp_dir))
- metrics_resource = self.service.get_resource("obsmcp://metrics/tokens", project_path=str(self.temp_dir))
- self.assertIn("Stable Prompt Prefix", stable_resource["text"])
- self.assertIn(task["id"], dynamic_resource["text"])
- self.assertGreater(metrics_resource["json"]["event_count"], 0)
-
- def test_list_tools_includes_opusmax_backed_web_and_image_tools(self) -> None:
- tool_names = {tool["name"] for tool in self.service.list_tool_definitions()}
- self.assertIn("web_search", tool_names)
- self.assertIn("understand_image", tool_names)
-
- def test_generate_startup_prompt_template_appends_output_contract_and_metrics(self) -> None:
- self.config.output_compression.enabled = True
- self.config.output_compression.mode = "prompt_only"
- self.config.output_compression.style = "concise_professional"
- service = ObsmcpService(self.config)
-
- rendered = service.generate_startup_prompt_template(project_path=str(self.temp_dir))
-
- self.assertIn("## Response Style Contract", rendered)
- self.assertIn("Start with the answer", rendered)
- stats = service.get_token_usage_stats(project_path=str(self.temp_dir), operation="generate_startup_prompt_template")
- self.assertGreaterEqual(stats["event_count"], 1)
-
- def test_generate_startup_prompt_template_supports_gateway_enforced_contract(self) -> None:
- self.config.output_compression.enabled = True
- self.config.output_compression.mode = "gateway_enforced"
- service = ObsmcpService(self.config)
-
- rendered = service.generate_startup_prompt_template(project_path=str(self.temp_dir))
-
- self.assertIn("## Enforced Response Contract", rendered)
- self.assertIn("Put the answer first.", rendered)
-
- def test_get_command_execution_policy_includes_output_policy_bypass(self) -> None:
- self.config.output_compression.enabled = True
- self.config.output_compression.mode = "prompt_only"
- service = ObsmcpService(self.config)
-
- policy = service.get_command_execution_policy(
- command="Remove-Item -LiteralPath build -Recurse -Force",
- project_path=str(self.temp_dir),
- )
-
- self.assertEqual(policy["action_type"], "destructive")
- self.assertEqual(policy["output_policy"]["mode"], "off")
- self.assertEqual(policy["output_policy"]["bypass_reason"], "destructive_actions")
-
- def test_get_output_response_policy_honors_task_override(self) -> None:
- self.config.output_compression.enabled = True
- self.config.output_compression.mode = "prompt_only"
- self.config.output_compression.task_overrides["review"] = TaskOutputOverrideConfig(style="terse_technical", level="full")
- service = ObsmcpService(self.config)
- task = service.create_task(
- title="Code review pagination changes",
- description="Review regression-sensitive output behavior.",
- tags=["review"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- service.set_current_task(task_id=task["id"], actor="test", project_path=str(self.temp_dir))
-
- policy = service.call_tool(
- "get_output_response_policy",
- {
- "task_id": task["id"],
- "operation_kind": "review",
- "project_path": str(self.temp_dir),
- },
- )
-
- self.assertEqual(policy["mode"], "prompt_only")
- self.assertEqual(policy["style"], "terse_technical")
- self.assertEqual(policy["task_type"], "review")
- self.assertIn("findings and risks", policy["prompt_contract"])
-
- def test_web_search_records_provider_usage(self) -> None:
- class FakeToolProvider:
- def web_search(self, query: str, max_results: int | None = None) -> dict[str, Any]:
- return {
- "request_id": "ws_test123",
- "provider": "opusmax",
- "endpoint": "/tools/web_search",
- "query": query,
- "latency_ms": 12.5,
- "results": [{"title": "obsmcp"}],
- "summary": "result ok",
- "raw": {"results": [{"title": "obsmcp"}]},
- }
-
- with patch("server.service.get_opusmax_tool_provider", return_value=FakeToolProvider()):
- result = self.service.web_search(
- query="obsmcp",
- actor="test",
- session_id="SESSION-1",
- task_id="TASK-1",
- client_name="unit-test",
- project_path=str(self.temp_dir),
- )
-
- self.assertTrue(result["request_id"].startswith("ws_"))
- self.assertEqual(result["provider"], "opusmax")
- self.assertEqual(result["results"][0]["title"], "obsmcp")
- stats = self.service.get_token_usage_stats(project_path=str(self.temp_dir), operation="web_search")
- self.assertGreaterEqual(stats["event_count"], 1)
-
- def test_understand_image_records_provider_usage(self) -> None:
- class FakeToolProvider:
- def understand_image(self, **kwargs: Any) -> dict[str, Any]:
- return {
- "request_id": "img_test123",
- "provider": "opusmax",
- "endpoint": "/tools/understand_image",
- "prompt": kwargs["prompt"],
- "latency_ms": 9.1,
- "image_source": {"kind": "url"},
- "analysis": "image ok",
- "raw": {"analysis": "image ok"},
- }
-
- with patch("server.service.get_opusmax_tool_provider", return_value=FakeToolProvider()):
- result = self.service.understand_image(
- prompt="Describe this image",
- image_url="https://example.com/test.png",
- actor="test",
- session_id="SESSION-2",
- task_id="TASK-2",
- client_name="unit-test",
- project_path=str(self.temp_dir),
- )
-
- self.assertTrue(result["request_id"].startswith("img_"))
- self.assertEqual(result["provider"], "opusmax")
- self.assertEqual(result["analysis"], "image ok")
- stats = self.service.get_token_usage_stats(project_path=str(self.temp_dir), operation="understand_image")
- self.assertGreaterEqual(stats["event_count"], 1)
-
- def test_retrieval_context_fast_paths_and_resume_targeting(self) -> None:
- task = self.service.create_task(
- title="Bug: Prompt cache regression",
- description="Investigate cache misses in prompt assembly.",
- relevant_files=["server/service.py", "server/store.py"],
- tags=["bug"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- self.service.log_work(
- message="Investigated prompt cache misses in segment assembly.",
- task_id=task["id"],
- files=["server/service.py"],
- actor="test",
- )
- self.service.log_decision(
- title="Bias bug retrieval",
- decision="Show blockers and recent work before semantic hints for bug tasks.",
- rationale="Bug investigations need current failure context first.",
- impact="Faster debugging startup.",
- task_id=task["id"],
- actor="test",
- )
-
- retrieval = self.service.generate_retrieval_context(
- query="prompt cache misses",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertIn("Retrieval Context", retrieval["markdown"])
- self.assertIn(task["id"], retrieval["markdown"])
- self.assertIn("Ranked Recent Work", retrieval["markdown"])
-
- fast = self.service.get_fast_path_response(
- kind="current_task",
- task_id=task["id"],
- as_markdown=True,
- project_path=str(self.temp_dir),
- )
- self.assertEqual(fast["source"], "deterministic")
- self.assertIn(task["id"], fast["markdown"])
-
- status_fast = self.service.get_fast_path_response(kind="project_status", project_path=str(self.temp_dir))
- self.assertEqual(status_fast["json"]["current_task"]["id"], task["id"])
-
- resume = self.service.generate_resume_packet(task_id=task["id"], project_path=str(self.temp_dir), write_files=False)
- self.assertIn("## Targeted Retrieval", resume["markdown"])
- self.assertIn("Matched Files:", resume["markdown"])
-
- retrieval_resource = self.service.get_resource("obsmcp://context/retrieval", project_path=str(self.temp_dir))
- self.assertIn("Retrieval Context", retrieval_resource["text"])
-
- def test_chunk_navigation_and_progressive_context(self) -> None:
- task = self.service.create_task(
- title="Feature: Progressive context",
- description="Verify chunk navigation for prompt segments and retrieval context.",
- relevant_files=["server/service.py", "tests/test_service.py", "docs/ARCHITECTURE.md"],
- tags=["feature"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
- self.service.log_work(
- message="Prepared multi-section context for chunk navigation.",
- task_id=task["id"],
- files=["server/service.py", "tests/test_service.py"],
- actor="test",
- )
-
- chunk_plan = self.service.list_context_chunks(
- artifact_type="prompt_segments",
- profile="balanced",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertGreaterEqual(chunk_plan["total_chunks"], 1)
- self.assertTrue(chunk_plan["chunks"])
-
- first_chunk = self.service.retrieve_context_chunk(
- artifact_type="prompt_segments",
- chunk_index=0,
- profile="balanced",
- task_id=task["id"],
- project_path=str(self.temp_dir),
- )
- self.assertIn("markdown", first_chunk)
- if first_chunk["is_last"]:
- self.assertIsNone(first_chunk["next_chunk_index"])
- else:
- self.assertIsNotNone(first_chunk["next_chunk_index"])
-
- progressive = self.service.generate_progressive_context(
- artifact_type="retrieval_context",
- profile="balanced",
- start_chunk=0,
- chunk_count=1,
- task_id=task["id"],
- query="progressive context",
- project_path=str(self.temp_dir),
- )
- self.assertEqual(progressive["chunk_count"], 1)
- self.assertIn("combined_markdown", progressive)
-
- def test_optimization_policy_adapts_bug_debug_compaction(self) -> None:
- task = self.service.create_task(
- title="Bug: Test output overflow",
- description="Need more failure detail in debug mode.",
- relevant_files=["server/service.py"],
- tags=["bug"],
- actor="test",
- )
- self.service.set_current_task(task_id=task["id"], actor="test")
-
- compact_policy = self.service.get_optimization_policy(
- mode="compact",
- task_id=task["id"],
- command="pytest -q",
- exit_code=1,
- project_path=str(self.temp_dir),
- )
- debug_policy = self.service.get_optimization_policy(
- mode="debug",
- task_id=task["id"],
- command="pytest -q",
- exit_code=1,
- project_path=str(self.temp_dir),
- )
- self.assertEqual(compact_policy["task_type"], "bug")
- self.assertGreater(debug_policy["window_scale"], compact_policy["window_scale"])
- self.assertTrue(debug_policy["raw_capture_on_failure"])
-
- output = "\n".join([f"line {idx}" for idx in range(80)] + ["FAILED", "Traceback", "AssertionError"])
- compact_result = self.service.compact_tool_output(
- command="pytest -q",
- output=output,
- exit_code=1,
- policy_mode="compact",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- debug_result = self.service.compact_tool_output(
- command="pytest -q",
- output=output,
- exit_code=1,
- policy_mode="debug",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.assertGreaterEqual(debug_result["compact_lines"], compact_result["compact_lines"])
- self.assertEqual(debug_result["policy_mode"], "debug")
-
- def test_scan_codebase_ignores_generated_runtime_artifacts(self) -> None:
- (self.temp_dir / "app.py").write_text(
- "def hello() -> str:\n return 'world'\n",
- encoding="utf-8",
- )
- first = self.service.scan_codebase(project_path=str(self.temp_dir), force_refresh=True)
- self.assertEqual(first["status"], "generated")
-
- (self.temp_dir / "projects" / "generated-project" / "vault").mkdir(parents=True, exist_ok=True)
- (self.temp_dir / "projects" / "generated-project" / "vault" / "note.md").write_text("# generated\n", encoding="utf-8")
- (self.temp_dir / "logs").mkdir(parents=True, exist_ok=True)
- (self.temp_dir / "logs" / "obsmcp.log").write_text("runtime log\n", encoding="utf-8")
- (self.temp_dir / "data").mkdir(parents=True, exist_ok=True)
- (self.temp_dir / "data" / "obsmcp.pid").write_text("1234\n", encoding="utf-8")
- (self.temp_dir / ".context").mkdir(parents=True, exist_ok=True)
- (self.temp_dir / ".context" / "PROJECT_CONTEXT.md").write_text("# context\n", encoding="utf-8")
- (self.temp_dir / "obsidian" / "vault" / "Research").mkdir(parents=True, exist_ok=True)
- (self.temp_dir / "obsidian" / "vault" / "Research" / "Generated.md").write_text("# generated note\n", encoding="utf-8")
-
- second = self.service.scan_codebase(project_path=str(self.temp_dir), force_refresh=False)
- self.assertEqual(second["status"], "current")
-
- def test_background_scan_job_polling(self) -> None:
- repo = self.temp_dir / "job-repo"
- repo.mkdir()
- (repo / "app.py").write_text("def greet() -> str:\n return 'hi'\n", encoding="utf-8")
-
- queued = self.service.call_tool("scan_codebase", {"project_path": str(repo), "force_refresh": True})
- self.assertIn(queued["status"], {"queued", "running"})
- self.assertTrue(queued["id"].startswith("SCAN-"))
-
- finished = self.service.wait_for_scan_job(queued["id"], project_path=str(repo), wait_seconds=30)
- self.assertEqual(finished["status"], "completed")
- self.assertEqual(finished["result"]["status"], "generated")
-
- current = self.service.call_tool("scan_codebase", {"project_path": str(repo), "force_refresh": False})
- self.assertEqual(current["status"], "current")
-
- def test_session_audit_and_close_flow(self) -> None:
- task = self.service.create_task(
- title="Audit continuity",
- description="Make sure session policy is enforced.",
- actor="test",
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Inspect the project and log findings.",
- session_goal="Verify session tracking.",
- task_id=task["id"],
- heartbeat_interval_seconds=30,
- work_log_interval_seconds=30,
- )
- self.assertTrue(session["id"].startswith("SESSION-"))
-
- issues = self.service.detect_missing_writeback()
- self.assertEqual(issues, [])
-
- self.service.session_heartbeat(
- session_id=session["id"],
- actor="test-agent",
- status_note="Still exploring the codebase.",
- task_id=task["id"],
- files=["server/store.py"],
- create_work_log=True,
- )
- self.assertEqual(self.service.detect_missing_writeback(), [])
-
- closed = self.service.session_close(
- session_id=session["id"],
- actor="test-agent",
- summary="Session completed with a clean handoff.",
- create_handoff=True,
- handoff_summary="Next agent can continue from the synced files.",
- handoff_next_steps="Read the handoff and continue implementation.",
- handoff_to_actor="next-agent",
- )
- self.assertEqual(closed["status"], "closed")
- project_paths = self.service.get_project_workspace_paths(project_path=str(self.temp_dir))
- audit_path = Path(project_paths["context_path"]) / "SESSION_AUDIT.json"
- self.assertTrue(audit_path.exists())
- self.assertEqual(json.loads(audit_path.read_text(encoding="utf-8")), [])
- session_dir = Path(project_paths["sessions_path"]) / session["id"]
- self.assertTrue((session_dir / "metadata.json").exists())
- self.assertTrue((session_dir / "heartbeat.jsonl").exists())
- self.assertTrue((session_dir / "worklog.md").exists())
- self.assertTrue((session_dir / "handoff.md").exists())
-
- def test_session_open_auto_resumes_matching_session(self) -> None:
- first = self.service.session_open(
- actor="codex",
- client_name="vscode-codex",
- model_name="gpt-5",
- project_path=str(self.temp_dir),
- initial_request="First open",
- session_goal="Start work",
- )
- second = self.service.session_open(
- actor="codex",
- client_name="vscode-codex",
- model_name="gpt-5",
- project_path=str(self.temp_dir),
- initial_request="Reopen work",
- session_goal="Resume work",
- )
- self.assertEqual(first["id"], second["id"])
- self.assertTrue(second["resumed"])
- self.assertEqual(len(self.service.get_active_sessions(project_path=str(self.temp_dir))["sessions"]), 1)
-
- def test_session_open_derives_readable_label_workstream_and_normalizes_identity(self) -> None:
- session = self.service.session_open(
- actor="claude-code",
- client_name="claude code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="This is a task for the managing director's email.",
- session_goal="Prepare the final draft for leadership review.",
- resume_strategy="new",
- )
-
- self.assertEqual(session["session_label"], "Managing Director's Email")
- self.assertEqual(session["workstream_key"], "managing-directors-email")
- self.assertEqual(session["workstream_title"], "Managing Director's Email")
- self.assertEqual(session["client_name"], "claude-code-vscode")
- self.assertEqual(session["model_name"], "claude-opus-4-6")
-
- health = self.service.health_check(project_path=str(self.temp_dir))
- self.assertEqual(health["active_sessions"], 1)
-
- def test_session_open_uses_mismatch_guard_for_same_workstream_different_task(self) -> None:
- task_a = self.service.create_task(
- title="Managing director email draft",
- description="Prepare the first draft.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- task_b = self.service.create_task(
- title="Managing director email review",
- description="Prepare the final review packet.",
- actor="test",
- project_path=str(self.temp_dir),
- )
-
- first = self.service.session_open(
- actor="claude-code",
- client_name="claude-code",
- model_name="claude-opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="Draft the managing director email.",
- session_goal="Finish the initial version.",
- task_id=task_a["id"],
- workstream_key="managing-director-email",
- session_label="Managing Director Email - Draft",
- )
- second = self.service.session_open(
- actor="claude-code",
- client_name="claude-code",
- model_name="claude-opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="Review the managing director email for final approval.",
- session_goal="Ship the reviewed version.",
- task_id=task_b["id"],
- workstream_key="managing-director-email",
- session_label="Managing Director Email - Final Review",
- )
-
- self.assertNotEqual(first["id"], second["id"])
- self.assertFalse(second.get("resumed", False))
- self.assertTrue(any("Auto-resume skipped" in warning for warning in second["warnings"]))
-
- def test_startup_preflight_reports_done_task_taskless_session_and_handoff_mismatch(self) -> None:
- task = self.service.create_task(
- title="Old implementation task",
- description="Already completed work.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(self.temp_dir))
- self.service.update_task(task_id=task["id"], status="done", actor="test", project_path=str(self.temp_dir))
- other_task = self.service.create_task(
- title="Different active task",
- description="Used to trigger handoff mismatch.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.create_handoff(
- summary="Completed prior work.",
- next_steps="No more changes required.",
- task_id=other_task["id"],
- from_actor="test",
- to_actor="next-agent",
- project_path=str(self.temp_dir),
- )
-
- preflight = self.service.get_startup_preflight(
- actor="claude-code",
- task_id=task["id"],
- initial_request="Create a complete beginner documentation for the Beanav ERP system with real-world examples.",
- session_goal="Write the full documentation from scratch for class 8 students.",
- project_path=str(self.temp_dir),
- )
- taskless_preflight = self.service.get_startup_preflight(
- actor="claude-code",
- initial_request="Create a complete beginner documentation for the Beanav ERP system with real-world examples.",
- session_goal="Write the full documentation from scratch for class 8 students.",
- project_path=str(self.temp_dir),
- )
-
- warning_codes = {item["code"] for item in preflight["warnings"]}
- self.assertIn("current_task_done", warning_codes)
- self.assertIn("latest_handoff_task_mismatch", warning_codes)
- self.assertFalse(preflight["ok"])
- self.assertIn("Create or select the correct task", preflight["recommended_action"])
- self.assertIn("session_without_task", {item["code"] for item in taskless_preflight["warnings"]})
- self.assertIn("Create or select", taskless_preflight["recommended_action"])
-
- def test_resume_board_surfaces_paused_and_stale_workstreams(self) -> None:
- current_task = self.service.create_task(
- title="ERP documentation",
- description="Active writing task.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- paused_task = self.service.create_task(
- title="Finance approval memo",
- description="Paused with no live session.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- stale_task = self.service.create_task(
- title="Managing director email",
- description="Open session has gone stale.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.set_current_task(task_id=current_task["id"], actor="test", project_path=str(self.temp_dir))
-
- current_session = self.service.session_open(
- actor="claude-code",
- client_name="claude-code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="Continue ERP documentation.",
- session_goal="Write the architecture chapter.",
- task_id=current_task["id"],
- resume_strategy="new",
- )
- stale_session = self.service.session_open(
- actor="claude-code",
- client_name="claude-code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="Resume the managing director email.",
- session_goal="Finish the paused email draft.",
- task_id=stale_task["id"],
- session_label="Managing Director Email",
- workstream_key="managing-director-email",
- resume_strategy="new",
- )
- stale_time = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat().replace("+00:00", "Z")
- store = self.service._store(str(self.temp_dir))
- with store._connect() as connection:
- connection.execute("UPDATE sessions SET heartbeat_at = ?, opened_at = ? WHERE id = ?", (stale_time, stale_time, stale_session["id"]))
- connection.commit()
-
- self.service.create_handoff(
- summary="Architecture outline completed.",
- next_steps="Write module-by-module details.",
- task_id=current_task["id"],
- from_actor="test",
- to_actor="next-agent",
- project_path=str(self.temp_dir),
- )
-
- board = self.service.get_resume_board(project_path=str(self.temp_dir))
-
- self.assertEqual(board["current_task"]["id"], current_task["id"])
- self.assertIn(paused_task["id"], {item["id"] for item in board["paused_tasks"]})
- self.assertIn(stale_session["id"], {item["id"] for item in board["stale_sessions"]})
- self.assertEqual(board["recommended_resume_target"]["task"]["id"], current_task["id"])
- self.assertEqual(board["recommended_resume_target"]["session"]["id"], current_session["id"])
- self.assertTrue(board["latest_handoffs"])
-
- def test_reset_project_returns_post_reset_snapshot(self) -> None:
- task = self.service.create_task(
- title="Reset verification task",
- description="Create state and wipe it clean.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(self.temp_dir))
- self.service.log_work(
- message="Created work that should disappear after reset.",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- session = self.service.session_open(
- actor="claude-code",
- client_name="claude-code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- initial_request="Open session before reset.",
- session_goal="Verify cleanup.",
- task_id=task["id"],
- resume_strategy="new",
- )
- self.assertTrue(session["id"].startswith("SESSION-"))
-
- result = self.service.reset_project(scope="full", actor="test", project_path=str(self.temp_dir))
- snapshot = result["post_reset_snapshot"]
-
- self.assertEqual(snapshot["current_task"], None)
- self.assertEqual(snapshot["active_tasks"], [])
- self.assertEqual(snapshot["latest_handoff"], None)
- self.assertEqual(snapshot["recent_work"], [])
- self.assertEqual(snapshot["active_sessions"], [])
-
- def test_compatibility_and_incremental_recent_reads(self) -> None:
- task = self.service.create_task(
- title="Compatibility verification",
- description="Check versioning and incremental reads.",
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.log_work(
- message="First entry.",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.log_work(
- message="Second entry.",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.log_decision(
- title="First decision",
- decision="Keep compatibility explicit.",
- rationale="It prevents silent client/server drift.",
- impact="Safer startup checks.",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
- self.service.log_decision(
- title="Second decision",
- decision="Expose version info through a dedicated endpoint.",
- rationale="The client can validate expectations before startup.",
- impact="Fewer runtime mismatches.",
- task_id=task["id"],
- actor="test",
- project_path=str(self.temp_dir),
- )
-
- recent_work = self.service.get_recent_work(limit=2, project_path=str(self.temp_dir))
- self.assertEqual(len(recent_work), 2)
- older_work = self.service.get_recent_work(after_id=recent_work[-1]["id"], project_path=str(self.temp_dir))
- self.assertTrue(all(item["id"] < recent_work[-1]["id"] for item in older_work))
-
- decisions = self.service.get_decisions(limit=1, project_path=str(self.temp_dir))
- self.assertEqual(len(decisions), 1)
- older_decisions = self.service.get_decisions(after_id=decisions[-1]["id"], project_path=str(self.temp_dir))
- self.assertTrue(all(item["id"] < decisions[-1]["id"] for item in older_decisions))
-
- capabilities = self.service.get_server_capabilities(project_path=str(self.temp_dir))
- compatibility = self.service.check_client_compatibility(
- client_api_version=self.service.API_VERSION,
- client_tool_schema_version=self.service.TOOL_SCHEMA_VERSION,
- client_name="claude-code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- )
- mismatch = self.service.check_client_compatibility(
- client_api_version="2025.01.01",
- client_tool_schema_version=999,
- client_name="claude-code",
- model_name="opus-4.6",
- project_path=str(self.temp_dir),
- )
-
- self.assertTrue(capabilities["features"]["resume_board"])
- self.assertTrue(compatibility["compatible"])
- self.assertEqual(compatibility["client"]["client_name"], "claude-code-vscode")
- self.assertEqual(compatibility["client"]["model_name"], "claude-opus-4-6")
- self.assertFalse(mismatch["compatible"])
- self.assertEqual(len(mismatch["warnings"]), 2)
-
- def test_call_tool_infers_project_from_file_paths(self) -> None:
- repo = self.temp_dir / "infer-repo"
- repo.mkdir()
- source_file = repo / "src" / "module.py"
- source_file.parent.mkdir()
- source_file.write_text("print('hello')\n", encoding="utf-8")
- self.service.register_project(repo_path=str(repo), name="Infer Repo")
-
- self.service.call_tool(
- "log_work",
- {
- "actor": "test-agent",
- "message": "Project inferred from absolute file path.",
- "files": [str(source_file)],
- },
- )
-
- snapshot = self.service.get_project_status_snapshot(project_path=str(repo))
- self.assertEqual(snapshot["recent_work"][0]["message"], "Project inferred from absolute file path.")
-
- def test_call_tool_infers_project_from_cwd_for_session_open(self) -> None:
- repo = self.temp_dir / "cwd-repo"
- repo.mkdir()
- (repo / ".git").mkdir()
- nested = repo / "src"
- nested.mkdir()
-
- with patch.object(self.service, "_registered_project_for_path_hint", side_effect=lambda hint: str(repo) if hint else None):
- with patch("server.service.os.getcwd", return_value=str(nested)):
- with patch.dict("server.service.os.environ", {}, clear=True):
- session = self.service.call_tool(
- "session_open",
- {
- "actor": "test-agent",
- "client_name": "unit-test",
- "model_name": "test-model",
- "initial_request": "Infer project from cwd",
- "session_goal": "Auto-route from current repo",
- },
- )
-
- self.assertEqual(session["project_path"], str(repo))
-
- def test_call_tool_rejects_unscoped_continuity_request(self) -> None:
- unknown = self.temp_dir / "unknown-cwd"
- unknown.mkdir()
-
- with patch("server.service.os.getcwd", return_value=str(unknown)):
- with patch.dict("server.service.os.environ", {}, clear=True):
- with self.assertRaises(ValueError) as exc:
- self.service.call_tool(
- "log_work",
- {
- "actor": "test-agent",
- "message": "This should not route to the default project.",
- },
- )
-
- self.assertIn("Project context is required", str(exc.exception))
-
- def test_resolve_active_project_from_ide_metadata_workspace_and_active_file(self) -> None:
- repo = self.temp_dir / "ide-repo"
- repo.mkdir()
- source_file = repo / "src" / "main.py"
- source_file.parent.mkdir()
- source_file.write_text("print('hi')\n", encoding="utf-8")
-
- with patch.object(self.service, "_registered_project_for_path_hint", side_effect=lambda hint: str(repo) if hint else None):
- result = self.service.call_tool(
- "resolve_active_project",
- {
- "ide_name": "claude",
- "workspace_folders": [str(repo)],
- "active_file": str(source_file),
- },
- )
-
- self.assertTrue(result["resolved"])
- self.assertFalse(result["already_registered"])
- self.assertEqual(result["project_path"], str(repo))
- self.assertEqual(result["resolution_source"], "workspace_folders[0]")
- self.assertEqual(result["ide_name"], "claude")
-
- def test_resolve_active_project_from_ide_metadata_requires_registration_when_disabled(self) -> None:
- repo = self.temp_dir / "unregistered-ide-repo"
- repo.mkdir()
- active_file = repo / "app.py"
- active_file.write_text("print('x')\n", encoding="utf-8")
-
- with patch.object(self.service, "_registered_project_for_path_hint", side_effect=lambda hint: str(repo) if hint else None):
- result = self.service.call_tool(
- "resolve_active_project",
- {
- "active_file": str(active_file),
- "auto_register": False,
- },
- )
-
- self.assertFalse(result["resolved"])
- self.assertTrue(result["requires_registration"])
- self.assertEqual(result["project_path"], str(repo))
-
- def test_resolve_active_project_from_ide_metadata_returns_unresolved_without_hints(self) -> None:
- result = self.service.call_tool(
- "resolve_active_project",
- {
- "ide_name": "claude",
- "auto_register": False,
- },
- )
-
- self.assertFalse(result["resolved"])
- self.assertEqual(result["project_path"], None)
- self.assertIn("Pass project_path", result["recommended_action"])
-
- def test_detect_missing_writeback_flags_stale_and_abandoned_sessions(self) -> None:
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(self.temp_dir),
- initial_request="Open stale session",
- session_goal="Leave it idle",
- heartbeat_interval_seconds=30,
- work_log_interval_seconds=30,
- )
- stale_time = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat().replace("+00:00", "Z")
- store = self.service._store(str(self.temp_dir))
- with store._connect() as connection:
- connection.execute(
- "UPDATE sessions SET heartbeat_at = ?, opened_at = ? WHERE id = ?",
- (stale_time, stale_time, session["id"]),
- )
- connection.commit()
-
- issues = self.service.detect_missing_writeback(project_path=str(self.temp_dir))
- issue_types = {item["issue"] for item in issues if item["session_id"] == session["id"]}
- self.assertIn("stale_open_session", issue_types)
- self.assertIn("abandoned_session", issue_types)
-
- def test_session_close_enriches_handoff(self) -> None:
- repo = self.temp_dir / "handoff-repo"
- repo.mkdir()
- source_file = repo / "app.py"
- source_file.write_text("def run() -> str:\n return 'ok'\n", encoding="utf-8")
- self.service.scan_codebase(project_path=str(repo), force_refresh=True)
- task = self.service.create_task(
- title="Close with enriched handoff",
- description="Verify handoff quality defaults.",
- relevant_files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- session = self.service.session_open(
- actor="test-agent",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(repo),
- initial_request="Implement and stop cleanly.",
- session_goal="Leave a useful handoff.",
- task_id=task["id"],
- )
- self.service.log_work(
- message="Implemented the run helper.",
- task_id=task["id"],
- actor="test-agent",
- session_id=session["id"],
- files=[str(source_file)],
- project_path=str(repo),
- )
-
- closed = self.service.session_close(
- session_id=session["id"],
- actor="test-agent",
- summary="Implemented the run helper.",
- project_path=str(repo),
- )
- self.assertEqual(closed["status"], "closed")
- handoff = self.service.get_latest_handoff(project_path=str(repo))
- self.assertIsNotNone(handoff)
- self.assertIn("Relevant files:", handoff["note"])
- self.assertIn("Recommended semantic lookups:", handoff["note"])
- self.assertTrue(handoff["next_steps"])
-
- def test_multiple_projects_keep_state_separate(self) -> None:
- project_a = self.temp_dir / "project-a"
- project_b = self.temp_dir / "project-b"
- project_a.mkdir()
- project_b.mkdir()
-
- task_a = self.service.create_task(
- title="Project A task",
- description="Track project A only.",
- relevant_files=[str(project_a / "src" / "a.py")],
- actor="test",
- project_path=str(project_a),
- )
- task_b = self.service.create_task(
- title="Project B task",
- description="Track project B only.",
- relevant_files=[str(project_b / "src" / "b.py")],
- actor="test",
- project_path=str(project_b),
- )
-
- self.service.set_current_task(task_id=task_a["id"], actor="test", project_path=str(project_a))
- self.service.set_current_task(task_id=task_b["id"], actor="test", project_path=str(project_b))
-
- session_a = self.service.call_tool(
- "session_open",
- {
- "actor": "test-agent",
- "client_name": "unit-test",
- "model_name": "test-model",
- "project_path": str(project_a),
- "task_id": task_a["id"],
- },
- )
- self.service.call_tool(
- "log_work",
- {
- "actor": "test-agent",
- "session_id": session_a["id"],
- "task_id": task_a["id"],
- "message": "Investigated project A bug.",
- "files": [str(project_a / "src" / "a.py")],
- },
- )
- self.service.log_work(
- message="Implemented project B feature.",
- task_id=task_b["id"],
- files=[str(project_b / "src" / "b.py")],
- actor="test",
- project_path=str(project_b),
- )
-
- snapshot_a = self.service.get_project_status_snapshot(project_path=str(project_a))
- snapshot_b = self.service.get_project_status_snapshot(project_path=str(project_b))
-
- self.assertEqual(snapshot_a["current_task"]["id"], task_a["id"])
- self.assertEqual(snapshot_b["current_task"]["id"], task_b["id"])
- self.assertEqual([task["id"] for task in snapshot_a["active_tasks"]], [task_a["id"]])
- self.assertEqual([task["id"] for task in snapshot_b["active_tasks"]], [task_b["id"]])
- self.assertEqual(snapshot_a["relevant_files"], [str(project_a / "src" / "a.py")])
- self.assertEqual(snapshot_b["relevant_files"], [str(project_b / "src" / "b.py")])
-
- paths_a = self.service.get_project_workspace_paths(project_path=str(project_a))
- paths_b = self.service.get_project_workspace_paths(project_path=str(project_b))
- self.assertTrue((Path(paths_a["context_path"]) / "CURRENT_TASK.json").exists())
- self.assertTrue((Path(paths_b["context_path"]) / "CURRENT_TASK.json").exists())
- self.assertTrue((Path(paths_a["vault_path"]) / "Projects" / "Project Brief.md").exists())
- self.assertTrue((Path(paths_b["vault_path"]) / "Projects" / "Project Brief.md").exists())
-
- def test_registry_resume_recovery_and_hub_sync(self) -> None:
- repo = self.temp_dir / "sample-repo"
- repo.mkdir()
- (repo / ".context").mkdir()
- (repo / "obsidian" / "vault" / "Projects").mkdir(parents=True)
- (repo / ".context" / "PROJECT_CONTEXT.md").write_text("# Legacy Context\n", encoding="utf-8")
- (repo / "obsidian" / "vault" / "Projects" / "Legacy.md").write_text("# Legacy Note\n", encoding="utf-8")
-
- registration = self.service.register_project(repo_path=str(repo), name="Sample Repo", tags=["python", "api"])
- self.assertEqual(registration["name"], "Sample Repo")
- bridge_file = repo / ".obsmcp-link.json"
- self.assertTrue(bridge_file.exists())
- migrated = self.service.migrate_project_layout(project_path=str(repo))
- self.assertGreaterEqual(migrated["copied_count"], 1)
-
- task = self.service.create_task(
- title="Recoverable task",
- description="Exercise resume and recovery flow.",
- actor="test",
- project_path=str(repo),
- )
- session = self.service.session_open(
- actor="codex",
- client_name="unit-test",
- model_name="test-model",
- project_path=str(repo),
- initial_request="Start implementation",
- session_goal="Leave enough state for recovery",
- task_id=task["id"],
- )
- self.service.log_work(
- message="Implemented the first half of the feature.",
- task_id=task["id"],
- actor="codex",
- session_id=session["id"],
- project_path=str(repo),
- )
-
- resume = self.service.generate_resume_packet(session_id=session["id"], project_path=str(repo))
- self.assertIn("Resume Packet", resume["markdown"])
- self.assertTrue(Path(resume["path"]).exists())
-
- recovered = self.service.recover_session(session_id=session["id"], actor="claude", project_path=str(repo))
- self.assertTrue(recovered["recovered"])
- self.assertEqual(recovered["handoff"]["to_actor"], "next-agent")
-
- hub = self.service.sync_hub()
- self.assertTrue(any(path.endswith("Projects Overview.md") for path in hub["files"]))
- self.assertGreaterEqual(len(self.service.list_projects()), 1)
-
- def test_semantic_knowledge_flow(self) -> None:
- repo = self.temp_dir / "semantic-repo"
- repo.mkdir()
- (repo / "app.py").write_text(
- """
-\"\"\"Application entry helpers.\"\"\"
-
-class ExampleService:
- \"\"\"Coordinates semantic behavior for tests.\"\"\"
-
- def run(self, task_name: str) -> str:
- \"\"\"Run the named task.\"\"\"
- return task_name.upper()
-
-
-def helper(task_name: str) -> str:
- \"\"\"Format a task label.\"\"\"
- return f"task:{task_name}"
-""".strip()
- + "\n",
- encoding="utf-8",
- )
- (repo / "other.py").write_text(
- """
-def helper(task_name: str) -> str:
- return task_name.lower()
-""".strip()
- + "\n",
- encoding="utf-8",
- )
-
- atlas = self.service.scan_codebase(project_path=str(repo), force_refresh=True)
- self.assertEqual(atlas["status"], "generated")
- self.assertGreater(atlas["semantic_index"]["entity_count"], 0)
-
- task = self.service.create_task(
- title="Semantic task",
- description="Exercise semantic descriptions.",
- relevant_files=["app.py", "other.py"],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- module = self.service.describe_module("app.py", project_path=str(repo))
- self.assertEqual(module["entity_type"], "module")
- self.assertIn("app.py", module["file"])
-
- symbol = self.service.describe_symbol("ExampleService", module_path="app.py", entity_type="class", project_path=str(repo))
- self.assertEqual(symbol["entity_type"], "class")
- self.assertIn("why_it_exists", symbol)
-
- method = self.service.describe_symbol("run", module_path="app.py", entity_type="function", project_path=str(repo))
- self.assertEqual(method["entity_type"], "function")
- self.assertIn("ExampleService", method["signature"])
- self.assertIn("why_it_exists", method)
-
- ambiguous = self.service.describe_symbol("helper", entity_type="function", project_path=str(repo))
- self.assertEqual(ambiguous["status"], "ambiguous")
- self.assertEqual(len(ambiguous["candidates"]), 2)
-
- feature = self.service.describe_feature("Python", project_path=str(repo))
- self.assertEqual(feature["entity_type"], "feature")
-
- search = self.service.search_code_knowledge("helper", project_path=str(repo))
- self.assertGreaterEqual(search["match_count"], 1)
-
- related = self.service.get_related_symbols(entity_key=symbol["entity_key"], project_path=str(repo))
- self.assertGreaterEqual(len(related["related_symbols"]), 1)
-
- cached_before = self.service.describe_module("app.py", project_path=str(repo))
- self.assertTrue(cached_before["cached"])
-
- (repo / "app.py").write_text(
- """
-\"\"\"Application entry helpers updated.\"\"\"
-
-class ExampleService:
- def run(self, task_name: str) -> str:
- return f"RUN:{task_name.upper()}"
-
-
-def helper(task_name: str) -> str:
- return f"task:{task_name}:updated"
-""".strip()
- + "\n",
- encoding="utf-8",
- )
-
- refreshed = self.service.refresh_semantic_description(module_path="app.py", project_path=str(repo))
- self.assertEqual(refreshed["entity_type"], "module")
- self.assertFalse(refreshed["cached"])
-
- resume = self.service.generate_resume_packet(task_id=task["id"], project_path=str(repo))
- self.assertIn("Recommended Semantic Lookups", resume["markdown"])
-
- workspace = self.service.get_project_workspace_paths(project_path=str(repo))
- vault_dir = Path(workspace["vault_path"])
- self.assertTrue((vault_dir / "Research" / "Architecture Map.md").exists())
- self.assertTrue((vault_dir / "Research" / "Module Summaries.md").exists())
- self.assertTrue((vault_dir / "Research" / "Feature Map.md").exists())
- symbol_notes = list((vault_dir / "Research" / "Symbol Knowledge").glob("*.md"))
- self.assertTrue(symbol_notes)
-
- tool_result = self.service.call_tool("describe_module", {"module_path": "app.py", "project_path": str(repo)})
- self.assertEqual(tool_result["entity_key"], module["entity_key"])
-
- def test_describe_module_passes_gateway_contract_to_llm_generation(self) -> None:
- repo = self.temp_dir / "semantic-gateway-repo"
- repo.mkdir()
- (repo / "app.py").write_text(
- """
-\"\"\"Architecture helpers.\"\"\"
-
-def build_packet(name: str) -> str:
- return name.upper()
-""".strip()
- + "\n",
- encoding="utf-8",
- )
-
- self.config.output_compression.enabled = True
- self.config.output_compression.mode = "gateway_enforced"
- self.service = ObsmcpService(self.config)
- self.service.scan_codebase(project_path=str(repo), force_refresh=True)
- task = self.service.create_task(
- title="Architecture review for semantic output",
- description="Exercise gateway-enforced semantic generation.",
- tags=["architecture"],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- captured: dict[str, Any] = {}
-
- def fake_generate_llm_description(
- entity: dict[str, Any],
- snippet: str,
- context: str | None = None,
- response_contract: str | None = None,
- ) -> dict[str, Any] | None:
- captured["response_contract"] = response_contract
- return {
- "purpose": "Summarizes the module.",
- "why_it_exists": "Exists to support architecture lookups.",
- "how_it_is_used": "Used through semantic description tools.",
- "inputs_outputs": "Takes source and returns description fields.",
- "side_effects": "No important side effects.",
- "risks": "Low risk.",
- "language": "Python",
- "llm_model": "fake-model",
- "llm_latency_ms": 1.2,
- "llm_input_tokens": 12,
- "llm_output_tokens": 24,
- "llm_generated": True,
- }
-
- with patch("server.semantic.generate_llm_description", side_effect=fake_generate_llm_description):
- result = self.service.refresh_semantic_description(module_path="app.py", project_path=str(repo), force_llm=True)
-
- self.assertEqual(result["entity_type"], "module")
- self.assertIn("## Enforced Response Contract", captured["response_contract"] or "")
- stats = self.service.get_token_usage_stats(project_path=str(repo), operation="generate_semantic_description")
- self.assertGreaterEqual(stats["event_count"], 1)
-
- def test_checkpoint_logging_syncs_into_obsidian(self) -> None:
- repo = self.temp_dir / "checkpoint-repo"
- repo.mkdir()
- source_file = repo / "app.py"
- source_file.write_text("def run() -> str:\n return 'ok'\n", encoding="utf-8")
-
- task = self.service.create_task(
- title="Checkpoint task",
- description="Track subtask completion.",
- relevant_files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- checkpoint = self.service.log_checkpoint(
- task_id=task["id"],
- checkpoint_id="P2-03",
- title="finance_handler institution scoping",
- message="Completed institution scoping for finance_handler.",
- files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- self.assertEqual(checkpoint["checkpoint_id"], "P2-03")
-
- progress = self.service.get_task_progress(task["id"], project_path=str(repo))
- self.assertEqual(progress["completed_count"], 1)
- self.assertEqual(progress["recent_checkpoints"][0]["checkpoint_id"], "P2-03")
-
- snapshot = self.service.get_project_status_snapshot(project_path=str(repo))
- self.assertEqual(snapshot["current_task_progress"]["completed_count"], 1)
- self.assertEqual(snapshot["recent_checkpoints"][0]["checkpoint_id"], "P2-03")
-
- workspace = self.service.get_project_workspace_paths(project_path=str(repo))
- vault_dir = Path(workspace["vault_path"])
- current_task_note = (vault_dir / "Projects" / "Current Task.md").read_text(encoding="utf-8")
- status_snapshot_note = (vault_dir / "Projects" / "Status Snapshot.md").read_text(encoding="utf-8")
- self.assertIn("P2-03", current_task_note)
- self.assertIn("finance_handler institution scoping", current_task_note)
- self.assertIn("Recent Checkpoints", status_snapshot_note)
-
- def test_scan_codebase_prewarms_module_summaries(self) -> None:
- repo = self.temp_dir / "prewarm-repo"
- repo.mkdir()
- (repo / "app.py").write_text(
- "def run_task(name: str) -> str:\n return f'run:{name}'\n",
- encoding="utf-8",
- )
-
- result = self.service.scan_codebase(project_path=str(repo), force_refresh=True)
- self.assertEqual(result["status"], "generated")
- self.assertTrue(result["semantic_prewarm"]["queued"])
-
- store = self.service._store(str(repo))
- deadline = time.time() + 5
- modules: list[dict[str, Any]] = []
- while time.time() < deadline:
- modules = store.get_cached_semantic_descriptions(entity_type="module", fresh_only=True, limit=10)
- if modules:
- break
- time.sleep(0.2)
-
- self.assertTrue(modules)
- self.assertTrue(any(item["file_path"] == "app.py" for item in modules))
-
- workspace = self.service.get_project_workspace_paths(project_path=str(repo))
- module_summaries_path = Path(workspace["vault_path"]) / "Research" / "Module Summaries.md"
- module_summaries = ""
- while time.time() < deadline:
- if module_summaries_path.exists():
- module_summaries = module_summaries_path.read_text(encoding="utf-8")
- if "app.py" in module_summaries:
- break
- time.sleep(0.2)
- self.assertIn("app.py", module_summaries)
- self.assertNotIn("No module summaries cached yet.", module_summaries)
-
- def test_set_current_task_prewarms_semantics_from_relevant_files(self) -> None:
- repo = self.temp_dir / "set-current-prewarm-repo"
- repo.mkdir()
- source_file = repo / "feature.py"
- source_file.write_text("def run_feature() -> str:\n return 'ready'\n", encoding="utf-8")
-
- self.config.semantic_auto_generate.on_create_task = False
- self.config.semantic_auto_generate.on_set_current_task = True
-
- task = self.service.create_task(
- title="Warm on set current task",
- description="Prime semantics from relevant files.",
- relevant_files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- store = self.service._store(str(repo))
- deadline = time.time() + 5
- modules: list[dict[str, Any]] = []
- while time.time() < deadline:
- modules = store.get_cached_semantic_descriptions(entity_type="module", fresh_only=True, limit=10)
- if any(item["file_path"] == "feature.py" for item in modules):
- break
- time.sleep(0.2)
-
- self.assertTrue(any(item["file_path"] == "feature.py" for item in modules))
-
- def test_startup_and_handoff_warm_semantics_for_current_task(self) -> None:
- repo = self.temp_dir / "startup-handoff-repo"
- repo.mkdir()
- source_file = repo / "app.py"
- source_file.write_text("def boot() -> str:\n return 'ok'\n", encoding="utf-8")
-
- self.config.semantic_auto_generate.on_create_task = False
- self.config.semantic_auto_generate.on_set_current_task = False
- self.config.semantic_auto_generate.on_handoff = True
- self.config.semantic_auto_generate.on_startup = True
- self.config.semantic_auto_generate.wait_ms_on_handoff = 500
- self.config.semantic_auto_generate.wait_ms_on_startup = 500
-
- task = self.service.create_task(
- title="Warm on startup and handoff",
- description="Ensure startup and handoff prewarm relevant modules.",
- relevant_files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- startup = self.service.generate_startup_context(task_id=task["id"], project_path=str(repo))
- self.assertIn("Startup Context", startup["markdown"])
-
- store = self.service._store(str(repo))
- deadline = time.time() + 5
- modules: list[dict[str, Any]] = []
- while time.time() < deadline:
- modules = store.get_cached_semantic_descriptions(entity_type="module", fresh_only=True, limit=10)
- if any(item["file_path"] == "app.py" for item in modules):
- break
- time.sleep(0.2)
- self.assertTrue(any(item["file_path"] == "app.py" for item in modules))
-
- store.invalidate_semantic_cache(file_paths=["app.py"])
- handoff = self.service.create_handoff(
- summary="Warm semantics before handoff.",
- next_steps="Continue with the warmed module context.",
- task_id=task["id"],
- from_actor="test",
- to_actor="next-model",
- project_path=str(repo),
- )
- self.assertTrue(handoff["summary"].startswith("Warm semantics"))
-
- module_row = store.get_module_index("app.py")
- self.assertIsNotNone(module_row)
- deadline = time.time() + 5
- while time.time() < deadline:
- refreshed = store.get_semantic_description(module_row["entity_key"])
- if refreshed and not refreshed.get("stale"):
- break
- time.sleep(0.2)
- refreshed = store.get_semantic_description(module_row["entity_key"])
- self.assertIsNotNone(refreshed)
- self.assertFalse(refreshed.get("stale"))
-
- def test_checkpoint_auto_rollup_tracks_expected_phase_progress(self) -> None:
- repo = self.temp_dir / "checkpoint-rollup-repo"
- repo.mkdir()
- source_file = repo / "module.py"
- source_file.write_text("def run() -> str:\n return 'done'\n", encoding="utf-8")
-
- self.config.checkpoints.auto_rollup = True
- self.config.checkpoints.auto_close_task = True
-
- task = self.service.create_task(
- title="Phase rollup task",
- description="Implement:\n- P9-01 add checkpoint logging\n- P9-02 add rollup rendering",
- relevant_files=[str(source_file)],
- actor="test",
- project_path=str(repo),
- )
- self.service.set_current_task(task_id=task["id"], actor="test", project_path=str(repo))
-
- self.service.log_checkpoint(
- task_id=task["id"],
- checkpoint_id="P9-01",
- title="add checkpoint logging",
- actor="test",
- project_path=str(repo),
- )
- first_progress = self.service.get_task_progress(task["id"], project_path=str(repo))
- self.assertEqual(first_progress["completed_count"], 1)
- self.assertEqual(first_progress["total_count"], 2)
- self.assertEqual(first_progress["remaining_checkpoints"], ["P9-02"])
- self.assertEqual(first_progress["phase_rollups"][0]["phase_key"], "P9")
-
- second = self.service.log_checkpoint(
- task_id=task["id"],
- checkpoint_id="P9-02",
- title="add rollup rendering",
- actor="test",
- project_path=str(repo),
- )
- self.assertIn("auto_closed_task", second)
- self.assertEqual(second["auto_closed_task"]["status"], "done")
-
- final_progress = self.service.get_task_progress(task["id"], project_path=str(repo))
- self.assertTrue(final_progress["all_expected_complete"])
- self.assertEqual(final_progress["phase_rollups"][0]["completed_count"], 2)
- self.assertEqual(final_progress["phase_rollups"][0]["total_count"], 2)
-
- workspace = self.service.get_project_workspace_paths(project_path=str(repo))
- current_task_note = (Path(workspace["vault_path"]) / "Projects" / "Current Task.md").read_text(encoding="utf-8")
- self.assertIn("P9: 2/2 complete", current_task_note)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tool/obsmcp/__init__.py b/tool/obsmcp/__init__.py
new file mode 100644
index 0000000..d18315a
--- /dev/null
+++ b/tool/obsmcp/__init__.py
@@ -0,0 +1,3 @@
+"""OBSMCP local tool — Observable Machine Code Protocol agent."""
+
+__version__ = "0.1.0"
diff --git a/tool/obsmcp/__main__.py b/tool/obsmcp/__main__.py
new file mode 100644
index 0000000..99983d9
--- /dev/null
+++ b/tool/obsmcp/__main__.py
@@ -0,0 +1,115 @@
+"""Main entry point for the OBSMCP local tool.
+
+Usage::
+
+ python -m obsmcp # full agent + local UI (standalone)
+ python -m obsmcp --mcp-stdio # MCP stdio server only
+ python -m obsmcp --agent-only # background monitors, no UI
+ python -m obsmcp --ui-only # local UI only (standalone mode)
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+
+from .client.http_client import BackendClient
+from .config import Config, load_config
+from .graph.edge_builder import EdgeBuilder
+from .graph.node_extractor import NodeExtractor
+from .local_ui import serve_local_ui
+from .monitors.file_watcher import FileWatcher
+from .monitors.git_monitor import GitMonitor
+from .monitors.perf_monitor import PerformanceMonitor
+from .monitors.session_monitor import SessionMonitor
+from .monitors.task_monitor import TaskMonitor
+from .scanners.code_atlas import CodeAtlasScanner
+from .scanners.semantic_index import SemanticIndexer
+from .utils.logger import get_logger
+
+logger = get_logger("obsmcp")
+
+
+async def run_agent(config: Config) -> None:
+ client = BackendClient(config)
+ await client.register()
+ tasks = []
+ m = config.enabled_modules
+ if m.session_monitor:
+ tasks.append(asyncio.create_task(SessionMonitor(client, config).run()))
+ if m.task_monitor:
+ tasks.append(asyncio.create_task(TaskMonitor(client, config).run()))
+ if m.file_watcher:
+ tasks.append(asyncio.create_task(FileWatcher(client, config).run()))
+ if m.git_monitor:
+ tasks.append(asyncio.create_task(GitMonitor(client, config).run()))
+ if m.perf_monitor:
+ tasks.append(asyncio.create_task(PerformanceMonitor(client, config).run()))
+ if m.code_atlas:
+ tasks.append(asyncio.create_task(CodeAtlasScanner(client, config).run()))
+ if m.semantic_index:
+ tasks.append(asyncio.create_task(SemanticIndexer(client, config).run()))
+ if m.knowledge_graph:
+ tasks.append(asyncio.create_task(NodeExtractor(client, config).run()))
+ tasks.append(asyncio.create_task(EdgeBuilder(client, config).run()))
+
+ async def heartbeat_loop() -> None:
+ while True:
+ await asyncio.sleep(30)
+ await client.heartbeat()
+
+ tasks.append(asyncio.create_task(heartbeat_loop()))
+ logger.info("OBSMCP agent running (mode=%s, %d modules)", config.mode, len(tasks))
+ try:
+ await asyncio.gather(*tasks)
+ finally:
+ await client.close()
+
+
+async def main_async(args: argparse.Namespace) -> None:
+ config = load_config()
+ if not config.project_path:
+ print(
+ "OBSMCP is not configured. Run `obsmcp-setup` or launch `start.bat` first.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ coros = []
+ if args.mcp_stdio:
+ from .mcp_server import run_stdio
+
+ await run_stdio()
+ return
+
+ if not args.ui_only:
+ coros.append(run_agent(config))
+ if not args.agent_only and config.mode == "standalone":
+ coros.append(serve_local_ui(config))
+
+ if not coros:
+ print("Nothing to do — pick one of --agent-only / --ui-only / --mcp-stdio.")
+ return
+
+ await asyncio.gather(*coros)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(prog="obsmcp")
+ parser.add_argument(
+ "--mcp-stdio",
+ action="store_true",
+ help="Run the MCP stdio tool server (for Claude Desktop / Cursor).",
+ )
+ parser.add_argument("--agent-only", action="store_true", help="Run monitors only, no UI.")
+ parser.add_argument("--ui-only", action="store_true", help="Run local dashboard only.")
+ args = parser.parse_args()
+ try:
+ asyncio.run(main_async(args))
+ except KeyboardInterrupt:
+ logger.info("shutting down")
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/tool/obsmcp/client/__init__.py b/tool/obsmcp/client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/client/http_client.py b/tool/obsmcp/client/http_client.py
new file mode 100644
index 0000000..22dea22
--- /dev/null
+++ b/tool/obsmcp/client/http_client.py
@@ -0,0 +1,358 @@
+"""Dual-mode HTTP client.
+
+- Always writes to the local SQLite DB first (offline-first).
+- If ``backend_url`` is configured, also pushes the same write to the remote
+ server in the background. Cloud failures never fail the local write.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+import platform
+import sqlite3
+import uuid
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.client")
+
+_SCHEMA = (Path(__file__).resolve().parents[3] / "server" / "obsmcp_server" / "schema.sql")
+
+
+def _now() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def _json_dump(value: Any) -> Any:
+ if isinstance(value, (list, dict)):
+ return json.dumps(value)
+ return value
+
+
+class BackendClient:
+ """Local-first client with optional cloud sync."""
+
+ _JSON_COLUMNS: dict[str, tuple[str, ...]] = {
+ "tasks": ("tags",),
+ "decisions": ("tags",),
+ "work_logs": ("tags",),
+ "code_atlas_files": ("imports", "exports"),
+ "knowledge_nodes": ("metadata",),
+ "knowledge_edges": ("metadata",),
+ "performance_logs": ("tags",),
+ }
+
+ def __init__(self, config: Config) -> None:
+ self.config = config
+ self.agent_id = config.agent_id
+ self.mode = config.mode
+ self.base_url = config.backend_url.rstrip("/")
+ self.token = config.api_token
+ self.db_path = config.local_db_path
+
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
+ self._conn.row_factory = sqlite3.Row
+ self._conn.execute("PRAGMA journal_mode=WAL")
+ self._conn.execute("PRAGMA synchronous=NORMAL")
+ self._ensure_tables()
+ self._lock = asyncio.Lock()
+
+ self._http: httpx.AsyncClient | None = None
+ if self.mode == "cloud":
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
+ self._http = httpx.AsyncClient(
+ base_url=self.base_url,
+ headers=headers,
+ timeout=10.0,
+ )
+
+ # ---------------------------------------------------------------- schema
+ def _ensure_tables(self) -> None:
+ if not _SCHEMA.exists():
+ logger.warning("schema.sql not found at %s; skipping local init", _SCHEMA)
+ return
+ self._conn.executescript(_SCHEMA.read_text(encoding="utf-8"))
+
+ # ---------------------------------------------------------------- local
+ def _encode(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
+ out = dict(data)
+ for col in self._JSON_COLUMNS.get(table, ()): # pragma: no cover - simple loop
+ if col in out and out[col] is not None and not isinstance(out[col], str):
+ out[col] = json.dumps(out[col])
+ return out
+
+ def _write(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
+ encoded = self._encode(table, data)
+ cols = list(encoded.keys())
+ placeholders = ",".join(["?"] * len(cols))
+ sql = f"INSERT OR REPLACE INTO {table} ({','.join(cols)}) VALUES ({placeholders})"
+ self._conn.execute(sql, list(encoded.values()))
+ return data
+
+ def _query(self, table: str, where: str = "", params: tuple = ()) -> list[dict[str, Any]]:
+ sql = f"SELECT * FROM {table}"
+ if where:
+ sql += f" WHERE {where}"
+ cur = self._conn.execute(sql, params)
+ return [dict(r) for r in cur.fetchall()]
+
+ # ---------------------------------------------------------------- cloud
+ async def _cloud_post(self, path: str, body: Any) -> None:
+ if self.mode != "cloud" or self._http is None:
+ return
+ try:
+ await self._http.post(path, json=body)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("cloud sync failed on POST %s: %s", path, exc)
+
+ async def _cloud_put(self, path: str, body: Any) -> None:
+ if self.mode != "cloud" or self._http is None:
+ return
+ try:
+ await self._http.put(path, json=body)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("cloud sync failed on PUT %s: %s", path, exc)
+
+ # ------------------------------------------------------------- lifecycle
+ async def register(self) -> dict[str, Any]:
+ data = {
+ "agent_id": self.agent_id,
+ "project_id": self.config.project_id or None,
+ "machine_name": platform.node(),
+ "os_type": platform.system(),
+ "display_name": f"{platform.node()} ({platform.system()})",
+ "last_seen_at": _now(),
+ "created_at": _now(),
+ }
+ self._write("agent_configs", data)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/agents/register", data))
+ return data
+
+ async def heartbeat(self) -> None:
+ self._conn.execute(
+ "UPDATE agent_configs SET last_seen_at=? WHERE agent_id=?",
+ (_now(), self.agent_id),
+ )
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_put(f"/api/agents/{self.agent_id}/heartbeat", {}))
+
+ async def close(self) -> None:
+ if self._http is not None:
+ await self._http.aclose()
+ self._conn.close()
+
+ # ----------------------------------------------------------------- tasks
+ async def create_task(self, task: dict[str, Any]) -> dict[str, Any]:
+ now = _now()
+ task.setdefault("id", str(uuid.uuid4()))
+ task.setdefault("status", "open")
+ task.setdefault("priority", "medium")
+ task["created_at"] = now
+ task["updated_at"] = now
+ self._write("tasks", task)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/tasks", task))
+ return task
+
+ async def update_task(self, task_id: str, updates: dict[str, Any]) -> dict[str, Any]:
+ updates["updated_at"] = _now()
+ existing = self._query("tasks", "id=?", (task_id,))
+ if not existing:
+ raise KeyError(f"task {task_id} not found")
+ merged = {**existing[0], **updates, "id": task_id}
+ self._write("tasks", merged)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_put(f"/api/tasks/{task_id}", updates))
+ return merged
+
+ async def delete_task(self, task_id: str) -> None:
+ self._conn.execute("DELETE FROM tasks WHERE id=?", (task_id,))
+ if self.mode == "cloud" and self._http is not None:
+ with contextlib.suppress(Exception):
+ await self._http.delete(f"/api/tasks/{task_id}")
+
+ def list_tasks(self, status: str | None = None) -> list[dict[str, Any]]:
+ if status:
+ return self._query("tasks", "status=?", (status,))
+ return self._query("tasks")
+
+ # -------------------------------------------------------------- sessions
+ async def start_session(self, project_id: str, context: str = "") -> dict[str, Any]:
+ session = {
+ "id": str(uuid.uuid4()),
+ "project_id": project_id,
+ "agent_id": self.agent_id,
+ "started_at": _now(),
+ "ended_at": None,
+ "duration_seconds": None,
+ "context": context,
+ }
+ self._write("sessions", session)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/sessions", session))
+ return session
+
+ async def end_session(self, session_id: str) -> None:
+ rows = self._query("sessions", "id=?", (session_id,))
+ if not rows:
+ return
+ session = rows[0]
+ started = session.get("started_at")
+ duration = 0
+ if started:
+ try:
+ ts = datetime.fromisoformat(started.replace("Z", "+00:00"))
+ if ts.tzinfo is None:
+ ts = ts.replace(tzinfo=UTC)
+ duration = int((datetime.now(UTC) - ts).total_seconds())
+ except ValueError:
+ pass
+ self._conn.execute(
+ "UPDATE sessions SET ended_at=?, duration_seconds=? WHERE id=?",
+ (_now(), duration, session_id),
+ )
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_put(f"/api/sessions/{session_id}/close", {}))
+
+ async def heartbeat_session(self, session_id: str, context: str = "") -> None:
+ # Local-only heartbeat — don't spam the server.
+ self._conn.execute(
+ "UPDATE sessions SET context=COALESCE(?, context) WHERE id=?",
+ (context or None, session_id),
+ )
+
+ # -------------------------------------------------------------- blockers
+ async def log_blocker(self, blocker: dict[str, Any]) -> dict[str, Any]:
+ blocker.setdefault("id", str(uuid.uuid4()))
+ blocker.setdefault("status", "active")
+ blocker.setdefault("severity", "medium")
+ blocker["agent_id"] = self.agent_id
+ blocker["created_at"] = _now()
+ self._write("blockers", blocker)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/blockers", blocker))
+ return blocker
+
+ async def resolve_blocker(self, blocker_id: str, resolution: str) -> dict[str, Any]:
+ rows = self._query("blockers", "id=?", (blocker_id,))
+ if not rows:
+ raise KeyError(blocker_id)
+ merged = {**rows[0], "status": "resolved", "resolved_at": _now(), "resolution": resolution}
+ self._write("blockers", merged)
+ if self.mode == "cloud":
+ asyncio.create_task(
+ self._cloud_put(f"/api/blockers/{blocker_id}/resolve", {"resolution": resolution})
+ )
+ return merged
+
+ # ------------------------------------------------------------- decisions
+ async def log_decision(self, decision: dict[str, Any]) -> dict[str, Any]:
+ decision.setdefault("id", str(uuid.uuid4()))
+ decision["agent_id"] = self.agent_id
+ decision["created_at"] = _now()
+ self._write("decisions", decision)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/decisions", decision))
+ return decision
+
+ # ------------------------------------------------------------- work logs
+ async def log_work(self, work: dict[str, Any]) -> dict[str, Any]:
+ work.setdefault("id", str(uuid.uuid4()))
+ work["agent_id"] = self.agent_id
+ work["created_at"] = _now()
+ self._write("work_logs", work)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/work-logs", work))
+ return work
+
+ # ------------------------------------------------------------ code atlas
+ async def trigger_scan(self, project_id: str) -> dict[str, Any]:
+ scan = {
+ "id": str(uuid.uuid4()),
+ "project_id": project_id,
+ "agent_id": self.agent_id,
+ "status": "pending",
+ "total_files": 0,
+ "scanned_files": 0,
+ "started_at": _now(),
+ }
+ self._write("code_atlas_scans", scan)
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_post("/api/code-atlas/scan", scan))
+ return scan
+
+ async def update_scan(self, scan_id: str, updates: dict[str, Any]) -> None:
+ rows = self._query("code_atlas_scans", "id=?", (scan_id,))
+ if not rows:
+ return
+ self._write("code_atlas_scans", {**rows[0], **updates, "id": scan_id})
+ if self.mode == "cloud":
+ asyncio.create_task(self._cloud_put(f"/api/code-atlas/scan/{scan_id}", updates))
+
+ async def add_scan_files(self, files: list[dict[str, Any]]) -> None:
+ for f in files:
+ f.setdefault("id", str(uuid.uuid4()))
+ f["scanned_at"] = _now()
+ self._write("code_atlas_files", f)
+ if self.mode == "cloud":
+ asyncio.create_task(
+ self._cloud_post("/api/code-atlas/files/bulk", {"files": files})
+ )
+
+ # --------------------------------------------------------- knowledge graph
+ async def add_nodes(self, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ for n in nodes:
+ n.setdefault("id", str(uuid.uuid4()))
+ n["agent_id"] = self.agent_id
+ n["created_at"] = _now()
+ self._write("knowledge_nodes", n)
+ if self.mode == "cloud":
+ asyncio.create_task(
+ self._cloud_post("/api/knowledge-graph/nodes/bulk", {"nodes": nodes})
+ )
+ return nodes
+
+ async def add_edges(self, edges: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ for e in edges:
+ e.setdefault("id", str(uuid.uuid4()))
+ e["created_at"] = _now()
+ self._write("knowledge_edges", e)
+ if self.mode == "cloud":
+ asyncio.create_task(
+ self._cloud_post("/api/knowledge-graph/edges/bulk", {"edges": edges})
+ )
+ return edges
+
+ def query_graph(self) -> dict[str, list[dict[str, Any]]]:
+ return {
+ "nodes": self._query("knowledge_nodes"),
+ "edges": self._query("knowledge_edges"),
+ }
+
+ # ------------------------------------------------------ performance logs
+ async def ingest_performance_logs(self, logs: list[dict[str, Any]]) -> None:
+ for log in logs:
+ log.setdefault("id", str(uuid.uuid4()))
+ log["agent_id"] = self.agent_id
+ log["logged_at"] = _now()
+ self._write("performance_logs", log)
+ if self.mode == "cloud" and logs:
+ asyncio.create_task(
+ self._cloud_post("/api/performance-logs", {"logs": logs})
+ )
+
+ def get_performance_summary(self, limit: int = 50) -> list[dict[str, Any]]:
+ rows = self._conn.execute(
+ "SELECT * FROM performance_logs ORDER BY logged_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ return [dict(r) for r in rows]
diff --git a/tool/obsmcp/client/websocket_client.py b/tool/obsmcp/client/websocket_client.py
new file mode 100644
index 0000000..682028e
--- /dev/null
+++ b/tool/obsmcp/client/websocket_client.py
@@ -0,0 +1,45 @@
+"""Optional WebSocket subscriber for server events (cloud mode only)."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+try:
+ import websockets
+except ImportError: # pragma: no cover
+ websockets = None # type: ignore[assignment]
+
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.ws")
+
+EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
+
+
+class DashboardWebSocket:
+ def __init__(self, base_url: str, token: str) -> None:
+ self.url = base_url.rstrip("/").replace("http://", "ws://").replace("https://", "wss://") + "/ws/dashboard"
+ self.token = token
+ self.on_event: EventHandler | None = None
+
+ async def connect(self) -> None:
+ if websockets is None:
+ logger.warning("websockets package not installed; skipping WS client")
+ return
+ headers = [("Authorization", f"Bearer {self.token}")] if self.token else []
+ while True:
+ try:
+ async with websockets.connect(self.url, extra_headers=headers) as ws:
+ async for message in ws:
+ try:
+ event = json.loads(message)
+ except json.JSONDecodeError:
+ continue
+ if self.on_event is not None:
+ await self.on_event(event)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("WS connect error: %s — retrying in 5s", exc)
+ await asyncio.sleep(5)
diff --git a/tool/obsmcp/config.py b/tool/obsmcp/config.py
new file mode 100644
index 0000000..3f47608
--- /dev/null
+++ b/tool/obsmcp/config.py
@@ -0,0 +1,106 @@
+"""Configuration management for the local OBSMCP tool.
+
+Config is stored at ``~/.obsmcp/config.json`` (``%USERPROFILE%\\.obsmcp\\config.json``
+on Windows).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import platform
+import uuid
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import Any
+
+
+def config_dir() -> Path:
+ return Path(os.path.expanduser("~/.obsmcp"))
+
+
+def config_path() -> Path:
+ return config_dir() / "config.json"
+
+
+def default_db_path() -> str:
+ return str(config_dir() / "data" / "obsmcp.db")
+
+
+@dataclass
+class EnabledModules:
+ task_monitor: bool = True
+ session_monitor: bool = True
+ file_watcher: bool = True
+ git_monitor: bool = True
+ perf_monitor: bool = True
+ code_atlas: bool = True
+ semantic_index: bool = False
+ knowledge_graph: bool = True
+
+
+@dataclass
+class Config:
+ version: str = "1.0.0"
+ project_path: str = ""
+ project_name: str = ""
+ project_id: str = ""
+ agent_id: str = field(
+ default_factory=lambda: f"agent-{platform.system().lower()}-{uuid.uuid4().hex[:12]}"
+ )
+ mode: str = "standalone" # "standalone" or "cloud"
+ backend_url: str = ""
+ api_token: str = ""
+ enabled_modules: EnabledModules = field(default_factory=EnabledModules)
+ scan_interval_seconds: int = 300
+ perf_log_interval_seconds: int = 30
+ graph_build_interval_seconds: int = 600
+ llm_model: str = "claude-opus-4-5"
+ llm_base_url: str = "https://api.anthropic.com/v1"
+ llm_api_key_env: str = "ANTHROPIC_API_KEY"
+ local_db_path: str = field(default_factory=default_db_path)
+ local_ui_port: int = 8000
+
+ def to_dict(self) -> dict[str, Any]:
+ d = asdict(self)
+ return d
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> Config:
+ modules = EnabledModules(**(data.pop("enabled_modules", {}) or {}))
+ return cls(enabled_modules=modules, **data)
+
+
+def load_config() -> Config:
+ """Load config from disk. Returns defaults if file missing."""
+ path = config_path()
+ if not path.exists():
+ return Config()
+ with path.open("r", encoding="utf-8") as f:
+ return Config.from_dict(json.load(f))
+
+
+def save_config(cfg: Config) -> None:
+ config_dir().mkdir(parents=True, exist_ok=True)
+ Path(cfg.local_db_path).parent.mkdir(parents=True, exist_ok=True)
+ with config_path().open("w", encoding="utf-8") as f:
+ json.dump(cfg.to_dict(), f, indent=2)
+
+
+def configure(
+ project_path: str,
+ backend_url: str = "",
+ api_token: str = "",
+) -> Config:
+ """Create or update config with supplied values and persist it."""
+ cfg = load_config()
+ if project_path:
+ cfg.project_path = str(Path(project_path).expanduser().resolve())
+ cfg.project_name = Path(cfg.project_path).name
+ if not cfg.project_id:
+ cfg.project_id = f"project-{uuid.uuid4().hex[:12]}"
+ cfg.backend_url = backend_url.strip()
+ cfg.api_token = api_token.strip()
+ cfg.mode = "cloud" if cfg.backend_url else "standalone"
+ save_config(cfg)
+ return cfg
diff --git a/tool/obsmcp/graph/__init__.py b/tool/obsmcp/graph/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/graph/edge_builder.py b/tool/obsmcp/graph/edge_builder.py
new file mode 100644
index 0000000..8da759e
--- /dev/null
+++ b/tool/obsmcp/graph/edge_builder.py
@@ -0,0 +1,55 @@
+"""Builds ``imports`` edges between file nodes from the code atlas."""
+
+from __future__ import annotations
+
+import asyncio
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.graph.edges")
+
+
+class EdgeBuilder:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.config = config
+ self.interval = config.graph_build_interval_seconds
+
+ async def run(self) -> None:
+ if not self.config.enabled_modules.knowledge_graph:
+ return
+ while True:
+ try:
+ await self._build()
+ except Exception: # noqa: BLE001
+ logger.exception("edge builder failed")
+ await asyncio.sleep(self.interval)
+
+ async def _build(self) -> None:
+ conn = self.client._conn # noqa: SLF001
+ rows = conn.execute(
+ "SELECT file_path, imports, project_id FROM code_atlas_files"
+ ).fetchall()
+ edges = []
+ for row in rows:
+ imports = row["imports"]
+ if not imports:
+ continue
+ try:
+ import_list = __import__("json").loads(imports)
+ except Exception: # noqa: BLE001
+ continue
+ for imp in import_list:
+ edges.append(
+ {
+ "project_id": row["project_id"],
+ "from_node_id": f"file:{row['file_path']}",
+ "to_node_id": f"file:{imp}",
+ "edge_type": "imports",
+ }
+ )
+ if edges:
+ await self.client.add_edges(edges)
+ logger.info("added %d graph edges", len(edges))
diff --git a/tool/obsmcp/graph/node_extractor.py b/tool/obsmcp/graph/node_extractor.py
new file mode 100644
index 0000000..fcbb427
--- /dev/null
+++ b/tool/obsmcp/graph/node_extractor.py
@@ -0,0 +1,73 @@
+"""Extracts knowledge graph nodes (classes/functions) from Python files.
+
+Kept intentionally minimal — language-specific AST support is expected to
+be added via tree-sitter in a follow-up.
+"""
+
+from __future__ import annotations
+
+import ast
+import asyncio
+from pathlib import Path
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+from ..utils.path_utils import SKIP_DIRS
+
+logger = get_logger("obsmcp.graph.nodes")
+
+
+class NodeExtractor:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.config = config
+ self.project_path = Path(config.project_path) if config.project_path else None
+ self.interval = config.graph_build_interval_seconds
+
+ async def run(self) -> None:
+ if not self.config.enabled_modules.knowledge_graph:
+ return
+ while True:
+ try:
+ await self._build()
+ except Exception: # noqa: BLE001
+ logger.exception("node extraction failed")
+ await asyncio.sleep(self.interval)
+
+ async def _build(self) -> None:
+ if not self.project_path or not self.project_path.exists():
+ return
+ nodes = []
+ for path in self.project_path.rglob("*.py"):
+ if any(part in SKIP_DIRS for part in path.parts):
+ continue
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
+ except SyntaxError:
+ continue
+ rel = str(path.relative_to(self.project_path))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef):
+ nodes.append(
+ {
+ "project_id": self.config.project_id or None,
+ "node_type": "class",
+ "name": node.name,
+ "description": ast.get_docstring(node) or "",
+ "metadata": {"file": rel, "line": node.lineno},
+ }
+ )
+ elif isinstance(node, ast.FunctionDef):
+ nodes.append(
+ {
+ "project_id": self.config.project_id or None,
+ "node_type": "function",
+ "name": node.name,
+ "description": ast.get_docstring(node) or "",
+ "metadata": {"file": rel, "line": node.lineno},
+ }
+ )
+ if nodes:
+ await self.client.add_nodes(nodes)
+ logger.info("added %d graph nodes", len(nodes))
diff --git a/tool/obsmcp/llm/__init__.py b/tool/obsmcp/llm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/llm/semantic_descriptions.py b/tool/obsmcp/llm/semantic_descriptions.py
new file mode 100644
index 0000000..2db16ac
--- /dev/null
+++ b/tool/obsmcp/llm/semantic_descriptions.py
@@ -0,0 +1,97 @@
+"""LLM-powered semantic description of source files.
+
+Reads the Anthropic API key from standard Claude config locations (Claude
+Desktop / Claude CLI) with a fallback to ``ANTHROPIC_API_KEY``.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import platform
+from pathlib import Path
+
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.llm")
+
+SEMANTIC_DESCRIPTION_PROMPT = """\
+You are an expert software architect analyzing a source code file.
+Provide a concise semantic description (2-4 sentences) of what this file does
+and its role in the codebase. Focus on: what problem does it solve, what are
+its key exports, and how does it relate to the rest of the codebase.
+
+File: {file_path}
+Language: {language}
+
+Source (first 2000 chars):
+{content}
+"""
+
+
+def _claude_config_paths() -> list[Path]:
+ if platform.system() == "Windows":
+ return [
+ Path(os.path.expandvars(r"%APPDATA%\Claude\claude_desktop_config.json")),
+ Path(os.path.expandvars(r"%USERPROFILE%\.claude\config.json")),
+ ]
+ return [
+ Path("~/.claude/config.json").expanduser(),
+ Path("~/Library/Application Support/Claude/claude_desktop_config.json").expanduser(),
+ ]
+
+
+def get_anthropic_api_key(config: Config | None = None) -> str:
+ for path in _claude_config_paths():
+ if path.exists():
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, dict) and data.get("api_key"):
+ return str(data["api_key"])
+ except (OSError, json.JSONDecodeError):
+ continue
+ env_var = config.llm_api_key_env if config else "ANTHROPIC_API_KEY"
+ return os.environ.get(env_var, "")
+
+
+async def describe_file(
+ file_path: str,
+ language: str,
+ content: str | None,
+ config: Config,
+) -> str | None:
+ api_key = get_anthropic_api_key(config)
+ if not api_key:
+ return None
+ try:
+ import anthropic # type: ignore[import-not-found]
+ except ImportError: # pragma: no cover
+ logger.warning("anthropic package not installed — semantic descriptions disabled")
+ return None
+
+ if content is None:
+ try:
+ content = Path(file_path).read_text(encoding="utf-8", errors="ignore")[:2000]
+ except OSError:
+ content = ""
+
+ prompt = SEMANTIC_DESCRIPTION_PROMPT.format(
+ file_path=file_path, language=language, content=content[:2000]
+ )
+ client = anthropic.AsyncAnthropic(api_key=api_key, base_url=config.llm_base_url or None)
+ try:
+ response = await client.messages.create(
+ model=config.llm_model,
+ max_tokens=512,
+ messages=[{"role": "user", "content": prompt}],
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("LLM call failed: %s", exc)
+ return None
+ parts = response.content or []
+ if not parts:
+ return None
+ first = parts[0]
+ text = getattr(first, "text", None)
+ return text.strip() if isinstance(text, str) else None
diff --git a/tool/obsmcp/local_ui.py b/tool/obsmcp/local_ui.py
new file mode 100644
index 0000000..1de4c31
--- /dev/null
+++ b/tool/obsmcp/local_ui.py
@@ -0,0 +1,40 @@
+"""Starts the bundled FastAPI server locally in standalone mode.
+
+Reuses the same app module as the remote backend; only the DB path is
+different (points to the local user's SQLite file).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+
+import uvicorn
+
+from .config import Config
+from .utils.logger import get_logger
+
+logger = get_logger("obsmcp.ui")
+
+
+async def serve_local_ui(config: Config) -> None:
+ os.environ.setdefault("OBSMCP_DB_PATH", config.local_db_path)
+ os.environ.setdefault("OBSMCP_PORT", str(config.local_ui_port))
+ os.environ.setdefault("OBSMCP_HOST", "127.0.0.1")
+ # In standalone mode auth is optional; leave token blank by default.
+
+ from obsmcp_server.main import app # noqa: WPS433 (local import by design)
+
+ cfg = uvicorn.Config(
+ app=app,
+ host=os.environ["OBSMCP_HOST"],
+ port=int(os.environ["OBSMCP_PORT"]),
+ log_level="info",
+ )
+ server = uvicorn.Server(cfg)
+ logger.info("Local UI listening on http://%s:%s", cfg.host, cfg.port)
+ await server.serve()
+
+
+def run_local_ui_blocking(config: Config) -> None:
+ asyncio.run(serve_local_ui(config))
diff --git a/tool/obsmcp/mcp_server.py b/tool/obsmcp/mcp_server.py
new file mode 100644
index 0000000..3189b62
--- /dev/null
+++ b/tool/obsmcp/mcp_server.py
@@ -0,0 +1,226 @@
+"""MCP tool server — exposes OBSMCP functionality to any MCP-compatible client.
+
+Uses stdio transport so it plugs into Claude Desktop, Cursor, and Claude Code
+without additional configuration.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+from typing import Any
+
+from .client.http_client import BackendClient
+from .config import load_config
+from .scanners.code_atlas import CodeAtlasScanner
+from .utils.logger import get_logger
+
+logger = get_logger("obsmcp.mcp")
+
+
+def _serialize(result: Any) -> str:
+ return json.dumps(result, default=str)
+
+
+async def _build_tool_handlers(client: BackendClient, config) -> dict[str, Any]: # noqa: ANN001
+ scanner = CodeAtlasScanner(client, config)
+
+ async def get_tasks(status: str | None = None) -> str:
+ return _serialize(client.list_tasks(status))
+
+ async def create_task(
+ title: str,
+ description: str | None = None,
+ status: str = "open",
+ priority: str = "medium",
+ tags: list[str] | None = None,
+ ) -> str:
+ task = {
+ "title": title,
+ "description": description,
+ "status": status,
+ "priority": priority,
+ "tags": tags,
+ "project_id": config.project_id or None,
+ }
+ return _serialize(await client.create_task(task))
+
+ async def update_task(
+ task_id: str,
+ title: str | None = None,
+ description: str | None = None,
+ status: str | None = None,
+ priority: str | None = None,
+ tags: list[str] | None = None,
+ ) -> str:
+ updates: dict[str, Any] = {}
+ for k, v in {
+ "title": title,
+ "description": description,
+ "status": status,
+ "priority": priority,
+ "tags": tags,
+ }.items():
+ if v is not None:
+ updates[k] = v
+ return _serialize(await client.update_task(task_id, updates))
+
+ async def delete_task(task_id: str) -> str:
+ await client.delete_task(task_id)
+ return _serialize({"ok": True, "id": task_id})
+
+ async def log_blocker(description: str, severity: str = "medium") -> str:
+ return _serialize(
+ await client.log_blocker(
+ {
+ "description": description,
+ "severity": severity,
+ "project_id": config.project_id or None,
+ }
+ )
+ )
+
+ async def resolve_blocker(blocker_id: str, resolution: str) -> str:
+ return _serialize(await client.resolve_blocker(blocker_id, resolution))
+
+ async def log_decision(
+ decision: str,
+ context: str | None = None,
+ outcome: str | None = None,
+ tags: list[str] | None = None,
+ ) -> str:
+ return _serialize(
+ await client.log_decision(
+ {
+ "decision": decision,
+ "context": context,
+ "outcome": outcome,
+ "tags": tags,
+ "project_id": config.project_id or None,
+ }
+ )
+ )
+
+ async def log_work(description: str, hours: float | None = None, tags: list[str] | None = None) -> str:
+ return _serialize(
+ await client.log_work(
+ {
+ "description": description,
+ "hours": hours,
+ "tags": tags,
+ "project_id": config.project_id or None,
+ }
+ )
+ )
+
+ async def start_session(context: str | None = None) -> str:
+ return _serialize(await client.start_session(config.project_id or "", context or ""))
+
+ async def end_session(session_id: str) -> str:
+ await client.end_session(session_id)
+ return _serialize({"ok": True, "id": session_id})
+
+ async def scan_codebase() -> str:
+ scan_id = await scanner.perform_scan()
+ return _serialize({"scan_id": scan_id})
+
+ async def get_scan_status(scan_id: str) -> str:
+ rows = client._query("code_atlas_scans", "id=?", (scan_id,)) # noqa: SLF001
+ return _serialize(rows[0] if rows else None)
+
+ async def add_node(
+ node_type: str,
+ name: str,
+ description: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ ) -> str:
+ created = await client.add_nodes(
+ [
+ {
+ "project_id": config.project_id or None,
+ "node_type": node_type,
+ "name": name,
+ "description": description or "",
+ "metadata": metadata or {},
+ }
+ ]
+ )
+ return _serialize(created[0])
+
+ async def add_edge(
+ from_node_id: str,
+ to_node_id: str,
+ edge_type: str,
+ metadata: dict[str, Any] | None = None,
+ ) -> str:
+ created = await client.add_edges(
+ [
+ {
+ "project_id": config.project_id or None,
+ "from_node_id": from_node_id,
+ "to_node_id": to_node_id,
+ "edge_type": edge_type,
+ "metadata": metadata or {},
+ }
+ ]
+ )
+ return _serialize(created[0])
+
+ async def query_graph() -> str:
+ return _serialize(client.query_graph())
+
+ async def get_performance_summary(limit: int = 50) -> str:
+ return _serialize(client.get_performance_summary(limit))
+
+ async def sync_state() -> str:
+ # Force flush: in this implementation everything is synced eagerly,
+ # but the hook exists for future buffer-and-flush behaviour.
+ return _serialize({"ok": True, "mode": client.mode})
+
+ return {
+ "get_tasks": get_tasks,
+ "create_task": create_task,
+ "update_task": update_task,
+ "delete_task": delete_task,
+ "log_blocker": log_blocker,
+ "resolve_blocker": resolve_blocker,
+ "log_decision": log_decision,
+ "log_work": log_work,
+ "start_session": start_session,
+ "end_session": end_session,
+ "scan_codebase": scan_codebase,
+ "get_scan_status": get_scan_status,
+ "add_node": add_node,
+ "add_edge": add_edge,
+ "query_graph": query_graph,
+ "get_performance_summary": get_performance_summary,
+ "sync_state": sync_state,
+ }
+
+
+async def run_stdio() -> None:
+ """Run the MCP tool server over stdio."""
+ from mcp.server.fastmcp import FastMCP # imported lazily to avoid hard dep in tests
+
+ config = load_config()
+ client = BackendClient(config)
+ await client.register()
+
+ server = FastMCP("obsmcp")
+ handlers = await _build_tool_handlers(client, config)
+
+ for name, fn in handlers.items():
+ server.tool(name=name)(fn)
+
+ logger.info("OBSMCP MCP server starting (mode=%s)", config.mode)
+ await server.run_stdio_async()
+
+
+def main() -> None:
+ with contextlib.suppress(KeyboardInterrupt):
+ asyncio.run(run_stdio())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/tool/obsmcp/monitors/__init__.py b/tool/obsmcp/monitors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/monitors/file_watcher.py b/tool/obsmcp/monitors/file_watcher.py
new file mode 100644
index 0000000..4de331b
--- /dev/null
+++ b/tool/obsmcp/monitors/file_watcher.py
@@ -0,0 +1,41 @@
+"""Watches the project directory for file changes (best-effort, optional)."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.files")
+
+try:
+ from watchfiles import awatch
+
+ HAS_WATCHFILES = True
+except ImportError: # pragma: no cover
+ HAS_WATCHFILES = False
+ awatch = None # type: ignore[assignment]
+
+
+class FileWatcher:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.project_path = Path(config.project_path) if config.project_path else None
+ self.debounce_seconds = 30
+ self._pending: dict[str, float] = {}
+
+ async def run(self) -> None:
+ if not HAS_WATCHFILES or awatch is None:
+ logger.warning("watchfiles not installed — file watcher disabled")
+ return
+ if not self.project_path or not self.project_path.exists():
+ logger.warning("project path %s not found — file watcher disabled", self.project_path)
+ return
+ logger.info("Watching %s for changes", self.project_path)
+ async for changes in awatch(str(self.project_path), recursive=True):
+ for change_type, path in changes:
+ logger.debug("file %s: %s", change_type.name, path)
+ await asyncio.sleep(0) # yield; real debounce handled upstream
diff --git a/tool/obsmcp/monitors/git_monitor.py b/tool/obsmcp/monitors/git_monitor.py
new file mode 100644
index 0000000..1bf1ea1
--- /dev/null
+++ b/tool/obsmcp/monitors/git_monitor.py
@@ -0,0 +1,64 @@
+"""Polls the project's git repo for branch/commit info."""
+
+from __future__ import annotations
+
+import asyncio
+import subprocess
+from pathlib import Path
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.git")
+
+
+class GitMonitor:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.project_path = Path(config.project_path) if config.project_path else None
+ self.interval = 60
+ self._last_commit: str | None = None
+ self._last_branch: str | None = None
+
+ def _git(self, *args: str) -> str | None:
+ if not self.project_path:
+ return None
+ try:
+ result = subprocess.run(
+ ["git", *args],
+ cwd=self.project_path,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ return result.stdout.strip()
+ except (subprocess.SubprocessError, FileNotFoundError):
+ return None
+
+ async def run(self) -> None:
+ if not self.project_path or not (self.project_path / ".git").exists():
+ logger.info("git monitor disabled (no .git in %s)", self.project_path)
+ return
+ while True:
+ branch = self._git("rev-parse", "--abbrev-ref", "HEAD")
+ commit = self._git("rev-parse", "HEAD")
+ if branch and branch != self._last_branch:
+ await self.client.log_work(
+ {
+ "description": f"Branch changed to {branch}",
+ "tags": ["git", "branch"],
+ }
+ )
+ self._last_branch = branch
+ if commit and commit != self._last_commit:
+ if self._last_commit is not None:
+ await self.client.log_work(
+ {
+ "description": f"New commit on {branch}: {commit[:8]}",
+ "tags": ["git", "commit"],
+ }
+ )
+ self._last_commit = commit
+ await asyncio.sleep(self.interval)
diff --git a/tool/obsmcp/monitors/perf_monitor.py b/tool/obsmcp/monitors/perf_monitor.py
new file mode 100644
index 0000000..c201a76
--- /dev/null
+++ b/tool/obsmcp/monitors/perf_monitor.py
@@ -0,0 +1,64 @@
+"""Periodic CPU / memory / disk sampling via psutil."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+
+try:
+ import psutil
+
+ HAS_PSUTIL = True
+except ImportError: # pragma: no cover
+ psutil = None # type: ignore[assignment]
+ HAS_PSUTIL = False
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.perf")
+
+
+class PerformanceMonitor:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.project_id = config.project_id or None
+ self.interval = config.perf_log_interval_seconds
+
+ async def run(self) -> None:
+ if not HAS_PSUTIL or psutil is None:
+ logger.warning("psutil not installed — perf monitor disabled")
+ return
+ while True:
+ await asyncio.sleep(self.interval)
+ vm = psutil.virtual_memory()
+ logs = [
+ {
+ "project_id": self.project_id,
+ "metric_name": "cpu_percent",
+ "metric_value": float(psutil.cpu_percent(interval=0.1)),
+ "unit": "percent",
+ "tags": {"core_count": psutil.cpu_count()},
+ },
+ {
+ "project_id": self.project_id,
+ "metric_name": "memory_percent",
+ "metric_value": float(vm.percent),
+ "unit": "percent",
+ "tags": {
+ "total_gb": round(vm.total / 1e9, 2),
+ "available_gb": round(vm.available / 1e9, 2),
+ },
+ },
+ ]
+ with contextlib.suppress(Exception):
+ logs.append(
+ {
+ "project_id": self.project_id,
+ "metric_name": "disk_percent",
+ "metric_value": float(psutil.disk_usage("/").percent),
+ "unit": "percent",
+ }
+ )
+ await self.client.ingest_performance_logs(logs)
diff --git a/tool/obsmcp/monitors/session_monitor.py b/tool/obsmcp/monitors/session_monitor.py
new file mode 100644
index 0000000..b34f64e
--- /dev/null
+++ b/tool/obsmcp/monitors/session_monitor.py
@@ -0,0 +1,35 @@
+"""Starts a session on launch and heartbeats periodically."""
+
+from __future__ import annotations
+
+import asyncio
+import platform
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.session")
+
+
+class SessionMonitor:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.config = config
+ self.heartbeat_interval = 60
+ self.session: dict | None = None
+
+ async def run(self) -> None:
+ self.session = await self.client.start_session(
+ self.config.project_id or "",
+ context=f"OBSMCP started on {platform.node()}",
+ )
+ logger.info("Session started: %s", self.session["id"])
+ try:
+ while True:
+ await asyncio.sleep(self.heartbeat_interval)
+ await self.client.heartbeat_session(self.session["id"], context="")
+ finally:
+ if self.session:
+ await self.client.end_session(self.session["id"])
+ logger.info("Session ended: %s", self.session["id"])
diff --git a/tool/obsmcp/monitors/task_monitor.py b/tool/obsmcp/monitors/task_monitor.py
new file mode 100644
index 0000000..36d6fc7
--- /dev/null
+++ b/tool/obsmcp/monitors/task_monitor.py
@@ -0,0 +1,46 @@
+"""Optional: watches a tasks.json file in the project root for agent-friendly
+sync between text-based task lists and OBSMCP's DB."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.tasks")
+
+
+class TaskMonitor:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.tasks_file = (
+ Path(config.project_path) / "tasks.json" if config.project_path else None
+ )
+ self._last_mtime: float = 0.0
+
+ async def run(self) -> None:
+ if not self.tasks_file:
+ return
+ while True:
+ await asyncio.sleep(10)
+ try:
+ if not self.tasks_file.exists():
+ continue
+ mtime = self.tasks_file.stat().st_mtime
+ if mtime == self._last_mtime:
+ continue
+ self._last_mtime = mtime
+ data = json.loads(self.tasks_file.read_text(encoding="utf-8"))
+ if not isinstance(data, list):
+ continue
+ for t in data:
+ if not isinstance(t, dict) or not t.get("title"):
+ continue
+ await self.client.create_task(t)
+ logger.info("Imported %d tasks from %s", len(data), self.tasks_file)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("task monitor error: %s", exc)
diff --git a/tool/obsmcp/obsmcp_setup.py b/tool/obsmcp/obsmcp_setup.py
new file mode 100644
index 0000000..bb5d86f
--- /dev/null
+++ b/tool/obsmcp/obsmcp_setup.py
@@ -0,0 +1,76 @@
+"""First-run configuration CLI invoked by ``start.bat`` / ``start.sh``.
+
+Accepts flags so the batch launcher can pass the prompted values
+non-interactively::
+
+ python -m obsmcp.obsmcp_setup --configure \
+ --project "D:\\Projects\\MyProject" \
+ --url "" --token ""
+
+When ``--configure`` is omitted, runs an interactive prompt.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+from .config import config_path, configure, load_config
+
+
+def _prompt(prompt: str, default: str = "") -> str:
+ suffix = f" [{default}]" if default else ""
+ value = input(f"{prompt}{suffix}: ").strip()
+ return value or default
+
+
+def interactive() -> int:
+ print("=" * 48)
+ print(" OBSMCP First-Run Setup")
+ print("=" * 48)
+ print()
+ current = load_config()
+ project = _prompt("Enter project path", current.project_path)
+ if not project:
+ print("Project path is required.", file=sys.stderr)
+ return 1
+ if not Path(project).expanduser().exists():
+ print(f"Warning: {project} does not exist yet — will be created on first scan.")
+ print()
+ print("Optional: cloud sync configuration (leave blank for standalone mode).")
+ backend = _prompt("Backend URL", current.backend_url)
+ token = _prompt("API token", current.api_token) if backend else ""
+ cfg = configure(project, backend, token)
+ print()
+ print(f"Configuration saved to {config_path()}")
+ print(f"Mode: {cfg.mode.upper()}")
+ print(f"Agent ID: {cfg.agent_id}")
+ if cfg.mode == "standalone":
+ print(f"Local dashboard will start at http://localhost:{cfg.local_ui_port}")
+ else:
+ print(f"Syncing to {cfg.backend_url}")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="OBSMCP setup")
+ parser.add_argument("--configure", action="store_true")
+ parser.add_argument("--project", default="")
+ parser.add_argument("--url", default="")
+ parser.add_argument("--token", default="")
+ args = parser.parse_args(argv)
+
+ if args.configure:
+ if not args.project:
+ print("--project is required with --configure", file=sys.stderr)
+ return 1
+ cfg = configure(args.project, args.url, args.token)
+ print(f"Configuration saved to {config_path()}")
+ print(f"Mode: {cfg.mode.upper()}")
+ return 0
+ return interactive()
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/tool/obsmcp/scanners/__init__.py b/tool/obsmcp/scanners/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/scanners/code_atlas.py b/tool/obsmcp/scanners/code_atlas.py
new file mode 100644
index 0000000..3585269
--- /dev/null
+++ b/tool/obsmcp/scanners/code_atlas.py
@@ -0,0 +1,160 @@
+"""Code Atlas scanner: walks the project tree and extracts lightweight
+metadata (language, function count, imports) per file using regex heuristics.
+
+Full tree-sitter integration is left as a TODO; the current implementation
+works across all languages without native deps.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..utils.logger import get_logger
+from ..utils.path_utils import SKIP_DIRS
+
+logger = get_logger("obsmcp.atlas")
+
+LANGUAGES: dict[str, str] = {
+ ".py": "python",
+ ".js": "javascript",
+ ".mjs": "javascript",
+ ".cjs": "javascript",
+ ".ts": "typescript",
+ ".tsx": "tsx",
+ ".jsx": "jsx",
+ ".java": "java",
+ ".go": "go",
+ ".rs": "rust",
+ ".cpp": "cpp",
+ ".cc": "cpp",
+ ".c": "c",
+ ".h": "c",
+ ".hpp": "cpp",
+ ".cs": "csharp",
+ ".rb": "ruby",
+ ".php": "php",
+ ".swift": "swift",
+ ".kt": "kotlin",
+ ".scala": "scala",
+ ".vue": "vue",
+ ".svelte": "svelte",
+ ".sh": "shell",
+}
+
+_IMPORT_PATTERNS: dict[str, list[re.Pattern[str]]] = {
+ "python": [
+ re.compile(r"^\s*import\s+([\w.]+)", re.M),
+ re.compile(r"^\s*from\s+([\w.]+)\s+import", re.M),
+ ],
+ "javascript": [re.compile(r"""(?:import|require)\s*\(?['"]([^'"]+)['"]""")],
+ "typescript": [re.compile(r"""(?:import|require)\s*\(?['"]([^'"]+)['"]""")],
+ "tsx": [re.compile(r"""(?:import|require)\s*\(?['"]([^'"]+)['"]""")],
+ "jsx": [re.compile(r"""(?:import|require)\s*\(?['"]([^'"]+)['"]""")],
+ "go": [re.compile(r"""^\s*import\s+['"]([^'"]+)['"]""", re.M)],
+ "rust": [re.compile(r"^\s*use\s+([\w:]+)", re.M)],
+ "java": [re.compile(r"^\s*import\s+([\w.]+);", re.M)],
+}
+
+_FUNCTION_PATTERNS: dict[str, re.Pattern[str]] = {
+ "python": re.compile(r"^\s*(?:async\s+)?def\s+\w+", re.M),
+ "javascript": re.compile(r"\bfunction\s+\w+|=>\s*{|\b\w+\s*=\s*\("),
+ "typescript": re.compile(r"\bfunction\s+\w+|=>\s*{|\b\w+\s*\("),
+ "go": re.compile(r"^\s*func\s+\w+", re.M),
+ "rust": re.compile(r"^\s*fn\s+\w+", re.M),
+ "java": re.compile(r"\b(?:public|private|protected|static)[\w\s]*\s+\w+\s*\(", re.M),
+}
+
+
+class CodeAtlasScanner:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.config = config
+ self.project_path = Path(config.project_path) if config.project_path else None
+ self.scan_interval = config.scan_interval_seconds
+
+ async def run(self) -> None:
+ if not self.project_path or not self.project_path.exists():
+ logger.warning("Code atlas disabled (invalid project path: %s)", self.project_path)
+ return
+ while True:
+ try:
+ await self.perform_scan()
+ except Exception: # noqa: BLE001
+ logger.exception("Scan failed")
+ await asyncio.sleep(self.scan_interval)
+
+ async def perform_scan(self) -> str:
+ assert self.project_path is not None
+ logger.info("Starting code atlas scan of %s", self.project_path)
+ scan = await self.client.trigger_scan(self.config.project_id or "")
+ scan_id = scan["id"]
+
+ batch: list[dict[str, Any]] = []
+ total = 0
+ for path, metadata in self._iter_files():
+ total += 1
+ batch.append(
+ {
+ "scan_id": scan_id,
+ "project_id": self.config.project_id or None,
+ "file_path": str(path.relative_to(self.project_path)),
+ "language": metadata["language"],
+ "functions_count": metadata["functions_count"],
+ "imports": metadata["imports"],
+ "exports": metadata.get("exports", []),
+ }
+ )
+ if len(batch) >= 50:
+ await self.client.add_scan_files(batch)
+ batch = []
+ await asyncio.sleep(0)
+ if batch:
+ await self.client.add_scan_files(batch)
+
+ await self.client.update_scan(
+ scan_id,
+ {
+ "status": "completed",
+ "total_files": total,
+ "scanned_files": total,
+ },
+ )
+ logger.info("Scan %s complete: %d files", scan_id, total)
+ return scan_id
+
+ def _iter_files(self) -> Iterator[tuple[Path, dict[str, Any]]]:
+ assert self.project_path is not None
+ for path in self.project_path.rglob("*"):
+ if not path.is_file():
+ continue
+ if any(part in SKIP_DIRS for part in path.parts):
+ continue
+ suffix = path.suffix.lower()
+ if suffix not in LANGUAGES:
+ continue
+ meta = self._extract(path, LANGUAGES[suffix])
+ if meta is not None:
+ yield path, meta
+
+ def _extract(self, path: Path, language: str) -> dict[str, Any] | None:
+ try:
+ content = path.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ return None
+ imports: list[str] = []
+ for pattern in _IMPORT_PATTERNS.get(language, []):
+ imports.extend(pattern.findall(content))
+ funcs = _FUNCTION_PATTERNS.get(language)
+ functions_count = len(funcs.findall(content)) if funcs else 0
+ return {
+ "language": language,
+ "imports": sorted(set(imports))[:100],
+ "functions_count": functions_count,
+ "exports": [],
+ }
diff --git a/tool/obsmcp/scanners/semantic_index.py b/tool/obsmcp/scanners/semantic_index.py
new file mode 100644
index 0000000..e92f393
--- /dev/null
+++ b/tool/obsmcp/scanners/semantic_index.py
@@ -0,0 +1,52 @@
+"""Best-effort LLM semantic descriptions.
+
+Disabled unless the Anthropic API key is available AND the user has enabled
+the ``semantic_index`` module in config.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+from ..client.http_client import BackendClient
+from ..config import Config
+from ..llm.semantic_descriptions import describe_file
+from ..utils.logger import get_logger
+
+logger = get_logger("obsmcp.semantic")
+
+
+class SemanticIndexer:
+ def __init__(self, client: BackendClient, config: Config) -> None:
+ self.client = client
+ self.config = config
+ self.interval = max(config.scan_interval_seconds * 2, 600)
+
+ async def run(self) -> None:
+ if not self.config.enabled_modules.semantic_index:
+ logger.info("semantic indexer disabled in config")
+ return
+ while True:
+ await asyncio.sleep(self.interval)
+ try:
+ await self._describe_batch()
+ except Exception: # noqa: BLE001
+ logger.exception("semantic indexing batch failed")
+
+ async def _describe_batch(self) -> None:
+ conn = self.client._conn # noqa: SLF001
+ rows = conn.execute(
+ "SELECT id, file_path, language FROM code_atlas_files WHERE semantic_description IS NULL LIMIT 10"
+ ).fetchall()
+ for row in rows:
+ description = await describe_file(
+ file_path=row["file_path"],
+ language=row["language"] or "unknown",
+ content=None,
+ config=self.config,
+ )
+ if description:
+ conn.execute(
+ "UPDATE code_atlas_files SET semantic_description=? WHERE id=?",
+ (description, row["id"]),
+ )
diff --git a/tool/obsmcp/utils/__init__.py b/tool/obsmcp/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tool/obsmcp/utils/logger.py b/tool/obsmcp/utils/logger.py
new file mode 100644
index 0000000..1849f73
--- /dev/null
+++ b/tool/obsmcp/utils/logger.py
@@ -0,0 +1,28 @@
+"""Structured logging helper."""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+_CONFIGURED = False
+
+
+def _configure_root() -> None:
+ global _CONFIGURED
+ if _CONFIGURED:
+ return
+ root = logging.getLogger()
+ if not root.handlers:
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(
+ logging.Formatter("%(asctime)s %(levelname)-7s %(name)s :: %(message)s")
+ )
+ root.addHandler(handler)
+ root.setLevel(logging.INFO)
+ _CONFIGURED = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ _configure_root()
+ return logging.getLogger(name)
diff --git a/tool/obsmcp/utils/path_utils.py b/tool/obsmcp/utils/path_utils.py
new file mode 100644
index 0000000..6f296bb
--- /dev/null
+++ b/tool/obsmcp/utils/path_utils.py
@@ -0,0 +1,31 @@
+"""Cross-platform path helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+SKIP_DIRS: set[str] = {
+ ".git",
+ "__pycache__",
+ "node_modules",
+ ".venv",
+ "venv",
+ "env",
+ "build",
+ "dist",
+ ".next",
+ ".cache",
+ ".mypy_cache",
+ ".ruff_cache",
+ ".pytest_cache",
+ ".obsmcp",
+ "target",
+ ".turbo",
+}
+
+
+def relative_safe(path: Path, base: Path) -> str:
+ try:
+ return str(path.relative_to(base))
+ except ValueError:
+ return str(path)
diff --git a/tool/tests/test_config.py b/tool/tests/test_config.py
new file mode 100644
index 0000000..5bb2272
--- /dev/null
+++ b/tool/tests/test_config.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+from obsmcp.config import Config, configure, load_config
+
+
+def test_configure_creates_standalone(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
+ project = tmp_path / "proj"
+ project.mkdir()
+ cfg = configure(str(project))
+ assert cfg.mode == "standalone"
+ assert cfg.project_path == str(project.resolve())
+ loaded = load_config()
+ assert loaded.project_path == cfg.project_path
+ assert Path(cfg.local_db_path).parent.exists()
+
+
+def test_configure_cloud_mode(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
+ project = tmp_path / "proj"
+ project.mkdir()
+ cfg = configure(str(project), backend_url="https://api.example.com", api_token="abc")
+ assert cfg.mode == "cloud"
+ assert cfg.backend_url == "https://api.example.com"
+
+
+def test_defaults_are_sane():
+ cfg = Config()
+ assert cfg.llm_model
+ assert cfg.perf_log_interval_seconds > 0
+ assert cfg.enabled_modules.task_monitor is True
diff --git a/tool/tests/test_http_client.py b/tool/tests/test_http_client.py
new file mode 100644
index 0000000..da42241
--- /dev/null
+++ b/tool/tests/test_http_client.py
@@ -0,0 +1,42 @@
+import asyncio
+
+import pytest
+from obsmcp.client.http_client import BackendClient
+from obsmcp.config import Config
+
+
+@pytest.fixture()
+def client(tmp_path):
+ cfg = Config()
+ cfg.local_db_path = str(tmp_path / "obsmcp.db")
+ cfg.project_path = str(tmp_path)
+ cfg.project_id = "p-1"
+ cfg.backend_url = "" # standalone
+ cfg.mode = "standalone"
+ c = BackendClient(cfg)
+ yield c
+ asyncio.run(c.close())
+
+
+def test_local_task_crud(client):
+ async def run() -> None:
+ t = await client.create_task({"title": "write tests"})
+ assert t["id"]
+ assert client.list_tasks()[0]["id"] == t["id"]
+ await client.update_task(t["id"], {"status": "done"})
+ assert client.list_tasks()[0]["status"] == "done"
+ await client.delete_task(t["id"])
+ assert client.list_tasks() == []
+
+ asyncio.run(run())
+
+
+def test_local_blocker_lifecycle(client):
+ async def run() -> None:
+ b = await client.log_blocker({"description": "stuck"})
+ assert b["status"] == "active"
+ await client.resolve_blocker(b["id"], "fixed")
+ rows = client._query("blockers", "id=?", (b["id"],)) # noqa: SLF001
+ assert rows[0]["status"] == "resolved"
+
+ asyncio.run(run())
diff --git a/tools/MANUAL_PROMPT.md b/tools/MANUAL_PROMPT.md
deleted file mode 100644
index 9d3391a..0000000
--- a/tools/MANUAL_PROMPT.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Manual Prompt For Non-MCP Tools
-
-Use the following instruction block when a tool cannot connect to MCP and cannot run `ctx.bat`.
-
-```text
-This project uses obsmcp as a shared continuity layer.
-
-Read these files first:
-- .context/PROJECT_CONTEXT.md
-- .context/CURRENT_TASK.json
-- .context/HANDOFF.md
-- .context/DECISIONS.md
-- .context/BLOCKERS.json
-- .context/RELEVANT_FILES.json
-- .context/SESSION_SUMMARY.md
-
-Continue the existing project. Do not restart discovery unless the context says it is necessary.
-Preserve prior decisions and blockers.
-Focus on the current task and relevant files.
-Before you stop, write a concise handoff for the next model or tool.
-```
-
diff --git a/tools/UNIVERSAL_AI_INSTRUCTIONS.md b/tools/UNIVERSAL_AI_INSTRUCTIONS.md
deleted file mode 100644
index 266cc47..0000000
--- a/tools/UNIVERSAL_AI_INSTRUCTIONS.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Universal AI Instructions
-
-This project uses `obsmcp` as the universal continuity layer.
-
-Always do the following first:
-
-1. Read `.context/PROJECT_CONTEXT.md`
-2. Read `.context/CURRENT_TASK.json`
-3. Read `.context/HANDOFF.md`
-4. Read `.context/DECISIONS.md`
-5. Read `.context/BLOCKERS.json`
-6. Read `.context/RELEVANT_FILES.json`
-
-If MCP access is available, query `obsmcp` on `http://127.0.0.1:9300/mcp`.
-
-If MCP access is not available, use `ctx.bat`.
-
-Primary goals:
-
-- preserve continuity across models
-- do not reset the project state
-- reduce repeated explanation
-- log meaningful progress
-- create a handoff before stopping
-
diff --git a/uninstall_task_scheduler.bat b/uninstall_task_scheduler.bat
deleted file mode 100644
index b923612..0000000
--- a/uninstall_task_scheduler.bat
+++ /dev/null
@@ -1,11 +0,0 @@
-@echo off
-setlocal
-set "TASK_NAME=obsmcp"
-
-SCHTASKS /Delete /TN "%TASK_NAME%" /F
-if %ERRORLEVEL% EQU 0 (
- echo Removed Task Scheduler entry "%TASK_NAME%".
-) else (
- echo Failed to remove Task Scheduler entry "%TASK_NAME%".
- exit /b 1
-)