catence 0.2.1-beta.2 → 0.2.1-beta.20

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 (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +748 -15
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +12 -1
  51. package/dist/interfaces/mcp/server.js +100 -19
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. 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 shared Catence agent can serve several isolated athlete stores. Every personal-data MCP call names an `athleteId`; Catence never silently combines athletes.
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
 
@@ -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. This is intentional: a shared agent may access the stores you configured, but no tool implicitly aggregates or crosses between athletes.
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 dashboard APIs:
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 beyond the optional bearer-token gate: `catence-data mcp enable` generates a token at `<home>/mcp-token` and every endpoint except the health probes then requires `Authorization: Bearer <token>`; `mcp status`/`mcp token`/`mcp disable` manage it, and the Console Settings expose the same controls to admins.
124
124
 
125
125
  ## Configuration
126
126
 
@@ -28,6 +28,7 @@
28
28
  "toolRounds": 8,
29
29
  "toolResultCharacters": 24000
30
30
  },
31
+ "mcpServers": {},
31
32
  "profiles": {
32
33
  "openai": {
33
34
  "label": "OpenAI",
@@ -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.1-beta.2";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.20";
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.1-beta.2';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.20';
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' | 'course_geometry';
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. NULL avg_power here does not mean no power; running/cycling power is in power_best_facts (FIT-derived). Use power_coverage_report / read_series.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
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,7 +113,7 @@ 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,
@@ -121,7 +127,7 @@ export const DATASET_CATALOG = {
121
127
  function domain(name, id, description, dateColumn) {
122
128
  return {
123
129
  name, relation: name, description, dateColumn, provenanceColumns: ['provider', id, 'raw_object_hash'], permittedGroupings: ['provider'],
124
- 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')],
125
131
  };
126
132
  }
127
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 = await this.rows(`SELECT * FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY nutrition_date, provider`, { startDate, endDate });
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() {
@@ -128,12 +128,30 @@ export async function buildRetrievalIndex(database) {
128
128
  await database.run('BEGIN TRANSACTION');
129
129
  try {
130
130
  await database.run('DELETE FROM retrieval_documents');
131
- for (const document of deduplicated.values()) {
132
- const id = documentId(document);
133
- await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
134
- VALUES ($id, $entityType, $entityId, $activitySourceId, $provider, $occurredOn, $text, $metadata, $hash)`, {
135
- id, entityType: document.entityType, entityId: document.entityId, activitySourceId: document.activitySourceId ?? null, provider: document.provider ?? null, occurredOn: document.occurredOn || null, text: document.text, metadata: JSON.stringify(document.metadata), hash: id,
131
+ // Multi-row INSERT batches: one statement per 500 documents instead of
132
+ // one round trip per document keeps a full 125k-document rebuild from
133
+ // being dominated by per-statement overhead.
134
+ const BATCH_SIZE = 500;
135
+ const allDocuments = [...deduplicated.values()];
136
+ for (let offset = 0; offset < allDocuments.length; offset += BATCH_SIZE) {
137
+ const batch = allDocuments.slice(offset, offset + BATCH_SIZE);
138
+ const values = {};
139
+ const tuples = batch.map((document, index) => {
140
+ const id = documentId(document);
141
+ const prefix = `d${index}`;
142
+ values[`${prefix}id`] = id;
143
+ values[`${prefix}entityType`] = document.entityType;
144
+ values[`${prefix}entityId`] = document.entityId;
145
+ values[`${prefix}activitySourceId`] = document.activitySourceId ?? null;
146
+ values[`${prefix}provider`] = document.provider ?? null;
147
+ values[`${prefix}occurredOn`] = document.occurredOn || null;
148
+ values[`${prefix}text`] = document.text;
149
+ values[`${prefix}metadata`] = JSON.stringify(document.metadata);
150
+ values[`${prefix}hash`] = id;
151
+ return `($${prefix}id, $${prefix}entityType, $${prefix}entityId, $${prefix}activitySourceId, $${prefix}provider, $${prefix}occurredOn, $${prefix}text, $${prefix}metadata, $${prefix}hash)`;
136
152
  });
153
+ await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
154
+ VALUES ${tuples.join(', ')}`, values);
137
155
  }
138
156
  await database.run(`INSERT INTO retrieval_index_state (index_name, status, mode, source_watermark, built_at, detail_json)
139
157
  VALUES ('context', 'ready', $mode, $watermark, now(), $detail)
@@ -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) {
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import type { CatalogPaths } from './catalog.js';
3
+ declare const accountSchema: z.ZodObject<{
4
+ username: z.ZodString;
5
+ passwordHash: z.ZodString;
6
+ role: z.ZodEnum<{
7
+ admin: "admin";
8
+ member: "member";
9
+ }>;
10
+ athletes: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>;
11
+ createdAt: z.ZodString;
12
+ }, z.core.$strict>;
13
+ export type ConsoleAccount = z.infer<typeof accountSchema>;
14
+ export type ConsoleAccountRole = ConsoleAccount['role'];
15
+ export type ConsoleAccountScope = {
16
+ role: ConsoleAccountRole;
17
+ athleteIds: string[] | 'all';
18
+ };
19
+ export declare function consoleAccountsPath(paths: CatalogPaths): string;
20
+ /**
21
+ * Read and strictly validate the Console accounts store. A missing file is an
22
+ * empty store; any other read or validation problem throws so callers fail
23
+ * closed instead of silently dropping accounts.
24
+ */
25
+ export declare function loadConsoleAccounts(paths: CatalogPaths): ConsoleAccount[];
26
+ /**
27
+ * The athlete scope one account may act for, or null when the username is not
28
+ * stored. An admin holding a grant list instead of "all" keeps the list (fail
29
+ * safe); any account holding "all" sees every athlete.
30
+ */
31
+ export declare function resolveAccountScope(paths: CatalogPaths, username: string): ConsoleAccountScope | null;
32
+ /** True when the scope grants this athlete: "all", or the id is listed. */
33
+ export declare function scopeAllowsAthlete(athleteIds: string[] | 'all', athleteId: string): boolean;
34
+ /**
35
+ * Verify a Console account password against its stored bcrypt hash (the
36
+ * OAuth consent login). Fail closed: a missing account, a malformed store, or
37
+ * a malformed hash all return false. bcryptjs is imported lazily so the stdio
38
+ * path never loads it. The password is verified, never logged or stored.
39
+ */
40
+ export declare function verifyAccountPassword(paths: CatalogPaths, username: string, password: string): Promise<boolean>;
41
+ /**
42
+ * Narrow an athlete roster to a scope, mirroring the Console's filter_roster:
43
+ * "all" returns the roster unchanged; otherwise only granted athletes remain
44
+ * and the default moves to the first visible athlete (or "" when none).
45
+ */
46
+ export declare function filterRosterToScope<Athlete extends {
47
+ id: string;
48
+ }>(athletes: Athlete[], defaultAthleteId: string, athleteIds: string[] | 'all'): {
49
+ athletes: Athlete[];
50
+ defaultAthleteId: string;
51
+ };
52
+ export {};