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.
Files changed (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. 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.0.0",
4
- "description": "OIDC trusted publishing setup package for catence",
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
- "oidc",
7
- "trusted-publishing",
8
- "setup"
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
@@ -0,0 +1,3 @@
1
+ """Read-only Garmin extraction worker for Catence."""
2
+
3
+ STAGING_SCHEMA_VERSION = 1