catence 0.2.0-beta.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -166
- package/config.example.json +5 -1
- 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/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- 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 +8 -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/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 +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -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 +168 -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 +66 -1
- 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')
|
|
@@ -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>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { catenceConfigSchema } from './configuration.js';
|
|
4
|
+
export const OPENCODE_GO_DEFAULT_BASE_URL = 'https://opencode.ai/zen/go/v1';
|
|
5
|
+
export const OPENCODE_GO_API_KEY_ENV = 'OPENCODE_GO_API_KEY';
|
|
6
|
+
export const OPENCODE_GO_API_BASE_ENV = 'OPENCODE_GO_API_BASE';
|
|
7
|
+
export const OPENCODE_GO_MESSAGES_API_BASE_ENV = 'OPENCODE_GO_MESSAGES_API_BASE';
|
|
8
|
+
// LiteLLM appends a fixed suffix to the profile base URL per provider, so the
|
|
9
|
+
// three APIs need different prefixes and two base URLs:
|
|
10
|
+
// openai/<id> -> {base}/chat/completions
|
|
11
|
+
// openai/responses/<id> -> {base}/responses
|
|
12
|
+
// anthropic/<id> -> {base minus /v1}/v1/messages
|
|
13
|
+
const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
|
|
14
|
+
const MESSAGES_MODELS = new Set([
|
|
15
|
+
'minimax-m2.5',
|
|
16
|
+
'minimax-m2.7',
|
|
17
|
+
'minimax-m3',
|
|
18
|
+
'qwen3.5-plus',
|
|
19
|
+
'qwen3.6-plus',
|
|
20
|
+
'qwen3.7-plus',
|
|
21
|
+
'qwen3.7-max',
|
|
22
|
+
'qwen3.8-max',
|
|
23
|
+
]);
|
|
24
|
+
const BRAND_NAMES = new Map([
|
|
25
|
+
['deepseek', 'DeepSeek'],
|
|
26
|
+
['glm', 'GLM'],
|
|
27
|
+
['gpt', 'GPT'],
|
|
28
|
+
['grok', 'Grok'],
|
|
29
|
+
['hy3', 'Hy3'],
|
|
30
|
+
['kimi', 'Kimi'],
|
|
31
|
+
['mimo', 'MiMo'],
|
|
32
|
+
['minimax', 'MiniMax'],
|
|
33
|
+
['qwen', 'Qwen'],
|
|
34
|
+
]);
|
|
35
|
+
const CHAT_PROFILE_ID = 'opencode-go';
|
|
36
|
+
const MESSAGES_PROFILE_ID = 'opencode-go-messages';
|
|
37
|
+
/** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
|
|
38
|
+
export function classifyOpenCodeGoModel(id) {
|
|
39
|
+
if (RESPONSES_MODELS.has(id))
|
|
40
|
+
return { route: 'responses', guessed: false };
|
|
41
|
+
if (MESSAGES_MODELS.has(id))
|
|
42
|
+
return { route: 'messages', guessed: false };
|
|
43
|
+
if (/^(grok-|gpt-)/.test(id))
|
|
44
|
+
return { route: 'responses', guessed: true };
|
|
45
|
+
if (/^(minimax-|qwen)/.test(id))
|
|
46
|
+
return { route: 'messages', guessed: true };
|
|
47
|
+
return { route: 'chat', guessed: false };
|
|
48
|
+
}
|
|
49
|
+
function modelReference(id, route) {
|
|
50
|
+
if (route === 'responses')
|
|
51
|
+
return `openai/responses/${id}`;
|
|
52
|
+
if (route === 'messages')
|
|
53
|
+
return `anthropic/${id}`;
|
|
54
|
+
return `openai/${id}`;
|
|
55
|
+
}
|
|
56
|
+
function labelFor(id) {
|
|
57
|
+
return id
|
|
58
|
+
.split('-')
|
|
59
|
+
.map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
60
|
+
.join(' ');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the two ready-made Console profiles from live model ids.
|
|
64
|
+
*
|
|
65
|
+
* The public `GET /models` endpoint returns only ids, not which API each model
|
|
66
|
+
* needs, so routing comes from the trusted table intersected with the live
|
|
67
|
+
* list; prefix-guessed routes are reported so callers can warn.
|
|
68
|
+
*/
|
|
69
|
+
export function buildOpenCodeGoConsoleProfiles(modelIds, baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
|
|
70
|
+
const groups = { chat: [], responses: [], messages: [] };
|
|
71
|
+
const guessedRoutes = [];
|
|
72
|
+
for (const id of modelIds) {
|
|
73
|
+
if (!id)
|
|
74
|
+
continue;
|
|
75
|
+
const { route, guessed } = classifyOpenCodeGoModel(id);
|
|
76
|
+
groups[route].push(id);
|
|
77
|
+
if (guessed)
|
|
78
|
+
guessedRoutes.push(`${id} -> ${route}`);
|
|
79
|
+
}
|
|
80
|
+
for (const route of Object.keys(groups))
|
|
81
|
+
groups[route].sort();
|
|
82
|
+
const chatModels = {};
|
|
83
|
+
for (const id of [...groups.chat, ...groups.responses]) {
|
|
84
|
+
chatModels[id] = { label: labelFor(id), model: modelReference(id, groups.chat.includes(id) ? 'chat' : 'responses') };
|
|
85
|
+
}
|
|
86
|
+
const messagesModels = {};
|
|
87
|
+
for (const id of groups.messages) {
|
|
88
|
+
messagesModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
|
|
89
|
+
}
|
|
90
|
+
const chatDefault = groups.chat[0] ?? groups.responses[0];
|
|
91
|
+
const profiles = {
|
|
92
|
+
[CHAT_PROFILE_ID]: {
|
|
93
|
+
label: 'OpenCode Go',
|
|
94
|
+
...(chatDefault ? { defaultModel: chatDefault } : {}),
|
|
95
|
+
models: chatModels,
|
|
96
|
+
apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
|
|
97
|
+
apiBaseEnv: OPENCODE_GO_API_BASE_ENV,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
if (groups.messages.length) {
|
|
101
|
+
profiles[MESSAGES_PROFILE_ID] = {
|
|
102
|
+
label: 'OpenCode Go (Anthropic)',
|
|
103
|
+
defaultModel: groups.messages[0],
|
|
104
|
+
models: messagesModels,
|
|
105
|
+
apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
|
|
106
|
+
apiBaseEnv: OPENCODE_GO_MESSAGES_API_BASE_ENV,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
profiles,
|
|
111
|
+
defaultProfile: CHAT_PROFILE_ID,
|
|
112
|
+
counts: { chat: groups.chat.length, responses: groups.responses.length, messages: groups.messages.length },
|
|
113
|
+
guessedRoutes,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function fetchOpenCodeGoModelIds(baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
|
|
117
|
+
const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`Could not reach ${modelsUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok)
|
|
126
|
+
throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
|
|
127
|
+
const payload = (await response.json());
|
|
128
|
+
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && Boolean(id));
|
|
129
|
+
if (!ids.length)
|
|
130
|
+
throw new Error(`GET ${modelsUrl} returned no models.`);
|
|
131
|
+
return ids;
|
|
132
|
+
}
|
|
133
|
+
async function readConfigRoot(configPath) {
|
|
134
|
+
try {
|
|
135
|
+
const root = JSON.parse(await readFile(configPath, 'utf8'));
|
|
136
|
+
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
|
137
|
+
throw new Error(`${configPath} must contain a JSON object.`);
|
|
138
|
+
}
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === 'ENOENT')
|
|
143
|
+
return {};
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Per-model fields a human may have customized after the initial discovery. */
|
|
148
|
+
function preservedModelFields(previous) {
|
|
149
|
+
if (typeof previous !== 'object' || previous === null || Array.isArray(previous))
|
|
150
|
+
return {};
|
|
151
|
+
const raw = previous;
|
|
152
|
+
const preserved = {};
|
|
153
|
+
if (typeof raw.label === 'string' && raw.label.trim())
|
|
154
|
+
preserved.label = raw.label;
|
|
155
|
+
if (typeof raw.reasoningEffort === 'string')
|
|
156
|
+
preserved.reasoningEffort = raw.reasoningEffort;
|
|
157
|
+
if (raw.variants !== undefined)
|
|
158
|
+
preserved.variants = raw.variants;
|
|
159
|
+
return preserved;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fetch the live OpenCode Go catalog and merge the two Console profiles into
|
|
163
|
+
* `configPath`, preserving every other section plus existing console fields
|
|
164
|
+
* (`limits`, other profiles) and the existing `defaultProfile` unless
|
|
165
|
+
* `setDefault` is passed. Model routing (`model`) is refreshed from the live
|
|
166
|
+
* catalog, per-model customizations (custom label, `reasoningEffort`,
|
|
167
|
+
* `variants`) survive for models still in the catalog, and models that
|
|
168
|
+
* disappeared upstream are pruned so the Console never offers stale routes.
|
|
169
|
+
* The written file is re-validated with the same strict schema the runtime
|
|
170
|
+
* applies on load, then replaced atomically.
|
|
171
|
+
*/
|
|
172
|
+
export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
173
|
+
const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
|
|
174
|
+
const modelIds = await fetchOpenCodeGoModelIds(baseUrl);
|
|
175
|
+
const built = buildOpenCodeGoConsoleProfiles(modelIds, baseUrl);
|
|
176
|
+
const root = await readConfigRoot(options.configPath);
|
|
177
|
+
const existingConsole = typeof root.console === 'object' && root.console !== null && !Array.isArray(root.console)
|
|
178
|
+
? root.console
|
|
179
|
+
: {};
|
|
180
|
+
const existingProfiles = typeof existingConsole.profiles === 'object' && existingConsole.profiles !== null && !Array.isArray(existingConsole.profiles)
|
|
181
|
+
? existingConsole.profiles
|
|
182
|
+
: {};
|
|
183
|
+
const defaultProfile = typeof existingConsole.defaultProfile === 'string' && existingConsole.defaultProfile && !options.setDefault
|
|
184
|
+
? existingConsole.defaultProfile
|
|
185
|
+
: built.defaultProfile;
|
|
186
|
+
// Merge per model, not per profile: discovery owns routing and the id list,
|
|
187
|
+
// but a human's label, reasoningEffort, and variants must survive every
|
|
188
|
+
// re-discovery. Models that vanished upstream are pruned so the managed
|
|
189
|
+
// profiles mirror the live catalog instead of going stale; a failed or
|
|
190
|
+
// empty catalog fetch never reaches this loop (fetch throws first).
|
|
191
|
+
const mergedProfiles = {};
|
|
192
|
+
const prunedModelIds = [];
|
|
193
|
+
for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
|
|
194
|
+
const previousProfile = existingProfiles[profileId];
|
|
195
|
+
const rawModels = typeof previousProfile === 'object' && previousProfile !== null
|
|
196
|
+
? previousProfile.models
|
|
197
|
+
: undefined;
|
|
198
|
+
const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
|
|
199
|
+
? rawModels
|
|
200
|
+
: {};
|
|
201
|
+
const models = {};
|
|
202
|
+
for (const [modelId, entry] of Object.entries(builtProfile.models)) {
|
|
203
|
+
models[modelId] = {
|
|
204
|
+
...preservedModelFields(previousModels[modelId]),
|
|
205
|
+
label: entry.label,
|
|
206
|
+
model: entry.model,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
for (const modelId of Object.keys(previousModels)) {
|
|
210
|
+
if (!(modelId in models))
|
|
211
|
+
prunedModelIds.push(`${profileId}:${modelId}`);
|
|
212
|
+
}
|
|
213
|
+
const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
|
|
214
|
+
? previousProfile.defaultModel
|
|
215
|
+
: undefined;
|
|
216
|
+
mergedProfiles[profileId] = {
|
|
217
|
+
...builtProfile,
|
|
218
|
+
...(typeof previousDefaultModel === 'string' && previousDefaultModel in models
|
|
219
|
+
? { defaultModel: previousDefaultModel }
|
|
220
|
+
: {}),
|
|
221
|
+
models,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const updatedConsole = {
|
|
225
|
+
...existingConsole,
|
|
226
|
+
defaultProfile,
|
|
227
|
+
profiles: { ...existingProfiles, ...mergedProfiles },
|
|
228
|
+
};
|
|
229
|
+
// Validate before writing anything: the runtime rejects invalid configs at
|
|
230
|
+
// load time, so a bad merge must never reach disk.
|
|
231
|
+
const candidate = { ...root, console: updatedConsole };
|
|
232
|
+
catenceConfigSchema.parse(candidate);
|
|
233
|
+
await mkdir(path.dirname(options.configPath), { recursive: true });
|
|
234
|
+
const temporaryPath = `${options.configPath}.tmp`;
|
|
235
|
+
await writeFile(temporaryPath, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
|
|
236
|
+
await rename(temporaryPath, options.configPath);
|
|
237
|
+
return {
|
|
238
|
+
configPath: options.configPath,
|
|
239
|
+
mergedProfileIds: Object.keys(built.profiles),
|
|
240
|
+
defaultProfile,
|
|
241
|
+
counts: built.counts,
|
|
242
|
+
guessedRoutes: built.guessedRoutes,
|
|
243
|
+
prunedModelIds,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=opencode-go.js.map
|