catence 0.2.0-beta.9 → 0.2.1-beta.1

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 (72) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +12 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/sql-guard.js +37 -5
  21. package/dist/core/query/swimming.js +2 -2
  22. package/dist/core/runtime/configuration.d.ts +3 -1
  23. package/dist/core/runtime/configuration.js +5 -1
  24. package/dist/core/runtime/opencode-go.d.ts +58 -0
  25. package/dist/core/runtime/opencode-go.js +246 -0
  26. package/dist/core/runtime/secrets.js +51 -13
  27. package/dist/elt/application/detached-sync.d.ts +50 -0
  28. package/dist/elt/application/detached-sync.js +99 -0
  29. package/dist/elt/application/management.d.ts +15 -0
  30. package/dist/elt/application/management.js +420 -18
  31. package/dist/elt/ingestion/importer.js +1 -1
  32. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  33. package/dist/elt/ingestion/providers/intervals.js +33 -1
  34. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  35. package/dist/elt/ingestion/providers/registry.js +19 -1
  36. package/dist/elt/normalization/normalizers.js +207 -15
  37. package/dist/elt/storage/database.d.ts +21 -2
  38. package/dist/elt/storage/database.js +182 -6
  39. package/dist/elt/storage/migrations.js +145 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  41. package/dist/elt/storage/progress-sidecar.js +200 -0
  42. package/dist/interfaces/cli/commands.js +94 -1
  43. package/dist/interfaces/http/generation-status.d.ts +19 -0
  44. package/dist/interfaces/http/generation-status.js +55 -0
  45. package/dist/interfaces/http/server.d.ts +7 -1
  46. package/dist/interfaces/http/server.js +184 -1
  47. package/dist/interfaces/mcp/main.js +33 -1
  48. package/dist/interfaces/mcp/server.d.ts +2 -1
  49. package/dist/interfaces/mcp/server.js +203 -45
  50. package/dist/runtime/index.d.ts +9 -2
  51. package/dist/runtime/index.js +6 -2
  52. package/docs/architecture.md +13 -2
  53. package/docs/assets/image.png +0 -0
  54. package/docs/configuration.md +243 -0
  55. package/docs/deployment/docker.md +528 -0
  56. package/docs/deployment/local-console.md +263 -0
  57. package/docs/deployment/local-mcp.md +329 -0
  58. package/docs/development.md +337 -0
  59. package/docs/distribution.md +45 -0
  60. package/docs/llm-providers.md +24 -2
  61. package/docs/mcp-activity-retrieval.md +90 -210
  62. package/package.json +1 -1
  63. package/pyproject.toml +1 -1
  64. package/python/catence/providers/garmin/cli.py +43 -9
  65. package/python/catence/providers/garmin/progress.py +85 -0
  66. package/python/catence/providers/garmin/registry.py +2 -0
  67. package/python/catence/providers/garmin/streams.py +38 -0
  68. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  69. package/python/catence/providers/garmin/worker.py +415 -12
  70. package/scripts/discover-opencode-go.mjs +36 -144
  71. package/uv.lock +3 -3
  72. package/docs/deployment.md +0 -338
