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.
- package/README.md +38 -180
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +12 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/sql-guard.js +37 -5
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +203 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +24 -2
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- 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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
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>;
|