Skip to main content
Glama
yashhooda1

paceforge-garmin-mcp

by yashhooda1
README.md
<div align="center">

# PaceForge

**An offline-first AI running coach.** A local LLM agent that reasons over your Garmin and Strava
training data through the Model Context Protocol — and a deterministic sports-science core that
does the actual math.

No API keys. No cloud inference. Your training data never leaves your machine.

[![CI](https://github.com/yashhooda1/paceforge/actions/workflows/ci.yml/badge.svg)](https://github.com/yashhooda1/paceforge/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff)
[![Typed](https://img.shields.io/badge/mypy-strict-2a6db2.svg)](https://mypy-lang.org/)

</div>

---

## Why this exists

Most "AI coach" projects are a prompt wrapped around a chat API. They send your health data to a
third party, they hallucinate paces, and they can't tell you why they told you to run 6 x 800m.

PaceForge is built the other way around:

| | Typical LLM coach | PaceForge |
|---|---|---|
| Inference | Cloud API, per-token billing | Local Ollama, $0, works on a plane |
| Your health data | Uploaded to a vendor | Never leaves the machine |
| Paces & load | Generated by the model | Computed by tested code; the model may not do arithmetic |
| Tool access | Bespoke function calls | Standard **MCP servers** any host can reuse |
| Advice provenance | "Trust me" | Every finding maps to a named threshold + a cited corpus passage |
| Failure mode | Confident nonsense | Confidence scores, explicit "not enough data" |

The interesting engineering claim: **the language model is the interface, not the reasoning
engine.** VDOT, training load, injury risk, and periodisation are deterministic, unit-tested
functions. The LLM's job is to pick the right tool, read the result, and explain it like a coach.
That split is what makes the output auditable and what makes a 7B model good enough to run it.

---

## Architecture

```mermaid
flowchart TB
    subgraph Interface
        CLI["CLI · paceforge chat / ask / analyze"]
        HOST["Any MCP host<br/>Claude Desktop, IDEs"]
    end

    subgraph Agent["Agent runtime"]
        LOOP["Bounded ReAct loop<br/>tool dedup · forced-answer fallback"]
        MEM["Rolling memory<br/>compaction for 8K windows"]
        LLM["Ollama<br/>native tools + prompt fallback"]
    end

    subgraph MCP["MCP layer (stdio JSON-RPC)"]
        GS["paceforge-garmin-mcp<br/>7 tools · read + write"]
        SS["paceforge-strava-mcp<br/>6 tools · read + analysis"]
    end

    subgraph Core["Deterministic core"]
        VDOT["vdot.py<br/>Daniels/Gilbert · Riegel"]
        MET["metrics.py<br/>TRIMP · EWMA ACWR"]
        AN["analysis.py<br/>rule-based findings"]
        PL["planner.py<br/>periodisation + invariants"]
        RAG["rag/<br/>hybrid dense + BM25"]
    end

    subgraph Data
        CACHE[("SQLite cache<br/>offline source of truth")]
        GAR["Garmin Connect"]
        STR["Strava v3"]
    end

    CLI --> LOOP
    HOST --> GS
    HOST --> SS
    LOOP <--> LLM
    LOOP <--> MEM
    LOOP --> GS
    LOOP --> SS
    GS --> Core
    SS --> Core
    Core --> CACHE
    GAR -. optional sync .-> CACHE
    STR -. optional sync .-> CACHE
    GS -. structured workouts .-> GAR
```

**Data flows one way in, one way out.** Providers normalise vendor JSON into canonical Pydantic
models exactly once, at the boundary. Everything downstream — metrics, analysis, planner, MCP
tools — sees metres, seconds, and seconds-per-kilometre, never `averageRunningCadenceInStepsPerMinute`.

---

## Quickstart

```bash
# 1. Local model runtime
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b-instruct

# 2. Install  (python -m pip, so it lands in the Python you actually run)
git clone https://github.com/yashhooda1/paceforge.git
cd paceforge
python -m pip install -e ".[dev]"

# 3. Load a year of realistic synthetic training data (no credentials needed)
paceforge seed
paceforge index

# 4. Check everything is wired up
paceforge doctor
```

Now use it:

```bash
paceforge analyze                  # deterministic coaching report — no LLM required
paceforge zones                    # VDOT, pace bands, HR zones
paceforge plan 2026-11-15          # periodised 5K block to race day
paceforge ask "am I overtraining?" --trace
paceforge chat                     # interactive session
```

`paceforge analyze` runs with **no model at all** — the sports-science core is independent of the
LLM. That is deliberate: the numbers must be right whether or not a model is running.

> **`'paceforge' is not recognized`?** The console script lives in your Python's `Scripts/`
> (Windows) or `bin/` (POSIX) directory, which is often not on `PATH`. Every command also works as
> `python -m paceforge <command>` — `python -m paceforge analyze`, `python -m paceforge chat`, and
> so on. That form is PATH-independent and always runs the same interpreter you invoked. See
> [Troubleshooting](#troubleshooting).

### Connect your real data

```bash
cp .env.example .env    # fill in Garmin / Strava credentials
paceforge sync garmin --days 365
paceforge sync strava --days 365
```

Credentials are optional throughout. Without them the system runs cache-only, which is the normal
offline path.

---

## Use the MCP servers from any host

The Garmin and Strava tools are real MCP servers, not internal functions. Point Claude Desktop —
or any MCP client — at them:

```jsonc
// claude_desktop_config.json
{
  "mcpServers": {
    "garmin": { "command": "paceforge-garmin-mcp" },
    "strava": { "command": "paceforge-strava-mcp" }
  }
}
```

| Server | Tools |
|---|---|
| **garmin** | `garmin_list_activities`, `garmin_training_summary`, `garmin_athlete_zones`, `garmin_build_workout_plan`, `garmin_schedule_workouts`, `garmin_sync`, `garmin_cache_status` |
| **strava** | `strava_analyze_training`, `strava_predict_race`, `strava_list_activities`, `strava_weekly_volume`, `strava_sync`, `strava_cache_status` |

Two servers rather than one on purpose: shorter tool lists measurably improve tool-selection
accuracy on small local models, and it keeps the read path (Strava) separable from the
device-write path (Garmin).

`garmin_schedule_workouts` writes to a real calendar, so it defaults to `dry_run=true` and the
system prompt requires explicit athlete approval before a live write.

---

## What the coaching core actually computes

| Module | What it does | Why it's written this way |
|---|---|---|
| `coaching/vdot.py` | Daniels & Gilbert oxygen-cost model, VDOT from a race, inverse solver for predicted times, Riegel blending | Forward/inverse solvers are property-tested as true inverses with Hypothesis |
| `coaching/metrics.py` | Banister TRIMP, pace-based fallback load, EWMA acute/chronic load, ACWR, time-weighted intensity distribution | EWMA, not rolling sums — a 7-day window produces a step discontinuity a body never feels. Load falls back to pace so strap-less runs don't bias ACWR downward |
| `coaching/analysis.py` | Rule engine producing severity-ranked `Finding` objects with evidence | Every threshold is a named constant. The LLM explains findings; it never invents them |
| `coaching/planner.py` | 5K periodisation (base → build → peak → taper) with 3-up/1-down volume | Structural invariants: ≤10% weekly ramp, no back-to-back quality days, taper cuts volume and holds intensity. Asserted, not hoped for |
| `rag/` | Hybrid retrieval — cosine + BM25 — over a bundled running-science corpus | Small jargon-dense corpus: "ACWR" and "acute chronic workload ratio" must match, and so must an exact term like "Riegel". Linear scan is genuinely faster than an ANN index at a few hundred chunks |

Embeddings degrade gracefully: local Ollama embedding model → `sentence-transformers` if cached →
a dependency-free hashing embedder that needs no download at all. The index records which backend
produced it and rebuilds automatically if that changes, so incompatible vectors are never compared.

---

## Engineering notes

Things in here that exist because the naive version broke:

**Tool-calling fallback.** Not every local model emits native `tool_calls`. When Ollama reports no
tool support, `llm/ollama.py` switches to a prompt-encoded protocol and parses a fenced JSON block
out of the reply — transparently, so the agent above never learns which path was taken.

**Loop breaking.** Small models re-call the same tool forever. The agent hashes
`(name, arguments)`; a repeat returns the cached result plus an explicit instruction to answer.
Iteration exhaustion doesn't return "max iterations reached" — it makes one final tool-free call so
the athlete always gets a real answer.

**Context compaction.** A 7B at 8K tokens overflows in a handful of turns of verbose JSON, and the
first thing it forgets is the system prompt — which is exactly when it starts inventing paces. Tool
results are truncated on ingest and older turns are summarised into a pinned brief.

**Cadence units.** Strava reports one leg, Garmin reports both. Merging the feeds without doubling
Strava's value produces a confident, wrong "low cadence" finding on every Strava run. Normalised at
the provider boundary.

**MCP SDK 1.x and 2.x.** The SDK's 2.0 release renamed `FastMCP` to `MCPServer` and
`Tool.inputSchema` to `Tool.input_schema`. An open upper bound turned that into a red CI run, which
is exactly what CI is for. Rather than pinning to 1.x and rotting, `mcpx/_compat.py` normalises the
differences and CI runs the MCP suite against both majors, so the claim stays true.

**Structured logging.** Every step emits a keyed event — `llm.request`, `tool.call`,
`agent.iteration` — so an agent run is a queryable trace rather than prose. `--log-format json`
pipes straight into any log store.

---

## Testing

```bash
make test          # pytest + coverage
make lint          # ruff
make typecheck     # mypy --strict
make check         # all three, as CI runs them
```

The suite covers the parts where being wrong matters: VDOT round-trips under Hypothesis, ACWR
behaviour on constructed load histories, planner invariants (ramp cap, hard/easy spacing, taper
shape) asserted across many generated plans, retrieval quality against a fixed eval set, and the
agent loop driven by a scripted fake model so tool-selection and loop-breaking are tested without a
GPU.

---

## Project layout

```
src/paceforge/
├── agent/          ReAct loop, rolling memory, prompts
├── llm/            ChatModel protocol + Ollama backend
├── mcpx/
│   ├── servers/    Garmin & Strava MCP servers (stdio)
│   ├── client.py   MCP host + in-process fallback ToolSource
│   ├── toolkit.py  Tool implementations, shared by both paths
│   └── _compat.py  Shim across MCP SDK 1.x / 2.x
├── coaching/       VDOT, metrics, analysis rules, planner
├── rag/            Embeddings + hybrid SQLite vector store
├── providers/      Garmin / Strava adapters + normalisation
├── storage/        SQLite cache, migrations, synthetic seed
└── corpus/         Running-science knowledge base (markdown)
```

---

## The Ollama model (persona only)

```bash
ollama run hoodarunner/paceforge-coach
```

`ollama/Modelfile` packages the coaching persona — the training philosophy, the
session-purpose reasoning, the tone — over `qwen2.5:7b-instruct`. It is **not**
PaceForge: it has no tools, so it cannot read your Garmin, compute your VDOT, or
calculate your ACWR. The system prompt makes that self-enforcing — asked for a
number it cannot know, it asks you for the input or points at `paceforge analyze`
rather than inventing one, and the push script refuses to publish a build that
fails that check.

The package is the coach. The model is the voice. See [ollama/README.md](ollama/README.md).

---

## Roadmap

- [ ] Sleep and HRV ingestion for readiness-adjusted daily prescription
- [ ] Per-stream analysis (splits, GAP on elevation) rather than session summaries
- [ ] Fine-grained plan adaptation from completed-vs-prescribed deltas
- [ ] Coros and Polar providers behind the existing `ActivityProvider` protocol
- [ ] Optional local web UI over the same MCP tools

---

## Troubleshooting

**`'paceforge' is not recognized as an internal or external command`**

The package installs a console script, but its directory may not be on `PATH` — common on Windows,
and common anywhere with several Pythons installed (conda, a venv, the system one). Two fixes:

*Use the module form.* Always works, no PATH changes, and guarantees you are running the same
interpreter you typed:

```bash
python -m paceforge seed
python -m paceforge analyze
python -m paceforge chat
```

*Or put the scripts directory on PATH.* First confirm the package is actually installed:

```bash
python -c "import paceforge; print(paceforge.__version__)"
```

If that fails, the install did not land in this interpreter — re-run `python -m pip install -e ".[dev]"`
(`python -m pip`, not bare `pip`, so pip and python cannot disagree). If it prints a version, find the
scripts directory and add it to PATH:

```powershell
# Windows
python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
```

```bash
# macOS / Linux
python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
```

**`ollama pull` succeeded but `paceforge doctor` says the model is unreachable**

Ollama must be *running*, not merely installed — start the Ollama app (or `ollama serve`). Confirm
with `curl http://localhost:11434/api/tags`. If Ollama listens on a non-default host or port, set
`PACEFORGE_OLLAMA_HOST` in `.env`.

**Load and heart-rate zones look wrong**

Set `PACEFORGE_ATHLETE_RESTING_HR` and `PACEFORGE_ATHLETE_MAX_HR` in `.env` to your real values.
TRIMP and every HR zone are computed from your heart-rate reserve, so the 50/190 defaults will skew
every load number if they are not close to yours.

**`paceforge analyze` says "Not enough recent running"**

The cache is empty or thin. Run `paceforge seed` for synthetic data, or
`paceforge sync garmin --days 365` once credentials are in `.env`.

---

## Disclaimer

PaceForge is a training-analysis tool, not medical advice. Focal bone pain, pain that alters your
gait, or anything persisting beyond two weeks belongs with a sports-medicine professional.

## License

MIT — see [LICENSE](LICENSE).

Built by [Yash Hooda](https://www.yashhooda.ai/) · Data/AI Engineer, long-distance runner.

Maintenance

ActivityMaintained
ResponsivenessNo issues