@@ -0,0 +1,121 @@
1
+ const COURSE_ID_KEYS = ['courseId', 'course_id', 'courseID'];
2
+ /** Pull a courseId from a Garmin/Intervals event/activity payload, tolerating the
3
+ * common key spellings and a nested `course.id` shape. Returns null when the
4
+ * payload carries no course reference rather than guessing. */
5
+ export function extractCourseId(payload) {
6
+ let normalized = payload;
7
+ if (typeof payload === 'string') {
8
+ try {
9
+ normalized = JSON.parse(payload);
10
+ }
11
+ catch {
12
+ normalized = {};
13
+ }
14
+ }
15
+ const record = (typeof normalized === 'object' && normalized !== null && !Array.isArray(normalized)
16
+ ? normalized
17
+ : {});
18
+ for (const key of COURSE_ID_KEYS) {
19
+ const value = record[key];
20
+ if (typeof value === 'string' && value.length > 0)
21
+ return value;
22
+ if (typeof value === 'number' && Number.isFinite(value))
23
+ return String(value);
24
+ }
25
+ const nested = record.course;
26
+ if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
27
+ const nestedId = nested.id;
28
+ if (typeof nestedId === 'string' && nestedId.length > 0)
29
+ return nestedId;
30
+ if (typeof nestedId === 'number' && Number.isFinite(nestedId))
31
+ return String(nestedId);
32
+ }
33
+ return null;
34
+ }
35
+ export class EventsService {
36
+ repository;
37
+ constructor(repository) {
38
+ this.repository = repository;
39
+ }
40
+ /** Resolve an event's referenced Garmin courseId and report whether its
41
+ * geometry has been synced. When geometry is absent, the result carries an
42
+ * explicit caveat so the agent never silently reuses a prior course profile. */
43
+ async resolveEventCourse(eventId) {
44
+ const caveats = [];
45
+ const events = await this.repository.rows(`SELECT provider, payload_json FROM source_entities
46
+ WHERE entity_type = 'event' AND remote_id = $eventId
47
+ ORDER BY provider = 'garmin' DESC, remote_id LIMIT 1`, { eventId });
48
+ if (events.length === 0) {
49
+ return {
50
+ eventId,
51
+ provider: null,
52
+ courseId: null,
53
+ courseSynced: false,
54
+ geometrySampleCount: 0,
55
+ caveats: [`No event source entity found for eventId ${eventId}.`],
56
+ };
57
+ }
58
+ const event = events[0];
59
+ const courseId = extractCourseId(event.payload_json);
60
+ if (courseId === null) {
61
+ return {
62
+ eventId,
63
+ provider: event.provider,
64
+ courseId: null,
65
+ courseSynced: false,
66
+ geometrySampleCount: 0,
67
+ caveats: [`Event ${eventId} does not reference a courseId; no course profile to resolve.`],
68
+ };
69
+ }
70
+ const synced = await this.repository.rows(`SELECT remote_id FROM source_entities
71
+ WHERE entity_type = 'course' AND remote_id = $courseId LIMIT 1`, { courseId });
72
+ const geometry = await this.repository.rows(`SELECT count(*) AS sample_count FROM course_geometry WHERE course_id = $courseId`, { courseId });
73
+ const geometrySampleCount = geometry.length > 0 ? Number(geometry[0].sample_count) : 0;
74
+ const courseSynced = synced.length > 0 && geometrySampleCount > 0;
75
+ if (!courseSynced) {
76
+ caveats.push(`Course geometry for courseId ${courseId} is not yet synced; this year's gradient is unverified until a recon run maps to that course. Do not reuse a prior course profile.`);
77
+ }
78
+ return {
79
+ eventId,
80
+ provider: event.provider,
81
+ courseId,
82
+ courseSynced,
83
+ geometrySampleCount,
84
+ caveats,
85
+ };
86
+ }
87
+ /** Compare an event's course against a past activity's course. A different
88
+ * courseId means the prior profile does not apply; an absent reference is
89
+ * reported as unknown rather than assumed equal. */
90
+ async diffCourseProfiles(eventCourseId, pastActivityId) {
91
+ const caveats = [];
92
+ let pastActivityCourseId = null;
93
+ if (pastActivityId) {
94
+ const activities = await this.repository.rows(`SELECT payload_json FROM source_entities
95
+ WHERE entity_type = 'activity' AND remote_id = $activityId LIMIT 1`, { activityId: pastActivityId });
96
+ if (activities.length > 0) {
97
+ pastActivityCourseId = extractCourseId(activities[0].payload_json);
98
+ }
99
+ else {
100
+ caveats.push(`No activity source entity found for pastActivityId ${pastActivityId}; could not resolve its course.`);
101
+ }
102
+ }
103
+ let sameCourse = null;
104
+ let differentCourse = null;
105
+ if (eventCourseId !== null && pastActivityCourseId !== null) {
106
+ sameCourse = eventCourseId === pastActivityCourseId;
107
+ differentCourse = !sameCourse;
108
+ if (differentCourse) {
109
+ caveats.push(`Event courseId ${eventCourseId} differs from past activity courseId ${pastActivityCourseId}; do not reuse the prior course profile.`);
110
+ }
111
+ }
112
+ else if (eventCourseId === null) {
113
+ caveats.push('Event courseId is unknown; a prior course profile cannot be validated against it.');
114
+ }
115
+ else {
116
+ caveats.push('Past activity courseId is unknown; a prior course profile cannot be confirmed or refuted.');
117
+ }
118
+ return { eventCourseId, pastActivityCourseId, sameCourse, differentCourse, caveats };
119
+ }
120
+ }
121
+ //# sourceMappingURL=events.js.map
@@ -26,6 +26,9 @@ export type LatestCyclingActivitiesRequest = FitnessDateRange & {
26
26
  includeMultisport?: boolean;
27
27
  limit?: number;
28
28
  };
