catence 0.2.0-beta.9 → 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.
Files changed (71) 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 +8 -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/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. 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 && 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>;
@@ -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