catence 0.2.0 → 0.2.1-beta.10
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/README.md +5 -5
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +20 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/query/sql-guard.js +37 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +94 -5
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +5 -1
- package/dist/interfaces/http/server.js +141 -3
- package/dist/interfaces/mcp/server.js +106 -16
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +2 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +49 -0
- package/docs/deployment/docker.md +87 -8
- package/docs/deployment/local-console.md +124 -21
- package/docs/deployment/local-mcp.md +12 -5
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +7 -5
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +31 -3
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- package/uv.lock +3 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ As a context, I'm both an endurance athlete and a data junkie. I already got a c
|
|
|
12
12
|
|
|
13
13
|
- Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
|
|
14
14
|
- Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
|
|
15
|
-
- One
|
|
15
|
+
- One Catence MCP server serves several athlete stores side by side. Every personal-data MCP call names an `athleteId`; tools act only on that store and never merge athletes implicitly. The MCP server does not authenticate callers — it trusts the `athleteId` each call names.
|
|
16
16
|
- A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
|
|
17
17
|
- A generated demo catalog for safe evaluation in Glama, desktop MCP clients, or local development.
|
|
18
18
|
|
|
@@ -31,7 +31,7 @@ Install Catence and create the first athlete. The default catalog home is `~/.ca
|
|
|
31
31
|
Every `catence-data` command requires `--athlete <id>`; there is no default athlete. Pair it with `--home <dir>` to select the catalog when not using the default home.
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npm install --global catence@
|
|
34
|
+
npm install --global catence@latest # or catence@beta
|
|
35
35
|
catence-data setup --athlete alex --label "Alex"
|
|
36
36
|
```
|
|
37
37
|
|
|
@@ -57,7 +57,7 @@ Start the stdio MCP server:
|
|
|
57
57
|
catence
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
An agent first calls `list_athletes`, then includes `athleteId` with every data tool.
|
|
60
|
+
An agent first calls `list_athletes`, then includes `athleteId` with every data tool. Tools act only on the named athlete store and never merge athletes implicitly. The MCP server does not authenticate callers — it trusts the `athleteId` each call names — so keep it on loopback or restrict it at the network layer (see [MCP clients and HTTP](#mcp-clients-and-http)).
|
|
61
61
|
|
|
62
62
|
### Add another athlete
|
|
63
63
|
|
|
@@ -114,13 +114,13 @@ For a source checkout:
|
|
|
114
114
|
codex mcp add catence -- npm --prefix /absolute/path/to/catence run mcp
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
Optional local Streamable HTTP MCP and
|
|
117
|
+
Optional local Streamable HTTP MCP and HTTP APIs:
|
|
118
118
|
|
|
119
119
|
```sh
|
|
120
120
|
catence serve --host 127.0.0.1 --port 8787
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. The MCP server has no authentication of its own.
|
|
123
|
+
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own.
|
|
124
124
|
|
|
125
125
|
## Configuration
|
|
126
126
|
|
package/config.example.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.10";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.10';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -8,4 +8,8 @@ export type CatencePaths = {
|
|
|
8
8
|
config: string;
|
|
9
9
|
secrets: string;
|
|
10
10
|
lock: string;
|
|
11
|
+
/** Athlete-authored markdown document shared by the athlete and the agent. */
|
|
12
|
+
athleteFile: string;
|
|
13
|
+
/** Rolling snapshots of prior athlete-file content. Created on first write. */
|
|
14
|
+
athleteFileHistory: string;
|
|
11
15
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
|
|
1
|
+
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'run_dynamics' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
|
|
2
2
|
export type CatalogColumn = {
|
|
3
3
|
name: string;
|
|
4
4
|
type: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const column = (name, type, options = {}) => ({ name, type, ...options });
|
|
2
|
+
const PAYLOAD_JSON_DESCRIPTION = 'Raw provider object retained for identifier and string lookups; its structure varies by provider and endpoint and is not a stable schema.';
|
|
3
|
+
const EXTENSION_JSON_DESCRIPTION = 'Provider extension object retained alongside payload_json for identifier and string lookups; its structure varies by provider and endpoint.';
|
|
2
4
|
export const DATASET_CATALOG = {
|
|
3
5
|
activities: {
|
|
4
6
|
name: 'activities', relation: 'activities', description: 'Logical activities linked by provider IDs, unique high-confidence fuzzy evidence, or an explicit manual correction.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'link_state'], permittedGroupings: ['sport', 'link_state'],
|
|
@@ -17,8 +19,8 @@ export const DATASET_CATALOG = {
|
|
|
17
19
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_activity_id', 'VARCHAR', { filterable: true }), column('external_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
18
20
|
},
|
|
19
21
|
activity_summaries: {
|
|
20
|
-
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence.
|
|
21
|
-
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
22
|
+
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence. Running form metrics (vertical oscillation, ground contact time, flight time, stride length, vertical ratio, cadence) are not columns here; use the run_dynamics dataset for those. metrics_json is the source-specific provider payload and its keys vary by provider: single Garmin activities carry flat keys (for example avgVerticalOscillation), while multisport legs nest the same values under summaryDTO.*. Never treat an absent key as a zero value.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
|
|
23
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true }), column('metrics_json', 'JSON', { description: 'Source-specific provider payload; keys vary by provider and are not a stable schema. Flat keys for single activities; multisport legs nest values under summaryDTO.*. Use run_dynamics for normalized running form metrics.' })],
|
|
22
24
|
},
|
|
23
25
|
activity_intervals: {
|
|
24
26
|
name: 'activity_intervals', relation: 'activity_interval_facts', description: 'Structured intervals attached to an activity source.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider'], permittedGroupings: ['provider', 'sport', 'label'],
|
|
@@ -54,7 +56,7 @@ export const DATASET_CATALOG = {
|
|
|
54
56
|
},
|
|
55
57
|
health_sessions: {
|
|
56
58
|
name: 'health_sessions', relation: 'health_sessions', description: 'Garmin sleep, nap, daily-event, and Body Battery event records with their source payload retained.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'source_type', 'source_remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'session_type', 'source_type'],
|
|
57
|
-
columns: [column('session_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('session_type', 'VARCHAR', { filterable: true, groupable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('ended_at', 'TIMESTAMPTZ', { filterable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('payload_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
59
|
+
columns: [column('session_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('session_type', 'VARCHAR', { filterable: true, groupable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('ended_at', 'TIMESTAMPTZ', { filterable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
58
60
|
},
|
|
59
61
|
power_bests: {
|
|
60
62
|
name: 'power_bests', relation: 'power_best_facts', description: 'Power-duration bests derived from Garmin FIT streams; they are labelled derived while retaining the Garmin activity provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'duration_s', 'source_type'],
|
|
@@ -64,19 +66,23 @@ export const DATASET_CATALOG = {
|
|
|
64
66
|
name: 'activity_decoupling', relation: 'activity_decoupling_facts', description: 'Persisted per-activity aerobic decoupling (Pa:Hr, pct) and grade-adjusted pace (s/km) with provenance. source_type provider means the provider payload supplied the value (authoritative); derived values are tagged locally. Values never overwrite provider output. Not a physiological performance model.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'metric', 'source_type'],
|
|
65
67
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('metric', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
66
68
|
},
|
|
69
|
+
run_dynamics: {
|
|
70
|
+
name: 'run_dynamics', relation: 'run_dynamics', description: 'Provider-normalized running form metrics per activity source: vertical oscillation (cm), ground contact time (ms), flight time (ms), stride length (cm), vertical ratio (%), and running cadence (spm). Only activities with at least one reported value have a row, and a null column means the provider did not report that metric; never treat null as zero or derive it from other metrics. Values come from the provider payload (flat keys for single activities, summaryDTO for multisport legs) and never overwrite provider output. Rows are per provider source and carry activity_id; when an activity is linked across providers, filter provider or group by activity_id before averaging.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source_type'],
|
|
71
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('name', 'VARCHAR', { filterable: true }), column('vertical_oscillation_cm', 'DOUBLE', { unit: 'cm', metric: true }), column('ground_contact_time_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('flight_time_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('stride_length_cm', 'DOUBLE', { unit: 'cm', metric: true }), column('vertical_ratio_pct', 'DOUBLE', { unit: '%', metric: true }), column('cadence_spm', 'DOUBLE', { unit: 'spm', metric: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
72
|
+
},
|
|
67
73
|
nutrition_days: {
|
|
68
|
-
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
69
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
74
|
+
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log. metrics_json is the source-specific provider payload; its keys vary by provider and are not a stable schema.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
75
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('metrics_json', 'JSON', { description: 'Source-specific provider payload; keys vary by provider and are not a stable schema.' }), column('raw_object_hash', 'VARCHAR')],
|
|
70
76
|
},
|
|
71
77
|
nutrition_items: {
|
|
72
78
|
name: 'nutrition_items', relation: 'nutrition_items', description: 'Meal and food-item detail retained from the provider.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'remote_item_id', 'raw_object_hash'], permittedGroupings: ['provider', 'meal', 'food_name'],
|
|
73
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_item_id', 'VARCHAR', { filterable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('meal', 'VARCHAR', { filterable: true, groupable: true }), column('consumed_at', 'TIMESTAMPTZ', { filterable: true }), column('food_name', 'VARCHAR', { filterable: true, groupable: true }), column('quantity', 'DOUBLE', { metric: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
79
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_item_id', 'VARCHAR', { filterable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('meal', 'VARCHAR', { filterable: true, groupable: true }), column('consumed_at', 'TIMESTAMPTZ', { filterable: true }), column('food_name', 'VARCHAR', { filterable: true, groupable: true }), column('quantity', 'DOUBLE', { metric: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
74
80
|
},
|
|
75
81
|
events: domain('events', 'event_id', 'Planned and completed calendar events.', 'occurred_on'),
|
|
76
82
|
workouts: domain('workouts', 'workout_id', 'Provider workouts and scheduled workouts.', 'occurred_on'),
|
|
77
83
|
workout_documents: {
|
|
78
84
|
name: 'workout_documents', relation: 'workout_documents', description: 'Structured workout documents.', provenanceColumns: ['provider', 'workout_id'], permittedGroupings: ['provider'],
|
|
79
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('workout_id', 'VARCHAR', { filterable: true, groupable: true }), column('workout_doc', 'JSON'), column('payload_json', 'JSON')],
|
|
85
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('workout_id', 'VARCHAR', { filterable: true, groupable: true }), column('workout_doc', 'JSON'), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION })],
|
|
80
86
|
},
|
|
81
87
|
training_plans: domain('training_plans', 'training_plan_id', 'Training plans and plan hierarchy.', 'occurred_on'),
|
|
82
88
|
routes: domain('routes', 'route_id', 'Saved routes.'),
|
|
@@ -87,7 +93,7 @@ export const DATASET_CATALOG = {
|
|
|
87
93
|
messages: domain('messages', 'message_id', 'Provider chat/messages.', 'occurred_on'),
|
|
88
94
|
source_entities: {
|
|
89
95
|
name: 'source_entities', relation: 'source_entities', description: 'All raw-normalized source entities, including fields not modelled elsewhere.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'entity_type', 'remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'entity_type'],
|
|
90
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('entity_type', 'VARCHAR', { filterable: true, groupable: true }), column('remote_id', 'VARCHAR', { filterable: true }), column('parent_remote_id', 'VARCHAR', { filterable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('source_updated_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR'), column('payload_json', 'JSON'), column('extension_json', 'JSON')],
|
|
96
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('entity_type', 'VARCHAR', { filterable: true, groupable: true }), column('remote_id', 'VARCHAR', { filterable: true }), column('parent_remote_id', 'VARCHAR', { filterable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('source_updated_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR'), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION })],
|
|
91
97
|
},
|
|
92
98
|
activity_links: {
|
|
93
99
|
name: 'activity_links', relation: 'activity_links', description: 'Auditable source-to-logical activity links and their evidence.', provenanceColumns: ['activity_source_id', 'method', 'evidence_json'], permittedGroupings: ['method'],
|
|
@@ -107,17 +113,21 @@ export const DATASET_CATALOG = {
|
|
|
107
113
|
},
|
|
108
114
|
strava_gear: {
|
|
109
115
|
name: 'strava_gear', relation: 'strava_gear', description: 'Current Strava bikes and shoes, refreshed from the authenticated athlete profile.', provenanceColumns: ['gear_id', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
110
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('gear_id', 'VARCHAR', { filterable: true, groupable: true }), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
116
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('gear_id', 'VARCHAR', { filterable: true, groupable: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
111
117
|
},
|
|
112
118
|
activity_samples: {
|
|
113
119
|
name: 'activity_samples', description: 'Registered normalized activity samples from immutable Parquet; callers cannot supply file paths.', dateColumn: 'timestamp_utc', provenanceColumns: ['provider', 'activity_source_id', 'content_hash'], permittedGroupings: ['provider', 'activity_source_id'], samplesOnly: true,
|
|
114
120
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('content_hash', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('timestamp_utc', 'TIMESTAMPTZ', { filterable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('latitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('longitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('heart_rate_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('power_w', 'DOUBLE', { unit: 'W', metric: true }), column('cadence_rpm', 'DOUBLE', { unit: 'rpm', metric: true }), column('speed_mps', 'DOUBLE', { unit: 'm/s', metric: true }), column('temperature_c', 'DOUBLE', { unit: 'C', metric: true }), column('grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_right_balance_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('right_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('extras_json', 'JSON')],
|
|
115
121
|
},
|
|
122
|
+
course_geometry: {
|
|
123
|
+
name: 'course_geometry', relation: 'course_geometry', description: 'Per-point Garmin course/route geometry (lat/lon/altitude_m/distance_m) normalized from a race event course. Resolve a courseId first with resolve_event_course, then query these rows for an elevation/height profile, gradient, or GPX-style track.', provenanceColumns: ['course_id', 'raw_object_hash'], permittedGroupings: ['course_id'],
|
|
124
|
+
columns: [column('course_id', 'VARCHAR', { filterable: true, groupable: true }), column('seq', 'INTEGER', { metric: true }), column('lat', 'DOUBLE', { unit: 'degrees', metric: true }), column('lon', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
125
|
+
},
|
|
116
126
|
};
|
|
117
127
|
function domain(name, id, description, dateColumn) {
|
|
118
128
|
return {
|
|
119
129
|
name, relation: name, description, dateColumn, provenanceColumns: ['provider', id, 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
120
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column(id, 'VARCHAR', { filterable: true, groupable: true }), ...(dateColumn ? [column(dateColumn, 'DATE', { filterable: true, groupable: true })] : []), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
130
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column(id, 'VARCHAR', { filterable: true, groupable: true }), ...(dateColumn ? [column(dateColumn, 'DATE', { filterable: true, groupable: true })] : []), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
121
131
|
};
|
|
122
132
|
}
|
|
123
133
|
export function getDataset(name) {
|
|
@@ -14,7 +14,7 @@ export declare class ReadOnlyRepository {
|
|
|
14
14
|
progress(): Promise<SyncProgressSnapshot>;
|
|
15
15
|
coverage(): Promise<Record<string, unknown>>;
|
|
16
16
|
activity(activityId: string): Promise<Record<string, unknown> | null>;
|
|
17
|
-
summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
|
|
17
|
+
summary(startDate: string, endDate: string, detail?: 'summary' | 'columns'): Promise<Record<string, unknown>>;
|
|
18
18
|
catalog(): DatasetDefinition[];
|
|
19
19
|
}
|
|
20
20
|
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
@@ -172,12 +172,39 @@ export class ReadOnlyRepository {
|
|
|
172
172
|
return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), runningSidecars);
|
|
173
173
|
}
|
|
174
174
|
async coverage() {
|
|
175
|
+
// One UNION keeps the per-relation min/max/count in a single metadata
|
|
176
|
+
// query. Every cataloged dataset with a usable date axis gets a row;
|
|
177
|
+
// segments and course_geometry are dictionaries without a data date, so
|
|
178
|
+
// they report a count and null dates rather than inventing a timeline from
|
|
179
|
+
// ingestion timestamps.
|
|
175
180
|
const rows = await this.rows(`
|
|
176
181
|
SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
|
|
182
|
+
UNION ALL SELECT 'canonical_activities', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM canonical_activity_training
|
|
183
|
+
UNION ALL SELECT 'canonical_activity_facts', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM canonical_activity_facts
|
|
184
|
+
UNION ALL SELECT 'activity_summaries', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_summary_facts
|
|
185
|
+
UNION ALL SELECT 'activity_intervals', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_interval_facts
|
|
186
|
+
UNION ALL SELECT 'swim_lengths', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM swim_length_facts
|
|
187
|
+
UNION ALL SELECT 'swim_sets', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM swim_set_facts
|
|
188
|
+
UNION ALL SELECT 'activity_quality_flags', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_quality_flag_facts
|
|
189
|
+
UNION ALL SELECT 'power_bests', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM power_best_facts
|
|
190
|
+
UNION ALL SELECT 'activity_decoupling', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_decoupling_facts
|
|
191
|
+
UNION ALL SELECT 'run_dynamics', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM run_dynamics_facts
|
|
177
192
|
UNION ALL SELECT 'daily_metrics', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_metrics
|
|
193
|
+
UNION ALL SELECT 'daily_health', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_health
|
|
178
194
|
UNION ALL SELECT 'training_metric_observations', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM training_metric_observations
|
|
195
|
+
UNION ALL SELECT 'wellness_samples', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM wellness_samples
|
|
196
|
+
UNION ALL SELECT 'health_sessions', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM health_sessions
|
|
179
197
|
UNION ALL SELECT 'nutrition_days', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_days
|
|
198
|
+
UNION ALL SELECT 'nutrition_items', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_items
|
|
199
|
+
UNION ALL SELECT 'events', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM events
|
|
200
|
+
UNION ALL SELECT 'workouts', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM workouts
|
|
201
|
+
UNION ALL SELECT 'training_plans', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM training_plans
|
|
202
|
+
UNION ALL SELECT 'messages', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM messages
|
|
180
203
|
UNION ALL SELECT 'source_entities', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM source_entities
|
|
204
|
+
UNION ALL SELECT 'activity_segments', min(cast(started_at AS DATE)), max(cast(started_at AS DATE)), count(*)::INTEGER FROM activity_segments
|
|
205
|
+
UNION ALL SELECT 'segment_effort_history', min(cast(started_at AS DATE)), max(cast(started_at AS DATE)), count(*)::INTEGER FROM segment_effort_history
|
|
206
|
+
UNION ALL SELECT 'segments', CAST(NULL AS DATE), CAST(NULL AS DATE), count(*)::INTEGER FROM strava_segments
|
|
207
|
+
UNION ALL SELECT 'course_geometry', CAST(NULL AS DATE), CAST(NULL AS DATE), count(*)::INTEGER FROM course_geometry
|
|
181
208
|
`);
|
|
182
209
|
return { coverage: rows, providers: await this.rows(`SELECT provider, count(*)::INTEGER AS activity_sources FROM activity_sources GROUP BY provider ORDER BY provider`) };
|
|
183
210
|
}
|
|
@@ -190,10 +217,12 @@ export class ReadOnlyRepository {
|
|
|
190
217
|
const intervals = await this.rows(`SELECT * FROM activity_interval_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider, start_s`, { activityId });
|
|
191
218
|
return { activity: activity[0], sources, summaries, intervals };
|
|
192
219
|
}
|
|
193
|
-
async summary(startDate, endDate) {
|
|
220
|
+
async summary(startDate, endDate, detail = 'summary') {
|
|
194
221
|
const training = await this.rows(`SELECT cast(started_at_utc AS DATE) AS date, count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s, sum(training_load) AS training_load FROM canonical_activity_training WHERE cast(started_at_utc AS DATE) BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) GROUP BY 1 ORDER BY 1`, { startDate, endDate });
|
|
195
222
|
const health = await this.rows(`SELECT * FROM daily_health WHERE metric_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY metric_date, provider`, { startDate, endDate });
|
|
196
|
-
const nutrition =
|
|
223
|
+
const nutrition = detail === 'columns'
|
|
224
|
+
? await this.rows(`SELECT nutrition_date, provider, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY nutrition_date, provider`, { startDate, endDate })
|
|
225
|
+
: await this.rows(`SELECT count(*)::INTEGER AS days_logged, min(nutrition_date) AS first_date, max(nutrition_date) AS last_date, sum(energy_kcal) AS energy_kcal, sum(carbohydrates_g) AS carbohydrates_g, sum(protein_g) AS protein_g, sum(fat_g) AS fat_g, sum(hydration_ml) AS hydration_ml FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE)`, { startDate, endDate });
|
|
197
226
|
return { training, health, nutrition };
|
|
198
227
|
}
|
|
199
228
|
catalog() {
|
|
@@ -44,6 +44,26 @@ function normalizeRelation(name) {
|
|
|
44
44
|
const relation = name.trim().replace(/\s+(?:as\s+)?(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*$/i, '');
|
|
45
45
|
return relation.replaceAll('"', '').split('.').at(-1).toLowerCase();
|
|
46
46
|
}
|
|
47
|
+
// Physical base tables that are reachable only through cataloged views
|
|
48
|
+
// (e.g. the events/workouts/routes/… domain projections over domain_entities).
|
|
49
|
+
// They must be accepted when DuckDB resolves a cataloged view down to its base
|
|
50
|
+
// table after tableNames(), but must never be writable directly by name.
|
|
51
|
+
const INDIRECT_BASE_TABLES = new Set(['domain_entities']);
|
|
52
|
+
// dataset->named tool hints for the common dead-end relations, so a rejected
|
|
53
|
+
// query tells the agent where to go instead of just that it was denied.
|
|
54
|
+
const RELATION_HINTS = {
|
|
55
|
+
events: 'events is a domain projection; use aggregate_data on events or resolve_event_course(eventId) / search_context to read events and races.',
|
|
56
|
+
workouts: 'workouts is a domain projection; use aggregate_data on workouts or search_context to read workouts.',
|
|
57
|
+
workout_documents: 'workout_documents is a domain projection; use aggregate_data on workout_documents or search_context to read workout documents.',
|
|
58
|
+
training_plans: 'training_plans is a domain projection; use aggregate_data on training_plans or search_context to read training plans.',
|
|
59
|
+
routes: 'routes is a domain projection; use aggregate_data on routes or search_context to read routes.',
|
|
60
|
+
gear: 'gear is a domain projection; use aggregate_data on gear or search_context to read saved gear.',
|
|
61
|
+
devices: 'devices is a domain projection; use aggregate_data on devices or search_context to read devices.',
|
|
62
|
+
goals: 'goals is a domain projection; use aggregate_data on goals or search_context to read goals and challenges.',
|
|
63
|
+
achievements: 'achievements is a domain projection; use aggregate_data on achievements or search_context to read badges and personal records.',
|
|
64
|
+
messages: 'messages is a domain projection; use aggregate_data on messages or search_context to read provider messages.',
|
|
65
|
+
course_geometry: 'course_geometry is the per-point course track (lat/lon/altitude_m/distance_m); resolve a race course first with resolve_event_course, then query it for elevation/height profiles.',
|
|
66
|
+
};
|
|
47
67
|
export async function queryReadOnlyData(repository, request) {
|
|
48
68
|
const sql = safeSql(request.sql);
|
|
49
69
|
let relations;
|
|
@@ -53,16 +73,28 @@ export async function queryReadOnlyData(repository, request) {
|
|
|
53
73
|
catch (error) {
|
|
54
74
|
throw new QueryValidationError(`SQL could not be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
75
|
}
|
|
56
|
-
|
|
76
|
+
// The catalog relation names form the strict allow-list for what a query may
|
|
77
|
+
// name directly (Check B below). Base tables reached only through cataloged
|
|
78
|
+
// views are additionally permitted after DuckDB resolves views for Check A,
|
|
79
|
+
// but they are never valid relation names in the SQL text.
|
|
80
|
+
const allowedNamed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
|
|
81
|
+
const allowedForExpansion = new Set([...allowedNamed, ...INDIRECT_BASE_TABLES]);
|
|
82
|
+
const rejected = [];
|
|
57
83
|
for (const relation of relations) {
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const normalized = normalizeRelation(relation);
|
|
85
|
+
if (!allowedForExpansion.has(normalized))
|
|
86
|
+
rejected.push(relation);
|
|
87
|
+
}
|
|
88
|
+
if (rejected.length > 0) {
|
|
89
|
+
const name = normalizeRelation(rejected[0]);
|
|
90
|
+
throw new QueryValidationError(`Relation ${rejected[0]} is not in the read-only catalog.${RELATION_HINTS[name] ? ` ${RELATION_HINTS[name]}` : ''}`);
|
|
60
91
|
}
|
|
61
92
|
const ctes = new Set([...sql.matchAll(/(?:\bwith|,)\s*([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(/gi)].map((match) => match[1].toLowerCase()));
|
|
62
93
|
for (const match of sql.matchAll(/\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_.]*)/gi)) {
|
|
63
94
|
const relation = normalizeRelation(match[1]);
|
|
64
|
-
if (!
|
|
65
|
-
throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog
|
|
95
|
+
if (!allowedNamed.has(relation) && !ctes.has(relation)) {
|
|
96
|
+
throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog.${RELATION_HINTS[relation] ? ` ${RELATION_HINTS[relation]}` : ''}`);
|
|
97
|
+
}
|
|
66
98
|
}
|
|
67
99
|
const values = request.values ?? {};
|
|
68
100
|
if ('__catence_page_size' in values || '__catence_offset' in values)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* One athlete-authored markdown document per athlete store. Both the athlete
|
|
4
|
+
* (Console/UI) and the agent (MCP tools) may write it; every write is guarded
|
|
5
|
+
* by an optimistic compare-and-swap on the sha256 of the current content so
|
|
6
|
+
* neither side silently clobbers the other. Prior content is snapshotted into
|
|
7
|
+
* a small rolling revision history.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ATHLETE_FILE_MAX_CHARACTERS = 32768;
|
|
10
|
+
export declare const ATHLETE_FILE_TEMPLATE = "# Athlete file\n\nThis file is shared by the athlete and the Catence agent. Keep durable facts here:\nprofile, goals, constraints, injuries, preferences, and standing notes.\nDo not store credentials, provider configuration, or transient daily status.\n\n## Profile\n\n## Goals\n\n## Constraints and injuries\n\n## Preferences\n\n## Notes\n";
|
|
11
|
+
export type AthleteFileSnapshot = {
|
|
12
|
+
exists: boolean;
|
|
13
|
+
/** Stored content, or the starter template while the file does not exist yet. */
|
|
14
|
+
content: string;
|
|
15
|
+
/** sha256 of the stored content; null while the file does not exist. */
|
|
16
|
+
hash: string | null;
|
|
17
|
+
updatedAt: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type AthleteFileOperation = 'replace' | 'append' | 'replace_section';
|
|
20
|
+
export type AthleteFileUpdate = {
|
|
21
|
+
operation: AthleteFileOperation;
|
|
22
|
+
/** Required when operation is replace_section. */
|
|
23
|
+
section?: string;
|
|
24
|
+
content: string;
|
|
25
|
+
/** Hash the caller read before this update; null when creating the file. */
|
|
26
|
+
expectedHash: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type AthleteFileRevision = {
|
|
29
|
+
revisionId: string;
|
|
30
|
+
hash: string;
|
|
31
|
+
savedAt: string;
|
|
32
|
+
};
|
|
33
|
+
export declare class AthleteFileConflictError extends Error {
|
|
34
|
+
readonly currentHash: string;
|
|
35
|
+
readonly currentContent: string;
|
|
36
|
+
constructor(currentHash: string, currentContent: string);
|
|
37
|
+
}
|
|
38
|
+
export declare class AthleteFileTooLargeError extends Error {
|
|
39
|
+
readonly characters: number;
|
|
40
|
+
constructor(characters: number);
|
|
41
|
+
}
|
|
42
|
+
export declare class AthleteFileValidationError extends Error {
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
|
45
|
+
export declare function hashAthleteFileContent(content: string): string;
|
|
46
|
+
export declare function readAthleteFile(paths: CatencePaths): Promise<AthleteFileSnapshot>;
|
|
47
|
+
export declare function updateAthleteFile(paths: CatencePaths, update: AthleteFileUpdate): Promise<AthleteFileSnapshot>;
|
|
48
|
+
export declare function listAthleteFileRevisions(paths: CatencePaths): Promise<AthleteFileRevision[]>;
|
|
49
|
+
export declare function readAthleteFileRevision(paths: CatencePaths, revisionId: string): Promise<string | null>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* One athlete-authored markdown document per athlete store. Both the athlete
|
|
6
|
+
* (Console/UI) and the agent (MCP tools) may write it; every write is guarded
|
|
7
|
+
* by an optimistic compare-and-swap on the sha256 of the current content so
|
|
8
|
+
* neither side silently clobbers the other. Prior content is snapshotted into
|
|
9
|
+
* a small rolling revision history.
|
|
10
|
+
*/
|
|
11
|
+
export const ATHLETE_FILE_MAX_CHARACTERS = 32_768;
|
|
12
|
+
const ATHLETE_FILE_HISTORY_LIMIT = 10;
|
|
13
|
+
const REVISION_PATTERN = /^(\d{1,20})-([0-9a-f]{8})\.md$/;
|
|
14
|
+
export const ATHLETE_FILE_TEMPLATE = `# Athlete file
|
|
15
|
+
|
|
16
|
+
This file is shared by the athlete and the Catence agent. Keep durable facts here:
|
|
17
|
+
profile, goals, constraints, injuries, preferences, and standing notes.
|
|
18
|
+
Do not store credentials, provider configuration, or transient daily status.
|
|
19
|
+
|
|
20
|
+
## Profile
|
|
21
|
+
|
|
22
|
+
## Goals
|
|
23
|
+
|
|
24
|
+
## Constraints and injuries
|
|
25
|
+
|
|
26
|
+
## Preferences
|
|
27
|
+
|
|
28
|
+
## Notes
|
|
29
|
+
`;
|
|
30
|
+
export class AthleteFileConflictError extends Error {
|
|
31
|
+
currentHash;
|
|
32
|
+
currentContent;
|
|
33
|
+
constructor(currentHash, currentContent) {
|
|
34
|
+
super('The athlete file changed since it was read. Read it again before writing.');
|
|
35
|
+
this.currentHash = currentHash;
|
|
36
|
+
this.currentContent = currentContent;
|
|
37
|
+
this.name = 'AthleteFileConflictError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class AthleteFileTooLargeError extends Error {
|
|
41
|
+
characters;
|
|
42
|
+
constructor(characters) {
|
|
43
|
+
super(`Athlete file would be ${characters} characters; the limit is ${ATHLETE_FILE_MAX_CHARACTERS}. Shorten the write or split it into sections.`);
|
|
44
|
+
this.characters = characters;
|
|
45
|
+
this.name = 'AthleteFileTooLargeError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class AthleteFileValidationError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'AthleteFileValidationError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function hashAthleteFileContent(content) {
|
|
55
|
+
return createHash('sha256').update(content).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
function normalizeContent(content) {
|
|
58
|
+
return `${content.replace(/\r\n?/g, '\n').replace(/\n+$/, '')}\n`;
|
|
59
|
+
}
|
|
60
|
+
function escapeRegExp(value) {
|
|
61
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
}
|
|
63
|
+
function applySection(content, section, replacement) {
|
|
64
|
+
const name = section.trim();
|
|
65
|
+
if (!name)
|
|
66
|
+
throw new AthleteFileValidationError('replace_section requires a non-empty section name.');
|
|
67
|
+
const body = normalizeContent(replacement).trim();
|
|
68
|
+
const match = new RegExp(`^##\\s+${escapeRegExp(name)}\\s*$`, 'im').exec(content);
|
|
69
|
+
if (!match) {
|
|
70
|
+
const base = normalizeContent(content).trimEnd();
|
|
71
|
+
return body ? `${base}\n\n## ${name}\n\n${body}\n` : `${base}\n\n## ${name}\n`;
|
|
72
|
+
}
|
|
73
|
+
const bodyStart = content.indexOf('\n', match.index) + 1;
|
|
74
|
+
const rest = content.slice(bodyStart);
|
|
75
|
+
const nextHeading = /^##\s+/m.exec(rest);
|
|
76
|
+
const bodyEnd = nextHeading ? bodyStart + nextHeading.index : content.length;
|
|
77
|
+
const before = content.slice(0, bodyStart);
|
|
78
|
+
const after = content.slice(bodyEnd).replace(/^\n+/, '');
|
|
79
|
+
const combined = body ? `${before}\n${body}\n\n${after}` : `${before}\n${after}`;
|
|
80
|
+
return normalizeContent(combined).replace(/\n{3,}/g, '\n\n');
|
|
81
|
+
}
|
|
82
|
+
export async function readAthleteFile(paths) {
|
|
83
|
+
let stored;
|
|
84
|
+
try {
|
|
85
|
+
stored = await readFile(paths.athleteFile, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT')
|
|
89
|
+
throw error;
|
|
90
|
+
stored = null;
|
|
91
|
+
}
|
|
92
|
+
if (stored === null)
|
|
93
|
+
return { exists: false, content: ATHLETE_FILE_TEMPLATE, hash: null, updatedAt: null };
|
|
94
|
+
const content = normalizeContent(stored);
|
|
95
|
+
const stats = await stat(paths.athleteFile);
|
|
96
|
+
return { exists: true, content, hash: hashAthleteFileContent(content), updatedAt: stats.mtime.toISOString() };
|
|
97
|
+
}
|
|
98
|
+
async function writeAtomically(filePath, content) {
|
|
99
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
100
|
+
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
101
|
+
try {
|
|
102
|
+
await rename(temporaryPath, filePath);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function listRevisionNames(paths) {
|
|
110
|
+
const names = await readdir(paths.athleteFileHistory).catch((error) => {
|
|
111
|
+
if (error.code === 'ENOENT')
|
|
112
|
+
return [];
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
return names
|
|
116
|
+
.map((name) => ({ name, match: REVISION_PATTERN.exec(name) }))
|
|
117
|
+
.filter((entry) => entry.match !== null)
|
|
118
|
+
.map((entry) => ({ name: entry.name, epoch: Number(entry.match[1]) }))
|
|
119
|
+
.sort((left, right) => right.epoch - left.epoch);
|
|
120
|
+
}
|
|
121
|
+
async function snapshotRevision(paths, content) {
|
|
122
|
+
await mkdir(paths.athleteFileHistory, { recursive: true, mode: 0o700 });
|
|
123
|
+
const name = `${Date.now()}-${hashAthleteFileContent(content).slice(0, 8)}.md`;
|
|
124
|
+
await writeFile(path.join(paths.athleteFileHistory, name), content, { encoding: 'utf8', mode: 0o600 });
|
|
125
|
+
const revisions = await listRevisionNames(paths);
|
|
126
|
+
for (const stale of revisions.slice(ATHLETE_FILE_HISTORY_LIMIT)) {
|
|
127
|
+
await unlink(path.join(paths.athleteFileHistory, stale.name)).catch(() => undefined);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function updateAthleteFile(paths, update) {
|
|
131
|
+
if (!['replace', 'append', 'replace_section'].includes(update.operation)) {
|
|
132
|
+
throw new AthleteFileValidationError(`Unknown athlete-file operation: ${String(update.operation)}.`);
|
|
133
|
+
}
|
|
134
|
+
const sparse = update.content.trim();
|
|
135
|
+
if (update.operation !== 'replace_section' && !sparse) {
|
|
136
|
+
throw new AthleteFileValidationError(`${update.operation} requires non-empty content.`);
|
|
137
|
+
}
|
|
138
|
+
const current = await readAthleteFile(paths);
|
|
139
|
+
if (current.hash !== update.expectedHash) {
|
|
140
|
+
throw new AthleteFileConflictError(current.hash ?? 'absent', current.content);
|
|
141
|
+
}
|
|
142
|
+
let next;
|
|
143
|
+
if (update.operation === 'replace')
|
|
144
|
+
next = normalizeContent(update.content);
|
|
145
|
+
else if (update.operation === 'append')
|
|
146
|
+
next = normalizeContent(`${current.content.trimEnd()}\n\n${update.content.trim()}`);
|
|
147
|
+
else
|
|
148
|
+
next = applySection(current.content, update.section ?? '', update.content);
|
|
149
|
+
next = normalizeContent(next);
|
|
150
|
+
if (next.length > ATHLETE_FILE_MAX_CHARACTERS)
|
|
151
|
+
throw new AthleteFileTooLargeError(next.length);
|
|
152
|
+
if (current.exists)
|
|
153
|
+
await snapshotRevision(paths, current.content);
|
|
154
|
+
await writeAtomically(paths.athleteFile, next);
|
|
155
|
+
return readAthleteFile(paths);
|
|
156
|
+
}
|
|
157
|
+
export async function listAthleteFileRevisions(paths) {
|
|
158
|
+
const revisions = await listRevisionNames(paths);
|
|
159
|
+
return revisions.map((revision) => ({
|
|
160
|
+
revisionId: revision.name,
|
|
161
|
+
hash: REVISION_PATTERN.exec(revision.name)[2],
|
|
162
|
+
savedAt: new Date(revision.epoch).toISOString(),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
export async function readAthleteFileRevision(paths, revisionId) {
|
|
166
|
+
if (!REVISION_PATTERN.test(revisionId))
|
|
167
|
+
throw new AthleteFileValidationError('Unknown athlete-file revision identifier.');
|
|
168
|
+
try {
|
|
169
|
+
return normalizeContent(await readFile(path.join(paths.athleteFileHistory, revisionId), 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT')
|
|
173
|
+
return null;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=athlete-file.js.map
|
|
@@ -14,6 +14,8 @@ export function resolvePaths(root = process.env.CATENCE_DATA_DIR ?? '.catence')
|
|
|
14
14
|
config: path.join(absoluteRoot, 'config.json'),
|
|
15
15
|
secrets: path.join(absoluteRoot, 'secrets'),
|
|
16
16
|
lock: path.join(absoluteRoot, '.catence-write.lock'),
|
|
17
|
+
athleteFile: path.join(absoluteRoot, 'athlete.md'),
|
|
18
|
+
athleteFileHistory: path.join(absoluteRoot, 'athlete-revisions'),
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
export async function ensurePaths(paths) {
|
|
@@ -37,4 +37,11 @@ export declare function resolveExtractionErrors(paths: CatencePaths, opts?: {
|
|
|
37
37
|
* previously mis-parsed nutrition_days/nutrition_items from stored payloads.
|
|
38
38
|
*/
|
|
39
39
|
export declare function reimportNutrition(paths: CatencePaths): Promise<Record<string, unknown>>;
|
|
40
|
+
/**
|
|
41
|
+
* Re-run normalization over already-captured Garmin weigh-in entities without
|
|
42
|
+
* contacting any provider. Stores synced before beta.5 staged weigh_ins
|
|
43
|
+
* payloads but never projected them to daily_metrics, so this backfills
|
|
44
|
+
* weight_kg from stored payloads. Idempotent upserts make it safe to re-run.
|
|
45
|
+
*/
|
|
46
|
+
export declare function reimportWeight(paths: CatencePaths): Promise<Record<string, unknown>>;
|
|
40
47
|
export {};
|