29
+ export type ReadinessBaselineRequest = FitnessDateRange & {
30
+ sport?: string;
31
+ };
29
32
  /** Read-only, source-aware fitness projections built on normalized facts. */
30
33
  export declare class FitnessService {
31
34
  private readonly repository;
@@ -36,5 +39,6 @@ export declare class FitnessService {
36
39
  powerCoverageReport(request?: PowerCoverageReportRequest): Promise<Record<string, unknown>>;
37
40
  latestCyclingActivities(request?: LatestCyclingActivitiesRequest): Promise<Record<string, unknown>>;
38
41
  cyclingProgressReport(request?: FitnessDateRange): Promise<Record<string, unknown>>;
42
+ readinessBaseline(request?: ReadinessBaselineRequest): Promise<Record<string, unknown>>;
39
43
  }
40
44
  export {};
@@ -293,5 +293,65 @@ export class FitnessService {
293
293
  ],
294
294
  };
295
295
  }
296
+ async readinessBaseline(request = {}) {
297
+ const sport = request.sport ?? 'running';
298
+ const isRunning = /run/i.test(sport);
299
+ const values = { sport };
300
+ const metricClauses = [
301
+ `metric_name IN ('running_lactate_threshold_pace_s_per_km','running_lactate_threshold_power_w','running_lactate_threshold_hr_bpm','running_lactate_threshold_power_w_kg','race_prediction_time5_k','race_prediction_time10_k','race_prediction_time_half_marathon','race_prediction_time_marathon','training_status_fitness_trend','endurance_score_overall_score')`,
302
+ `(lower(sport) = lower($sport) OR lower(sport) = 'generic')`,
303
+ ...addDateRange('observed_at', request, values),
304
+ ];
305
+ const observations = await this.repository.rows(`
306
+ SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
307
+ source_type, source_remote_id, activity_source_id, raw_object_hash, metric_name
308
+ FROM training_metric_observations
309
+ WHERE ${metricClauses.join(' AND ')}
310
+ ORDER BY observed_at DESC, observation_id DESC
311
+ `, values);
312
+ const [vo2max, powerCurve, powerCoverage] = await Promise.all([
313
+ this.vo2MaxHistory({ ...request, sport }),
314
+ this.powerCurveTrend({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
315
+ this.powerCoverageReport({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
316
+ ]);
317
+ const byMetric = new Map();
318
+ for (const row of observations) {
319
+ const arr = byMetric.get(row.metric_name) ?? [];
320
+ arr.push(row);
321
+ byMetric.set(row.metric_name, arr);
322
+ }
323
+ const latest = (name) => byMetric.get(name)?.[0] ?? null;
324
+ const series = (name) => byMetric.get(name) ?? [];
325
+ return {
326
+ data: {
327
+ sport,
328
+ lactateThreshold: {
329
+ pace_s_per_km: latest('running_lactate_threshold_pace_s_per_km'),
330
+ power_w: latest('running_lactate_threshold_power_w'),
331
+ hr_bpm: latest('running_lactate_threshold_hr_bpm'),
332
+ power_w_kg: latest('running_lactate_threshold_power_w_kg'),
333
+ powerSeries: series('running_lactate_threshold_power_w'),
334
+ },
335
+ vo2max,
336
+ racePrediction: {
337
+ time5_k: latest('race_prediction_time5_k'),
338
+ time10_k: latest('race_prediction_time10_k'),
339
+ timeHalfMarathon: latest('race_prediction_time_half_marathon'),
340
+ timeMarathon: latest('race_prediction_time_marathon'),
341
+ },
342
+ fitnessTrend: latest('training_status_fitness_trend'),
343
+ enduranceScore: latest('endurance_score_overall_score'),
344
+ powerCurve,
345
+ powerCoverage,
346
+ },
347
+ provenance: { dataset: 'training_metric_observations', powerDataset: 'power_best_facts' },
348
+ query: request,
349
+ caveats: [
350
+ 'Readiness baseline is descriptive, not a physiological performance model; it combines lactate threshold, VO₂max, race prediction, fitness trend, and FIT-derived power.',
351
+ 'Running power uses Garmin FIT-derived power bests (power_best_facts), not sparse activity-summary average power; a NULL summary avg_power does not mean no power.',
352
+ 'Course/elevation profile for a specific race is not included here; call resolve_event_course(eventId) before reusing any prior course profile.',
353
+ ],
354
+ };
355
+ }
296
356
  }
297
357
  //# sourceMappingURL=fitness.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CatencePaths } from '../../contracts/runtime.js';
2
+ import { type SyncProgressSnapshot } from '../../contracts/progress.js';
2
3
  import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
3
4
  import { type DatasetDefinition } from './catalog.js';
4
5
  export declare class ReadOnlyRepository {
@@ -10,6 +11,7 @@ export declare class ReadOnlyRepository {
10
11
  datasetRelation(datasetName: string): Promise<string>;
11
12
  activitySampleRelation(): Promise<string>;
12
13
  status(): Promise<Record<string, unknown>>;
14
+ progress(): Promise<SyncProgressSnapshot>;
13
15
  coverage(): Promise<Record<string, unknown>>;
14
16
  activity(activityId: string): Promise<Record<string, unknown> | null>;
15
17
  summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { SYNC_STAGES } from '../../contracts/progress.js';
4
+ import { mergeProgress, readRunningProgress } from '../../elt/storage/progress-sidecar.js';
3
5
  import { sqlString } from './sql.js';
4
6
  import { DATASET_CATALOG, getDataset } from './catalog.js';
5
7
  export class ReadOnlyRepository {
@@ -137,6 +139,38 @@ export class ReadOnlyRepository {
137
139
  incrementalCursors: cursors,
138
140
  };
139
141
  }
142
+ async progress() {
143
+ // Same sequential-read discipline as status(): the shared native
144
+ // connection must not run these small reads concurrently.
145
+ const running = await this.rows(`
146
+ SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
147
+ progress.current_step,
148
+ coalesce(progress.completed_units, 0) AS completed_units,
149
+ progress.total_units,
150
+ coalesce(progress.percent, 0) AS percent,
151
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
152
+ progress.estimated_remaining_seconds,
153
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
154
+ FROM sync_runs AS runs
155
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
156
+ WHERE runs.status = 'running'
157
+ ORDER BY runs.started_at DESC
158
+ `);
159
+ const recent = await this.rows(`
160
+ SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
161
+ elapsed_seconds, estimated_remaining_seconds,
162
+ cast(heartbeat_at AS VARCHAR) AS heartbeat_at
163
+ FROM sync_run_progress
164
+ ORDER BY heartbeat_at DESC
165
+ LIMIT 10
166
+ `);
167
+ // Merge with sidecar heartbeat files so an active Garmin run stays visible
168
+ // even while the same process holds the single-writer database lock. The
169
+ // filtered reader guarantees a terminal or stale heartbeat can never read
170
+ // as an active run again, so a finished sync cannot block new ones.
171
+ const runningSidecars = await readRunningProgress(this.paths);
172
+ return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), runningSidecars);
173
+ }
140
174
  async coverage() {
141
175
  const rows = await this.rows(`
142
176
  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
@@ -166,6 +200,20 @@ export class ReadOnlyRepository {
166
200
  return Object.values(DATASET_CATALOG);
167
201
  }
168
202
  }
203
+ function toSyncProgressState(row) {
204
+ return {
205
+ runId: row.run_id,
206
+ provider: row.provider,
207
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
208
+ currentStep: row.current_step,
209
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
210
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
211
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
212
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
213
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
214
+ heartbeatAt: row.heartbeat_at,
215
+ };
216
+ }
169
217
  /** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
170
218
  export function jsonSafe(value) {
171
219
  if (typeof value === 'bigint')
@@ -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
- const allowed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
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
- if (!allowed.has(normalizeRelation(relation)))
59
- throw new QueryValidationError(`Relation ${relation} is not in the read-only catalog.`);
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 (!allowed.has(relation) && !ctes.has(relation))
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)
@@ -86,7 +86,7 @@ export class SwimmingService {
86
86
  availability: {
87
87
  explicitLengthFacts: lengths.length > 0,
88
88
  setFacts: sets.length > 0,
89
- perLengthPaceAvailable: lengths.some((length) => Number(length.duration_s) > 0 && Number(length.distance_m) > 0),
89
+ perLengthPaceAvailable: lengths.some((length) => Number(length.duration_s) > 0),
90
90
  },
91
91
  };
92
92
  }));
@@ -123,7 +123,7 @@ export class SwimmingService {
123
123
  FROM activity_quality_flags GROUP BY activity_source_id
124
124
  ), lengths AS (
125
125
  SELECT activity_source_id, count(*) AS length_fact_count,
126
- count(*) FILTER (WHERE duration_s > 0 AND distance_m > 0) AS pace_length_fact_count
126
+ count(*) FILTER (WHERE duration_s > 0) AS pace_length_fact_count
127
127
  FROM swim_lengths GROUP BY activity_source_id
128
128
  ), sets AS (
129
129
  SELECT activity_source_id, count(*) AS set_fact_count FROM swim_sets GROUP BY activity_source_id
@@ -12,7 +12,7 @@ declare const rateLimitSchema: z.ZodObject<{
12
12
  requests: z.ZodNumber;
13
13
  windowSeconds: z.ZodNumber;
14
14
  }, z.core.$strict>;
15
- declare const catenceConfigSchema: z.ZodObject<{
15
+ export declare const catenceConfigSchema: z.ZodObject<{
16
16
  mcp: z.ZodOptional<z.ZodObject<{
17
17
  rateLimits: z.ZodOptional<z.ZodObject<{
18
18
  server: z.ZodOptional<z.ZodNullable<z.ZodObject<{
@@ -50,6 +50,8 @@ declare const catenceConfigSchema: z.ZodObject<{
50
50
  models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
51
51
  label: z.ZodOptional<z.ZodString>;
52
52
  model: z.ZodString;
53
+ reasoningEffort: z.ZodOptional<z.ZodString>;
54
+ variants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
53
55
  }, z.core.$strict>>>;
54
56
  defaultModel: z.ZodOptional<z.ZodString>;
55
57
  defaultReasoningEffort: z.ZodOptional<z.ZodEnum<{
@@ -32,9 +32,13 @@ export function requireIntervalsConfig(environment = process.env) {
32
32
  const rateLimitSchema = z.object({ requests: z.number().int().positive(), windowSeconds: z.number().int().positive() }).strict();
33
33
  const nullableRateLimitSchema = rateLimitSchema.nullable();
34
34
  const environmentVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be an uppercase environment-variable name');
35
+ // Keep this aligned with the Console's Python validator, which accepts a
36
+ // per-model reasoningEffort and free-form reasoning-effort variants.
35
37
  const consoleModelSchema = z.object({
36
38
  label: z.string().min(1).optional(),
37
39
  model: z.string().min(1),
40
+ reasoningEffort: z.string().min(1).optional(),
41
+ variants: z.record(z.string(), z.string()).optional(),
38
42
  }).strict();
39
43
  const consoleProfileSchema = z.object({
40
44
  label: z.string().min(1).optional(),
@@ -60,7 +64,7 @@ const consoleLimitsSchema = z.object({
60
64
  toolRounds: z.number().int().min(1).max(32).optional(),
61
65
  toolResultCharacters: z.number().int().min(1_000).max(250_000).optional(),
62
66
  }).strict();
63
- const catenceConfigSchema = z.object({
67
+ export const catenceConfigSchema = z.object({
64
68
  mcp: z.object({
65
69
  rateLimits: z.object({
66
70
  server: nullableRateLimitSchema.optional(),
@@ -0,0 +1,58 @@
1
+ export declare const OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
2
+ export declare const OPENCODE_GO_API_KEY_ENV = "OPENCODE_GO_API_KEY";
3
+ export declare const OPENCODE_GO_API_BASE_ENV = "OPENCODE_GO_API_BASE";
4
+ export declare const OPENCODE_GO_MESSAGES_API_BASE_ENV = "OPENCODE_GO_MESSAGES_API_BASE";
5
+ export type OpenCodeGoRoute = 'chat' | 'responses' | 'messages';
6
+ /** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
7
+ export declare function classifyOpenCodeGoModel(id: string): {
8
+ route: OpenCodeGoRoute;
9
+ guessed: boolean;
10
+ };
11
+ export type OpenCodeGoConsoleProfiles = {
12
+ profiles: Record<string, Record<string, unknown>>;
13
+ defaultProfile: string;
14
+ counts: {
15
+ chat: number;
16
+ responses: number;
17
+ messages: number;
18
+ };
19
+ guessedRoutes: string[];
20
+ };
21
+ /**
22
+ * Build the two ready-made Console profiles from live model ids.
23
+ *
24
+ * The public `GET /models` endpoint returns only ids, not which API each model
25
+ * needs, so routing comes from the trusted table intersected with the live
26
+ * list; prefix-guessed routes are reported so callers can warn.
27
+ */
28
+ export declare function buildOpenCodeGoConsoleProfiles(modelIds: readonly string[], baseUrl?: string): OpenCodeGoConsoleProfiles;
29
+ export declare function fetchOpenCodeGoModelIds(baseUrl?: string): Promise<string[]>;
30
+ export type OpenCodeGoMergeResult = {
31
+ configPath: string;
32
+ mergedProfileIds: string[];
33
+ defaultProfile: string;
34
+ counts: {
35
+ chat: number;
36
+ responses: number;
37
+ messages: number;
38
+ };
39
+ guessedRoutes: string[];
40
+ /** Model ids removed from the managed profiles because they left the live catalog. */
41
+ prunedModelIds?: string[];
42
+ };
43
+ /**
44
+ * Fetch the live OpenCode Go catalog and merge the two Console profiles into
45
+ * `configPath`, preserving every other section plus existing console fields
46
+ * (`limits`, other profiles) and the existing `defaultProfile` unless
47
+ * `setDefault` is passed. Model routing (`model`) is refreshed from the live
48
+ * catalog, per-model customizations (custom label, `reasoningEffort`,
49
+ * `variants`) survive for models still in the catalog, and models that
50
+ * disappeared upstream are pruned so the Console never offers stale routes.
51
+ * The written file is re-validated with the same strict schema the runtime
52
+ * applies on load, then replaced atomically.
53
+ */
54
+ export declare function mergeOpenCodeGoConsoleProfiles(options: {
55
+ configPath: string;
56
+ baseUrl?: string;
57
+ setDefault?: boolean;
58
+ }): Promise<OpenCodeGoMergeResult>;