catence 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Catence architecture
|
|
2
|
+
|
|
3
|
+
This document records the architecture and design rationale for Catence, a local MCP server to interact with health and fitness data.
|
|
4
|
+
|
|
5
|
+
Even though a great deal of code is written by an agent, the base architecture remains human-driven. However, since I am quite a beginner this means it may actually be somewhat less efficient, but as IBM famously said (somewhere), machine can't make decisions.
|
|
6
|
+
|
|
7
|
+
## Design
|
|
8
|
+
|
|
9
|
+
The project consists of the following stages:
|
|
10
|
+
|
|
11
|
+
1. Data fetching
|
|
12
|
+
2. Normalization and storage
|
|
13
|
+
3. Queries, tools and methods
|
|
14
|
+
4. Serving
|
|
15
|
+
|
|
16
|
+
## Source-layer boundaries
|
|
17
|
+
|
|
18
|
+
The repository is one publishable package, organized by logical layers:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
src/contracts/ shared staging, runtime-path, and storage-port contracts
|
|
22
|
+
↑
|
|
23
|
+
src/core/ catalog, query guard, analytics, retrieval, and runtime policy
|
|
24
|
+
↑
|
|
25
|
+
src/elt/ provider clients, artifact/archive storage, DuckDB, Parquet,
|
|
26
|
+
normalization, and management use cases
|
|
27
|
+
↑
|
|
28
|
+
src/runtime/ internal facade for shared core, ETL, storage, and providers
|
|
29
|
+
↑
|
|
30
|
+
src/interfaces/ CLI and MCP transport adapters and executable entry points
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`core` imports contracts and standard-library capabilities only. `elt` implements storage and provider concerns and may invoke core use cases such as retrieval-index building. `runtime` is the only import surface for `interfaces`; a checked boundary prevents CLI, HTTP, and MCP adapters from reaching into `core` or `elt` implementation paths. `interfaces` parses transport-specific input, invokes these use cases, and serializes the response. The Python Garmin and Strava workers remain under `python/catence/` so their stable `uv` module path and npm distribution contract remain intact; they are an ELT component and never write DuckDB.
|
|
34
|
+
|
|
35
|
+
### 1. Data fetching
|
|
36
|
+
|
|
37
|
+
Garmin Connect provides the complete local record. Intervals.icu contributes only its separately named activity-analysis values (for example training load, RPE, feel, and weighted power). Strava is a deliberately sparse, targeted source for current gear and selected activity segment enrichment.
|
|
38
|
+
|
|
39
|
+
For Garmin, we will use [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) as it is the most mature and widely used library for this purpose. On the Intervals.icu side, the best option available right now is [node-intervals-icue](https://github.com/paladini/node-intervals-icu).
|
|
40
|
+
|
|
41
|
+
### 2. Normalization and storage
|
|
42
|
+
|
|
43
|
+
Catence is DuckDB-first. It is a local, read-only ingestion pipeline before it becomes an MCP server.
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
Intervals.icu / Garmin Connect / targeted Strava
|
|
47
|
+
↓
|
|
48
|
+
immutable raw JSON and original activity files
|
|
49
|
+
↓
|
|
50
|
+
DuckDB catalogue and provenance records
|
|
51
|
+
↓
|
|
52
|
+
Parquet activity-stream lake, queried through DuckDB views
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Runtime data lives in the ignored `.catence/` directory:
|
|
56
|
+
|
|
57
|
+
- `catence.duckdb` contains normalized entities, ingestion state, source provenance, and future-query views.
|
|
58
|
+
- `raw/` contains content-addressed JSON responses and original downloaded activity files.
|
|
59
|
+
- `lake/activity_samples/` contains one Parquet stream file per provider activity, partitioned by provider/year/month.
|
|
60
|
+
- `staging/` contains versioned Garmin JSONL batches before the Node importer commits them to DuckDB.
|
|
61
|
+
|
|
62
|
+
Node/TypeScript owns the DuckDB connection and all database writes. It fetches Intervals.icu directly and imports Garmin staging batches. Python owns Garmin authentication, API extraction, FIT parsing, and Parquet generation; it never opens the DuckDB database for writing.
|
|
63
|
+
|
|
64
|
+
Every provider call is on an explicit GET/download allowlist. Syncs archive responses before normalization, record work-item and error state, continue past an individual failed endpoint/activity, and can be safely rerun because source artifacts are content-addressed and normalized rows have stable keys.
|
|
65
|
+
|
|
66
|
+
The first manual sync uses the previous 12 months only when there is no local normalized coverage. Afterwards, normal syncs are cursor-backed: daily facts reconcile the most recent three days and activity discovery reconciles the most recent fourteen. Activity child endpoints and files are fetched only for new or changed activity summaries. Historical imports remain explicit backfill commands; by default they stop before the newest local source date for each daily/activity data family, avoiding remote reads and upserts of recent data. `backfill --refresh` intentionally disables that boundary. There is no scheduler or provider mutation.
|
|
67
|
+
|
|
68
|
+
Normalized tables preserve provenance rather than force an early merge:
|
|
69
|
+
|
|
70
|
+
- Activities remain provider-specific through `activity_sources`. Provider external IDs are strong links; the general cross-provider auto-link requires one sport-compatible candidate within 90 seconds and tight moving-duration/distance tolerances, while ambiguity and virtual/indoor conflicts remain unlinked. Strava hydration is separate: it uses elapsed duration and does not reject an otherwise compatible candidate on Strava's indoor/virtual classification. Garmin is the canonical original activity detail source. Intervals training load, RPE, feel, and related calculations remain separately named analysis fields; Strava is never canonical.
|
|
71
|
+
- Daily health observations retain provider/source values in `daily_metrics`, while the default `daily_health` projection selects Garmin for every date where Garmin has health facts (another provider is a whole-day fallback). Nutrition includes daily macro/hydration totals and individual food/meal items, in addition to the complete archived payload.
|
|
72
|
+
- Plans, workouts, events, routes, gear, devices, goals, achievements, messages, and provider-specific data are represented as normalized source and domain entities with stable query views and `extension_json` for fields the common model does not yet name.
|
|
73
|
+
- Per-sample GPS, power, heart rate, cadence, speed, altitude, temperature, and grade data are stored as nullable Parquet columns; uncommon sensors are kept in per-sample JSON and the original activity archive remains recoverable.
|
|
74
|
+
|
|
75
|
+
### 3. Queries, tools and methods
|
|
76
|
+
|
|
77
|
+
The MCP process is a separate stdio consumer. Ordinary tools and resources open DuckDB `READ_ONLY` snapshots. The only write capabilities are the explicit `hydrate_strava_activity`, serial `hydrate_recent_strava_activities`, and `hydrate_strava_segment_history` tools: each acquires the shared data-directory lock, calls only the Strava GET allowlist, archives responses before normalization, commits, and releases the lock. If another writer owns it, the result is retryable `data_sync_in_progress`.
|
|
78
|
+
|
|
79
|
+
The query layer has four shared pieces:
|
|
80
|
+
|
|
81
|
+
1. A catalog describes every allowed dataset, field, unit, provenance field, filter, and grouping. The catalog includes activities, activity sources and summaries/intervals, health/daily facts, nutrition days/items, all domain projections, source entities, and activity samples.
|
|
82
|
+
2. The repository reads stream data only from `stream_manifest`. It resolves each recorded relative Parquet path under `lake/activity_samples`, verifies it exists under that directory, and otherwise produces an empty typed relation. No MCP argument can introduce a filesystem path.
|
|
83
|
+
3. Analytics services power both general tools and any future dedicated fitness tool. They provide bounded series reads, declarative aggregation, descriptive statistics/correlations/trends, and OLS, Theil–Sen, quadratic, or cubic fitting.
|
|
84
|
+
4. A SQL guard offers a limited lower-level fallback for questions that do not yet justify a dedicated tool. It accepts one parameterized `SELECT` or `WITH … SELECT` over cataloged views, inspects its resolved table names, rejects filesystem/extension/mutation statements, interrupts after four seconds, and returns deterministic `ORDER BY ALL` pages of at most 500 rows. Every page reports `returnedRows`, `totalRows`, `truncated`, and a query-bound `nextCursor`; a truncated response explicitly warns against exhaustive conclusions.
|
|
85
|
+
|
|
86
|
+
`read_series` returns a maximum of 1,000 points per page, along with the same explicit row-count/truncation fields. Dense activity streams default to minute aggregation on the first `auto` page; raw data stays available through the same bounded interface with a deterministic query-bound cursor.
|
|
87
|
+
|
|
88
|
+
### 4. Serving and retrieval
|
|
89
|
+
|
|
90
|
+
The server exposes `catence_status`, `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `find_activities`, `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, `power_coverage_report`, `latest_cycling_activities`, `cycling_progress_report`, `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, `search_context`, `hydrate_strava_activity`, `hydrate_recent_strava_activities`, and `hydrate_strava_segment_history`, plus resources for status, catalog, a single activity, and a date-range summary.
|
|
91
|
+
|
|
92
|
+
`catence serve` is a loopback-only-by-default Streamable HTTP adapter. It creates a stateless MCP server and transport for each `/mcp` request, so the HTTP and stdio surfaces use the same tool implementation. `GET /health` is a versioned client handshake with runtime version, protocol version, and capabilities; distributed Console builds reject an incompatible protocol before starting a chat. The same listener serves a small read-only `/api/v1/dashboard` snapshot for the local Console: canonical daily-health rows, weekly canonical activity aggregates, recent canonical activities, status, coverage, and explicit missing-data caveats.
|
|
93
|
+
|
|
94
|
+
`catence-data build-retrieval-index` explicitly generates `retrieval_documents` and `retrieval_index_state` in DuckDB. Documents draw from activity labels and key
|
|
95
|
+
summary facts, interval labels, routes/messages, planned workouts/events, weekly training summaries, nutrition daily summaries, and food names. They do
|
|
96
|
+
not include sample streams, GPS paths, source JSON, raw nutrition payloads, or activity files. Sync marks the index stale; rebuilding is idempotent and records the raw-data watermark. When the index is stale, `search_context` says so before its result can be used as activity evidence and directs callers to `find_activities` or `query_read_only_data` instead.
|
|
97
|
+
|
|
98
|
+
When the DuckDB FTS extension is already available locally, the build uses it; otherwise—and without attempting installation—search falls back to normalized keyword matching. `catence_status` reports the active retrieval mode. Embeddings and vector search remain deliberately deferred until keyword/full-text results prove inadequate.
|
|
99
|
+
|
|
100
|
+
Dedicated tools should be added gradually only when recurring questions have stable semantics and cannot be expressed clearly by these primitives. They must call the same repository/analytics services, preserving provenance and the read-only boundary.
|
|
Binary file
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# MCP activity retrieval contract
|
|
2
|
+
|
|
3
|
+
This document is the implementation contract for answering questions. It records the current data behaviour and the dedicated MCP endpoints that should replace repeated ad-hoc SQL.
|
|
4
|
+
|
|
5
|
+
The only mutation exception is targeted Strava activity, serial activity-batch, or segment-history hydration. It acquires the shared data-directory lock, calls only the Strava GET allowlist, archives responses before normalization, and returns a retryable busy result if another writer owns the lock.
|
|
6
|
+
|
|
7
|
+
## Basics
|
|
8
|
+
|
|
9
|
+
- The MCP server is a local, stdio-only consumer of DuckDB and
|
|
10
|
+
registered Parquet files. These endpoints must not call Garmin or
|
|
11
|
+
Intervals.icu, download files, rebuild the retrieval index, or write to the
|
|
12
|
+
database.
|
|
13
|
+
- `activity_id` is a logical identity only when it is linked by a
|
|
14
|
+
provider-supplied external ID. `activity_source_id` identifies one
|
|
15
|
+
provider's record and is the correct identifier for source-specific details,
|
|
16
|
+
samples, intervals, and laps.
|
|
17
|
+
- Never link two records using time, distance, title, sport, or similar
|
|
18
|
+
metrics. A Garmin and an Intervals activity that look like the same workout
|
|
19
|
+
remain separate unless `link_state` and a shared external ID prove the link.
|
|
20
|
+
- Every response includes `data`, `provenance`, `query`, `caveats`, and, when
|
|
21
|
+
appropriate, `nextCursor`. Keep existing result/error conventions:
|
|
22
|
+
`data_sync_in_progress`, `data_unavailable`, and `invalid_request`.
|
|
23
|
+
- Source values are retained alongside derived values. Derived fields must name
|
|
24
|
+
their formula and must never overwrite a provider field.
|
|
25
|
+
- For a selected activity’s Strava segments, climbs, per-segment grades, or
|
|
26
|
+
KOM/PRs, call `get_activity_segments`. It hydrates the matching Strava
|
|
27
|
+
activity before reading its efforts. Do not report segments as absent unless
|
|
28
|
+
that hydration result is `not_found`, `ambiguous`, unauthorised, throttled,
|
|
29
|
+
or failed—and report that result rather than guessing.
|
|
30
|
+
|
|
31
|
+
## What exists today
|
|
32
|
+
|
|
33
|
+
The following are already available and are the authoritative building blocks.
|
|
34
|
+
Dedicated activity endpoints must call the same repository/analytics services,
|
|
35
|
+
rather than embedding their own DuckDB connection or SQL guard.
|
|
36
|
+
|
|
37
|
+
| Need | Current interface | Authoritative relation / identifier |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| Find a recent activity | `query_read_only_data` on `activities`, `activity_sources`, and `activity_summary_facts` | `activity_id`; then `activity_source_id` |
|
|
40
|
+
| Read one logical activity with its sources, summaries, and intervals | `catence://activity/{activityId}` | `activity_id` |
|
|
41
|
+
| Read provider summary metrics | `aggregate_data` or `query_read_only_data` on `activity_summaries` | `activity_source_id` |
|
|
42
|
+
| Read structured interval chunks | `query_read_only_data` on `activity_interval_facts` | `activity_source_id` |
|
|
43
|
+
| Inspect sample-level HR/distance/etc. | `read_series` with `dataset: "activity_samples"` | `activity_source_id` |
|
|
44
|
+
| Read an activity’s Strava segments/climbs | `get_activity_segments` | canonical `activity_id` or `activity_source_id` |
|
|
45
|
+
| Locate relevant activity text | `search_context` | returned entity/activity identifiers |
|
|
46
|
+
|
|
47
|
+
`activity_summary_facts` is the provider-specific summary view. It includes
|
|
48
|
+
the summary metrics plus `activity_id`, `activity_source_id`, `provider`,
|
|
49
|
+
`remote_activity_id`, source artifact hash, activity timestamps, sport, and
|
|
50
|
+
name. `canonical_activity_training` contains one selected interpretation per
|
|
51
|
+
logical activity (Garmin is preferred when linked); it is useful for
|
|
52
|
+
comparison but must not hide provider detail.
|
|
53
|
+
|
|
54
|
+
`activity_interval_facts` contains structured chunks:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
activity_source_id, interval_key, label, start_s, end_s, distance_m,
|
|
58
|
+
avg_power, avg_hr, avg_pace, intensity, metrics_json,
|
|
59
|
+
activity_id, provider, started_at_utc, sport, name
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
It is not synonymous with device laps. Some providers supply labelled workout
|
|
63
|
+
intervals, some supply swim/lap chunks, and some expose an empty split list.
|
|
64
|
+
Null `distance_m`, `start_s`, or `end_s` is a genuine “not supplied” value,
|
|
65
|
+
not zero. `metrics_json` remains the source-specific escape hatch.
|
|
66
|
+
|
|
67
|
+
`activity_samples` is a Parquet-backed time series available only through
|
|
68
|
+
`read_series`/`aggregate_data`. It has `timestamp_utc`, `elapsed_s`,
|
|
69
|
+
`distance_m`, `heart_rate_bpm`, and other nullable sensor fields. It cannot
|
|
70
|
+
be used to introduce an arbitrary Parquet path.
|
|
71
|
+
|
|
72
|
+
## Swim facts and tools
|
|
73
|
+
|
|
74
|
+
`swim_length_facts` contains only explicit provider-supplied length records.
|
|
75
|
+
Catence does not infer a 25 m/50 m length boundary, rest, or pace from Garmin
|
|
76
|
+
sample points when distance disappears after the opening sample. An empty
|
|
77
|
+
length result therefore means *not supplied*, not no lengths swum.
|
|
78
|
+
|
|
79
|
+
`swim_set_facts` retains source-specific grouped work:
|
|
80
|
+
|
|
81
|
+
- `garmin_detected` comes from Garmin split summaries and may have a supplied
|
|
82
|
+
duration/HR but no usable distance;
|
|
83
|
+
- `intervals_auto` parses Intervals.icu labels such as `12x 52m 154bpm` into
|
|
84
|
+
`reps`, `rep_distance_m`, total distance, and mean HR. It remains an
|
|
85
|
+
auto-detected block, not a manually pressed set, and pace/work/rest stay
|
|
86
|
+
null unless supplied.
|
|
87
|
+
|
|
88
|
+
`activity_interval_facts` now includes `duration_s`, `moving_s`, and
|
|
89
|
+
`source_type` for those source rows. `canonical_activity_facts` exposes the
|
|
90
|
+
unmodified Garmin and Intervals values beside each selected field, the source
|
|
91
|
+
that won precedence, their distance difference, and quality flags.
|
|
92
|
+
|
|
93
|
+
Use `get_swim_laps({ activityId, provider? })` for one source or linked
|
|
94
|
+
activity. A source ID is resolved exactly; a logical activity ID returns all
|
|
95
|
+
linked swim sources. Use `swim_progress_report({ startDate, endDate,
|
|
96
|
+
poolLengthM? })` for Garmin-native summary comparisons. The report never
|
|
97
|
+
creates a pace trend from `moving_s / distance_m`, and it returns
|
|
98
|
+
data-completeness notes for per-length pace.
|
|
99
|
+
|
|
100
|
+
Ingestion flags implausible pool settings, zero speed/cadence long pool
|
|
101
|
+
sessions, active-length/distance mismatches, linked-provider distance
|
|
102
|
+
disagreements, and missing explicit length records. Flags are caveats; they
|
|
103
|
+
never overwrite or discard a provider's source values.
|
|
104
|
+
|
|
105
|
+
## Current investigation procedure
|
|
106
|
+
|
|
107
|
+
Use this procedure until the endpoints below exist.
|
|
108
|
+
|
|
109
|
+
1. Check `catence_status`. If the sync snapshot is unavailable, report the MCP
|
|
110
|
+
error; do not retry by opening the database for writing.
|
|
111
|
+
2. List provider records from newest to oldest with `activities` joined to
|
|
112
|
+
`activity_sources` and, where needed, `activity_summary_facts`. Include
|
|
113
|
+
both the logical and source IDs in the result.
|
|
114
|
+
3. For one selected source, query `activity_interval_facts` ordered by
|
|
115
|
+
`start_s`. Do not use a time/distance heuristic to select a different
|
|
116
|
+
source.
|
|
117
|
+
4. If a provider’s interval rows are empty or all structural fields are null,
|
|
118
|
+
say that detailed splits were not supplied by that imported endpoint. A
|
|
119
|
+
summary field such as Garmin's lap count or fastest split is still useful,
|
|
120
|
+
but it does **not** reconstruct per-lap records.
|
|
121
|
+
5. If numerical detail is required, use `read_series` for the selected
|
|
122
|
+
`activity_source_id`; include its resolution/downsampling caveat. The
|
|
123
|
+
original activity file may exist in the raw archive, but raw archives are
|
|
124
|
+
not exposed directly to MCP in this release.
|
|
125
|
+
|
|
126
|
+
### Segment calculations
|
|
127
|
+
|
|
128
|
+
For an interval with positive `distance_m` and both times present, an agent
|
|
129
|
+
may add the following *derived* fields:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
duration_s = end_s - start_s
|
|
133
|
+
pace_s_per_100m = 100 * duration_s / distance_m
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Only emit `pace_s_per_100m` when `duration_s > 0` and `distance_m > 0`.
|
|
137
|
+
Retain the original `avg_pace` separately and do not infer its unit when the
|
|
138
|
+
provider does not document/populate it. A no-distance chunk may be described
|
|
139
|
+
as `no_distance_segment`; calling it a recovery/rest requires a source label
|
|
140
|
+
or a clearly labelled user interpretation. If the sum of chunk distances
|
|
141
|
+
differs from the activity summary, report both values and the difference.
|
|
142
|
+
|
|
143
|
+
## Proposed dedicated MCP endpoints
|
|
144
|
+
|
|
145
|
+
These endpoints are drafts, not registered tools yet. Add them incrementally
|
|
146
|
+
only after their shared service methods and fixture tests exist.
|
|
147
|
+
|
|
148
|
+
## Implemented fitness and hydration endpoints
|
|
149
|
+
|
|
150
|
+
- `describe_dataset(dataset)` returns one cataloged schema and its coverage. `training_metric_observations` also reports its observed sports, metric names, units, and source types.
|
|
151
|
+
- `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, and `power_coverage_report` read normalized, source-aware fitness facts. Power tools require an explicit sport or sport family, use `power_bests`/`power_best_facts` for FIT-derived duration values, and do not treat sparse `avg_power` summaries as coverage. `get_vo2max_history` does not default to cycling: omission returns available source labels and requires an explicit choice. For Garmin running VO₂max, request `sport: "running"`; the tool reads the raw `generic` source series and preserves that label on returned observations.
|
|
152
|
+
- `find_activities` is implemented for compact, canonical, paginated activity/race discovery by sport, distance, name, and date. Its race flags are transparent heuristics, not provider-confirmed race metadata.
|
|
153
|
+
- `get_activity_segments` is the required segment/climb path. It performs the
|
|
154
|
+
targeted Strava hydration before returning the persisted effort and segment
|
|
155
|
+
facts; an empty result must be interpreted alongside its hydration status.
|
|
156
|
+
- `latest_cycling_activities` returns Garmin source records to avoid silently double-counting linked Intervals summaries; optional multisport parents are explicitly flagged.
|
|
157
|
+
- `cycling_progress_report` composes the preceding read-only outputs with monthly canonical volume/load. It is descriptive, not a physiological model.
|
|
158
|
+
- `hydrate_recent_strava_activities` accepts an explicit list or a bounded date/sport window and awaits each write in sequence. Its unmatched output includes the exact Strava search window and safe-match rejection diagnostics.
|
|
159
|
+
|
|
160
|
+
### Power investigation procedure
|
|
161
|
+
|
|
162
|
+
For a question about running or cycling power, do not begin with
|
|
163
|
+
`canonical_activities.avg_power`: it is an activity-summary field and can be
|
|
164
|
+
null even when the FIT stream has power. Instead:
|
|
165
|
+
|
|
166
|
+
1. Call `describe_dataset` for `power_bests` and use `power_coverage_report`
|
|
167
|
+
with an explicit `sport` or `sportFamily` to establish coverage and every
|
|
168
|
+
available duration.
|
|
169
|
+
2. Use `power_curve_trend` with the same selector and
|
|
170
|
+
`sourceQuality: "garmin_fit_derived"` for comparable monthly curve bests.
|
|
171
|
+
3. Inspect `activity_samples` only for a selected `activity_source_id` when a
|
|
172
|
+
specific extreme needs validation. Raw sample reads are paginated and are
|
|
173
|
+
not evidence that the global power dataset is absent.
|
|
174
|
+
|
|
175
|
+
### `list_recent_activities`
|
|
176
|
+
|
|
177
|
+
Purpose: answer “what is my latest activity?” without requiring the agent to
|
|
178
|
+
invent joins, provider precedence, or identifier handling.
|
|
179
|
+
|
|
180
|
+
Input:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
{
|
|
184
|
+
before?: string, // ISO timestamp; exclusive cursor anchor
|
|
185
|
+
after?: string, // ISO timestamp; inclusive
|
|
186
|
+
provider?: "garmin" | "intervals",
|
|
187
|
+
sport?: string,
|
|
188
|
+
sourceMode?: "source" | "logical", // default: "source"
|
|
189
|
+
limit?: number, // 1–100; default: 20
|
|
190
|
+
cursor?: string
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`sourceMode: "source"` is the default because it never conceals an unlinked
|
|
195
|
+
provider record. It returns one provider source per item. `sourceMode:
|
|
196
|
+
"logical"` returns an activity plus all its source records only when the
|
|
197
|
+
database already has that logical relationship; unlinked records remain
|
|
198
|
+
separate items. Both modes sort by `started_at_utc DESC`, then stable source
|
|
199
|
+
identity. Cursor contents are opaque, signed/validated query state—not a raw
|
|
200
|
+
SQL offset.
|
|
201
|
+
|
|
202
|
+
Each item should include:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
{
|
|
206
|
+
activityId: string,
|
|
207
|
+
activitySourceId: string,
|
|
208
|
+
provider: "garmin" | "intervals",
|
|
209
|
+
remoteActivityId: string,
|
|
210
|
+
externalId: string | null,
|
|
211
|
+
linkState: string,
|
|
212
|
+
startedAtUtc: string | null,
|
|
213
|
+
startedAtLocal: string | null,
|
|
214
|
+
timezone: string | null,
|
|
215
|
+
sport: string | null,
|
|
216
|
+
name: string | null,
|
|
217
|
+
summary: {
|
|
218
|
+
distanceM: number | null,
|
|
219
|
+
movingS: number | null,
|
|
220
|
+
elapsedS: number | null,
|
|
221
|
+
avgHr: number | null,
|
|
222
|
+
maxHr: number | null,
|
|
223
|
+
trainingLoad: number | null
|
|
224
|
+
} | null,
|
|
225
|
+
availability: { intervals: boolean, samples: boolean }
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The query is a read-only projection of `activities`, `activity_sources`,
|
|
230
|
+
`activity_summary_facts`, and `stream_manifest`. It must not use
|
|
231
|
+
`canonical_activity_training` to suppress a Garmin source. Provenance names
|
|
232
|
+
the selected relations and source artifact hash where available.
|
|
233
|
+
|
|
234
|
+
### `get_activity_detail`
|
|
235
|
+
|
|
236
|
+
Purpose: return the compact, authoritative detail an agent needs after a
|
|
237
|
+
selection from `list_recent_activities`.
|
|
238
|
+
|
|
239
|
+
Input is exactly one of:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
{ activitySourceId: string, include?: ["summary", "intervals", "availability"] }
|
|
243
|
+
// or
|
|
244
|
+
{ activityId: string, include?: ["summary", "intervals", "availability"] }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`activitySourceId` returns exactly that provider record. `activityId` returns
|
|
248
|
+
the logical activity plus **all** linked sources; it never selects a “best”
|
|
249
|
+
source silently. The default includes `summary` and `availability`, not every
|
|
250
|
+
interval. Intervals are limited to 500 items and receive deterministic cursor
|
|
251
|
+
pagination. A compact response should contain activity identity/timestamps,
|
|
252
|
+
source identities and external IDs, all available provider summaries, and:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
availability: {
|
|
256
|
+
intervalCount: number,
|
|
257
|
+
intervalDetailsAvailable: boolean,
|
|
258
|
+
stream: { available: boolean, rowCount?: string, columns?: string[] },
|
|
259
|
+
originalArchiveRecorded: boolean
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`originalArchiveRecorded` only states that a raw object/manifest exists. It
|
|
264
|
+
does not expose a filesystem path or claim that FIT parsing supplied laps.
|
|
265
|
+
`include: ["intervals"]` follows the segment contract below.
|
|
266
|
+
|
|
267
|
+
### `list_activity_segments`
|
|
268
|
+
|
|
269
|
+
Purpose: replace the manual interval query used for lap/interval questions.
|
|
270
|
+
It should have a neutral name because source “intervals”, swim laps, and
|
|
271
|
+
device splits have different meanings.
|
|
272
|
+
|
|
273
|
+
Input:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
{
|
|
277
|
+
activitySourceId: string,
|
|
278
|
+
kind?: "all" | "structured_interval" | "lap_or_split", // default: "all"
|
|
279
|
+
includeDerived?: boolean, // default: true
|
|
280
|
+
limit?: number, // 1–500; default: 100
|
|
281
|
+
cursor?: string
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`kind` filters only by explicit source classification. It must return an
|
|
286
|
+
empty result with `lap_or_split_available: false` when the provider supplied
|
|
287
|
+
no detailed split records; it must not manufacture laps from sample points.
|
|
288
|
+
|
|
289
|
+
For each item return source fields (`intervalKey`, `label`, `startS`, `endS`,
|
|
290
|
+
`distanceM`, `avgHr`, `avgPower`, `avgPace`, `intensity`, and optionally
|
|
291
|
+
`metricsJson`) plus safe derived fields:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
{
|
|
295
|
+
derived: {
|
|
296
|
+
durationS: number | null,
|
|
297
|
+
paceSPer100m: number | null,
|
|
298
|
+
distanceClass: "positive_distance" | "no_distance" | "unknown"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The top-level result includes total supplied distance, total derived distance,
|
|
304
|
+
summary distance, and their difference. It must explicitly disclose whether
|
|
305
|
+
the response is made of structured intervals, provider laps/splits, or both.
|
|
306
|
+
|
|
307
|
+
### `read_activity_stream`
|
|
308
|
+
|
|
309
|
+
Purpose: make selected-source sample inspection straightforward while retaining
|
|
310
|
+
the existing bounded stream rules.
|
|
311
|
+
|
|
312
|
+
Input:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
{
|
|
316
|
+
activitySourceId: string,
|
|
317
|
+
metrics: Array<"elapsed_s" | "distance_m" | "heart_rate_bpm" |
|
|
318
|
+
"power_w" | "cadence_rpm" | "speed_mps" | "temperature_c">,
|
|
319
|
+
resolution?: "raw" | "1m" | "5m" | "15m" | "auto",
|
|
320
|
+
startElapsedS?: number,
|
|
321
|
+
endElapsedS?: number,
|
|
322
|
+
pageSize?: number,
|
|
323
|
+
cursor?: string
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
This is a source-ID wrapper over `read_series({ dataset: "activity_samples" })`.
|
|
328
|
+
It inherits the 1,000-point page limit, cursor format, automatic downsampling,
|
|
329
|
+
manifest-only Parquet access, and null handling. It returns no data—not an
|
|
330
|
+
error—when the source has no registered stream, with a caveat explaining that
|
|
331
|
+
sample availability is provider/activity-specific.
|
|
332
|
+
|
|
333
|
+
## Intended agent behaviour
|
|
334
|
+
|
|
335
|
+
For “how did this go in the context of my recent swims?” an agent should:
|
|
336
|
+
|
|
337
|
+
1. Use `list_recent_activities` filtered to swimming to identify the selected
|
|
338
|
+
source and comparable source records.
|
|
339
|
+
2. Use `get_activity_detail` for the selected source. Prefer canonical
|
|
340
|
+
Intervals training values only when the logical activity is strongly linked;
|
|
341
|
+
otherwise state the provider used.
|
|
342
|
+
3. Use `list_activity_segments` only for set/lap questions. Compute pace using
|
|
343
|
+
the documented formula, name it as derived, and do not call unlabelled
|
|
344
|
+
sections “recovery” as a fact.
|
|
345
|
+
4. Use `read_activity_stream` when the summary/segments cannot answer a
|
|
346
|
+
question about HR evolution, temperature, or other sample-level behaviour.
|
|
347
|
+
5. State missing data and provenance in the answer. Do not imply that an empty
|
|
348
|
+
Garmin split list means no laps occurred—only that detailed split records
|
|
349
|
+
were not returned by the imported endpoint.
|
|
350
|
+
|
|
351
|
+
## Implementation order and acceptance criteria
|
|
352
|
+
|
|
353
|
+
1. Add repository methods that return typed source-first activity records and
|
|
354
|
+
manifest availability. Keep SQL there; MCP handlers remain thin.
|
|
355
|
+
2. Implement `list_recent_activities`, including stable ordering and tamper-
|
|
356
|
+
resistant cursor validation.
|
|
357
|
+
3. Implement `get_activity_detail` and `list_activity_segments` against the
|
|
358
|
+
repository methods. Reuse the existing JSON-safe result/error envelope.
|
|
359
|
+
4. Add `read_activity_stream` as a validated adapter to `AnalyticsService`;
|
|
360
|
+
do not duplicate downsampling or Parquet path checks.
|
|
361
|
+
5. Add Zod schemas and one independent module/test file per tool. Update the
|
|
362
|
+
catalog/README once the tools are registered.
|
|
363
|
+
|
|
364
|
+
Fixture tests must cover:
|
|
365
|
+
|
|
366
|
+
- two unlinked Garmin/Intervals records with similar timestamps and distances;
|
|
367
|
+
list results remain distinct;
|
|
368
|
+
- a truly strong-ID-linked activity; detail returns both sources without
|
|
369
|
+
concealing either;
|
|
370
|
+
- labelled intervals, unlabelled positive-distance chunks, and no-distance
|
|
371
|
+
chunks; pace is derived only when valid;
|
|
372
|
+
- a Garmin summary with lap count/fastest split but an empty split list;
|
|
373
|
+
`lap_or_split_available` is false and no laps are invented;
|
|
374
|
+
- no stream manifest, missing Parquet file, and a valid registered stream;
|
|
375
|
+
- pagination, malformed/tampered cursors, limits, and read-only snapshot
|
|
376
|
+
errors;
|
|
377
|
+
- response provenance and caveats for every branch.
|
package/package.json
CHANGED
|
@@ -1,10 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catence",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/rifusaki/catence.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/rifusaki/catence#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/rifusaki/catence/issues"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"catence": "./dist/interfaces/mcp/main.js",
|
|
23
|
+
"catence-data": "./dist/interfaces/cli/main.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/**/*.js",
|
|
27
|
+
"dist/**/*.d.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"config.example.json",
|
|
30
|
+
"docs",
|
|
31
|
+
"pyproject.toml",
|
|
32
|
+
"uv.lock",
|
|
33
|
+
"python/__init__.py",
|
|
34
|
+
"python/catence/providers/garmin/*.py",
|
|
35
|
+
"python/catence/providers/strava/*.py",
|
|
36
|
+
"python/catence/__init__.py",
|
|
37
|
+
"python/catence/providers/__init__.py"
|
|
38
|
+
],
|
|
5
39
|
"keywords": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
40
|
+
"mcp",
|
|
41
|
+
"model-context-protocol",
|
|
42
|
+
"fitness",
|
|
43
|
+
"garmin",
|
|
44
|
+
"intervals-icu",
|
|
45
|
+
"strava",
|
|
46
|
+
"triathlon",
|
|
47
|
+
"duckdb"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\" && tsc -p tsconfig.build.json",
|
|
51
|
+
"check": "node scripts/check-runtime-boundaries.mjs && tsc -p tsconfig.json --noEmit",
|
|
52
|
+
"test": "vitest run --config vitest.config.ts",
|
|
53
|
+
"test:watch": "vitest",
|
|
54
|
+
"catence-data": "tsx src/interfaces/cli/main.ts",
|
|
55
|
+
"mcp": "tsx src/interfaces/mcp/main.ts",
|
|
56
|
+
"console": "uv run --project console catence-console serve",
|
|
57
|
+
"console:doctor": "uv run --project console catence-console doctor",
|
|
58
|
+
"package:check": "npm pack --dry-run",
|
|
59
|
+
"release:check": "node scripts/release-artifacts.mjs check",
|
|
60
|
+
"mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
|
|
61
|
+
"mcpb:prepare": "npm run build && node scripts/release-artifacts.mjs prepare-mcpb",
|
|
62
|
+
"mcpb:pack": "node scripts/pack-mcpb.mjs",
|
|
63
|
+
"prepack": "npm run release:check && npm run build",
|
|
64
|
+
"prepublishOnly": "npm run release:check && npm run check && npm test"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@duckdb/node-api": "^1.4.4-r.1",
|
|
68
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
69
|
+
"commander": "^14.0.0",
|
|
70
|
+
"dotenv": "^17.2.2",
|
|
71
|
+
"intervals-icu": "^2.2.0",
|
|
72
|
+
"zod": "^4.1.5"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@types/node": "^24.3.0",
|
|
76
|
+
"tsx": "^4.20.5",
|
|
77
|
+
"typescript": "^5.9.2",
|
|
78
|
+
"vitest": "^3.2.4"
|
|
79
|
+
},
|
|
80
|
+
"overrides": {
|
|
81
|
+
"fast-uri": "3.1.5",
|
|
82
|
+
"hono": "4.12.34",
|
|
83
|
+
"nanoid": "3.3.17"
|
|
84
|
+
}
|
|
10
85
|
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "catence-data"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local Catence provider workers for read-only fitness data extraction"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"duckdb>=1.4.0",
|
|
8
|
+
"fitdecode>=0.10.0",
|
|
9
|
+
"garminconnect>=0.2.37",
|
|
10
|
+
"polars>=1.0.0",
|
|
11
|
+
"pyarrow>=18.0.0",
|
|
12
|
+
"pydantic>=2.0.0",
|
|
13
|
+
"stravalib>=2.5.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.uv.sources]
|
|
17
|
+
# fork commit
|
|
18
|
+
garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "732bf14602bce2ca22cb569ba6583c3229bf904d" }
|
|
19
|
+
|
|
20
|
+
[tool.uv]
|
|
21
|
+
package = false
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=8.0.0"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
pythonpath = ["."]
|
|
28
|
+
testpaths = ["python/tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python runtime package root for Catence."""
|
|
File without changes
|
|
File without changes
|