catence 0.2.0-beta.7 → 0.2.0-beta.9

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 CHANGED
@@ -213,6 +213,48 @@ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.e
213
213
 
214
214
  [`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`.
215
215
 
216
+ ### Per-model reasoning effort
217
+
218
+ Models that support reasoning effort can set a fixed level per model in `config.json`, or expose a per-chat **Thinking effort** dropdown with custom levels via `variants`.
219
+
220
+ **Fixed level** (all requests for the model use it):
221
+
222
+ ```json
223
+ {
224
+ "console": {
225
+ "profiles": {
226
+ "opencode-zen": {
227
+ "label": "OpenCode Zen",
228
+ "defaultModel": "deepseek-v4-flash-free",
229
+ "models": {
230
+ "deepseek-v4-flash-free": {
231
+ "label": "DeepSeek V4 Flash Free",
232
+ "model": "openai/deepseek-v4-flash-free",
233
+ "reasoningEffort": "high"
234
+ }
235
+ },
236
+ "apiKeyEnv": "OPENCODE_API_KEY",
237
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
238
+ }
239
+ }
240
+ }
241
+ }
242
+ ```
243
+
244
+ **Custom variants** (per-chat dropdown): `variants` maps each dropdown label to the value sent to the provider. This is how non-OpenAI models (for example DeepSeek V4 Flash, which uses its own Default/High/Max scheme rather than the OpenAI-standard list) expose their native levels:
245
+
246
+ ```json
247
+ "deepseek-v4-flash-free": {
248
+ "label": "DeepSeek V4 Flash Free",
249
+ "model": "openai/deepseek-v4-flash-free",
250
+ "variants": { "High": "high", "Max": "max" }
251
+ }
252
+ ```
253
+
254
+ With `variants`, the chat **Thinking effort** dropdown shows `Provider default` plus each label; selecting one sends that exact value (e.g. `reasoning_effort: "high"`). Without `variants`, the dropdown falls back to the OpenAI-standard set (`minimal`, `low`, `medium`, `high`, `xhigh`).
255
+
256
+ A fixed `reasoningEffort` overrides any profile-level `defaultReasoningEffort`; the per-chat dropdown selection (a variant value) overrides both. If nothing is set, the provider default is used.
257
+
216
258
  For source development:
217
259
 
218
260
  ```sh
@@ -238,3 +280,17 @@ data.
238
280
  Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete’s email/password credentials.
239
281
 
240
282
  See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
283
+
284
+ ##
285
+
286
+ ```bash
287
+ cd catence-deploy
288
+ docker compose -f docker-compose.yml --env-file .env exec console \
289
+ catence-data sync --athlete martina --provider all --home /data # incremental sync, all providers
290
+ docker compose -f docker-compose.yml --env-file .env exec console \
291
+ catence-data backfill --athlete martina --from 2026-01-01 --home /data # full backfill from a date
292
+ docker compose -f docker-compose.yml --env-file .env exec console \
293
+ catence-data status --athlete martina --home /data # coverage/errors
294
+ docker compose -f docker-compose.yml --env-file .env exec console \
295
+ catence-data athlete list --home /data # list athlete IDs
296
+ ```
@@ -44,6 +44,31 @@
44
44
  "label": "Anthropic",
45
45
  "model": "anthropic/claude-sonnet-4-5",
46
46
  "apiKeyEnv": "ANTHROPIC_API_KEY"
47
+ },
48
+ "opencode-zen": {
49
+ "label": "OpenCode Zen",
50
+ "defaultModel": "deepseek-v4-flash-free",
51
+ "models": {
52
+ "deepseek-v4-flash-free": {
53
+ "label": "DeepSeek V4 Flash Free",
54
+ "model": "openai/deepseek-v4-flash-free",
55
+ "variants": { "High": "high", "Max": "max" }
56
+ },
57
+ "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
58
+ "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
59
+ },
60
+ "apiKeyEnv": "OPENCODE_API_KEY",
61
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
62
+ },
63
+ "opencode-zen-responses": {
64
+ "label": "OpenCode Zen (Responses)",
65
+ "defaultModel": "gpt-5.6-luna",
66
+ "models": {
67
+ "gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
68
+ "gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
69
+ },
70
+ "apiKeyEnv": "OPENCODE_API_KEY",
71
+ "apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
47
72
  }
48
73
  }
49
74
  }
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.7";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.9";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.7';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.9';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -0,0 +1,16 @@
1
+ import type { CatencePaths } from '../contracts/runtime.js';
2
+ export type SyncLogger = {
3
+ info: (message: string, context?: Record<string, unknown>) => void;
4
+ warn: (message: string, context?: Record<string, unknown>) => void;
5
+ error: (message: string, context?: Record<string, unknown>) => void;
6
+ debug: (message: string, context?: Record<string, unknown>) => void;
7
+ };
8
+ /**
9
+ * Resolve the sync log file path.
10
+ *
11
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
12
+ * When unset (the default), logs go to stderr only so the console stays clean
13
+ * and stdout remains machine-readable for callers like sync.sh.
14
+ */
15
+ export declare function resolveLogFile(paths: CatencePaths, environment?: NodeJS.ProcessEnv): string | null;
16
+ export declare function createSyncLogger(paths: CatencePaths, environment?: NodeJS.ProcessEnv): SyncLogger;
@@ -0,0 +1,68 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
4
+ function configuredLevel(environment = process.env) {
5
+ const raw = (environment.CATENCE_LOG_LEVEL ?? 'info').toLowerCase();
6
+ if (raw === 'debug')
7
+ return LEVEL_WEIGHT.debug;
8
+ if (raw === 'warn')
9
+ return LEVEL_WEIGHT.warn;
10
+ if (raw === 'error')
11
+ return LEVEL_WEIGHT.error;
12
+ return LEVEL_WEIGHT.info;
13
+ }
14
+ /**
15
+ * Resolve the sync log file path.
16
+ *
17
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
18
+ * When unset (the default), logs go to stderr only so the console stays clean
19
+ * and stdout remains machine-readable for callers like sync.sh.
20
+ */
21
+ export function resolveLogFile(paths, environment = process.env) {
22
+ const configured = environment.CATENCE_LOG;
23
+ if (!configured)
24
+ return null;
25
+ return path.isAbsolute(configured) ? configured : path.join(paths.root, configured);
26
+ }
27
+ export function createSyncLogger(paths, environment = process.env) {
28
+ const threshold = configuredLevel(environment);
29
+ const logFile = resolveLogFile(paths, environment);
30
+ const write = (level, message, context) => {
31
+ if (LEVEL_WEIGHT[level] < threshold)
32
+ return;
33
+ const line = formatLogLine(level, message, context);
34
+ process.stderr.write(`${line}\n`);
35
+ if (logFile) {
36
+ // Fire-and-forget: never block a sync run on log persistence.
37
+ appendLogLine(logFile, `${line}\n`).catch(() => { });
38
+ }
39
+ };
40
+ return {
41
+ info: (message, context) => write('info', message, context),
42
+ warn: (message, context) => write('warn', message, context),
43
+ error: (message, context) => write('error', message, context),
44
+ debug: (message, context) => write('debug', message, context),
45
+ };
46
+ }
47
+ function formatLogLine(level, message, context) {
48
+ const timestamp = new Date().toISOString();
49
+ const tag = level.toUpperCase().padEnd(5);
50
+ let line = `[${timestamp}] [${tag}] ${message}`;
51
+ if (context && Object.keys(context).length > 0) {
52
+ line += ` ${serializeContext(context)}`;
53
+ }
54
+ return line;
55
+ }
56
+ function serializeContext(context) {
57
+ try {
58
+ return JSON.stringify(context);
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ }
64
+ let appendLogLine = async (file, line) => {
65
+ await mkdir(path.dirname(file), { recursive: true });
66
+ await appendFile(file, line, 'utf8');
67
+ };
68
+ //# sourceMappingURL=logging.js.map
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { buildRetrievalIndex } from '../../core/retrieval/index.js';
10
10
  import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
11
+ import { createSyncLogger } from '../../core/logging.js';
11
12
  import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
12
13
  import { importJsonl } from '../ingestion/importer.js';
13
14
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
@@ -19,7 +20,7 @@ const execFileAsync = promisify(execFile);
19
20
  function packageRoot() {
20
21
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
22
  }
22
- async function syncProvider(database, paths, provider, options) {
23
+ async function syncProvider(database, paths, provider, options, log) {
23
24
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
24
25
  const uncoveredExplicitWindow = async (cursorName) => {
25
26
  if (!explicitWindow)
@@ -33,6 +34,7 @@ async function syncProvider(database, paths, provider, options) {
33
34
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
34
35
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
35
36
  if (!hasProviderWork) {
37
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
36
38
  return {
37
39
  runId: null, provider, fromDate: null,
38
40
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -43,13 +45,19 @@ async function syncProvider(database, paths, provider, options) {
43
45
  }
44
46
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
45
47
  const runId = await database.beginRun(provider, fromDate);
48
+ log.info('Starting sync run', {
49
+ runId, provider, fromDate, toDate: options.toDate,
50
+ dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
51
+ activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
52
+ backfill: options.backfill, refreshActivities: options.refreshActivities,
53
+ });
46
54
  let extractionStarted = false;
47
55
  try {
48
56
  if (provider === 'intervals') {
49
57
  const environment = await athleteProviderEnvironment(paths);
50
58
  const config = requireIntervalsConfig(environment);
51
59
  extractionStarted = true;
52
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
60
+ await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
53
61
  }
54
62
  else {
55
63
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -77,14 +85,18 @@ async function syncProvider(database, paths, provider, options) {
77
85
  });
78
86
  if (!existsSync(output))
79
87
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
80
- await importJsonl(database, runId, output);
88
+ await importJsonl(database, runId, output, log);
81
89
  }
82
90
  }
83
91
  catch (error) {
84
- await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), true);
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
94
+ await database.addError(runId, provider, 'sync', null, message, true);
85
95
  }
86
96
  finally {
87
97
  await database.finishRun(runId);
98
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
99
+ log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
88
100
  }
89
101
  if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
90
102
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
@@ -109,12 +121,13 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
109
121
  if (provider === 'strava')
110
122
  return { provider, result: await syncStravaGear(paths) };
111
123
  const database = await CatenceDatabase.open(paths);
124
+ const log = createSyncLogger(paths);
112
125
  try {
113
126
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
114
127
  const toDate = new Date().toISOString().slice(0, 10);
115
128
  const runs = [];
116
129
  for (const source of providers)
117
- runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
130
+ runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
118
131
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
119
132
  }
120
133
  finally {
@@ -130,7 +143,8 @@ export async function retryDataSync(paths, previousRunId) {
130
143
  throw new Error(`No sync run found for ${previousRunId}.`);
131
144
  if (previous.provider === 'strava')
132
145
  throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
133
- const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false });
146
+ const log = createSyncLogger(paths);
147
+ const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false }, log);
134
148
  return { retriedRun: previousRunId, runId: run.runId, run };
135
149
  }
136
150
  finally {
@@ -1,7 +1,8 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord): Promise<void>;
4
- export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
5
+ export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
5
6
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
6
7
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
7
8
  export declare function completeWorkItem(database: CatenceDatabase, itemId: string): Promise<void>;
@@ -7,7 +7,7 @@ import { importSourceEntity, refreshActivityQuality } from '../normalization/nor
7
7
  import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
8
8
  import { reconcileActivityLink } from '../normalization/activities/linking.js';
9
9
  import { json } from '../storage/sql.js';
10
- export async function importRecord(database, runId, record) {
10
+ export async function importRecord(database, runId, record, log) {
11
11
  if (record.kind === 'run_manifest') {
12
12
  if (record.runId !== runId)
13
13
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -54,8 +54,9 @@ export async function importRecord(database, runId, record) {
54
54
  return;
55
55
  }
56
56
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
+ log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
57
58
  }
58
- export async function importJsonl(database, runId, filePath) {
59
+ export async function importJsonl(database, runId, filePath, log) {
59
60
  let count = 0;
60
61
  const file = createReadStream(filePath, { encoding: 'utf8' });
61
62
  const lines = createInterface({ input: file, crlfDelay: Infinity });
@@ -63,7 +64,7 @@ export async function importJsonl(database, runId, filePath) {
63
64
  if (!line.trim())
64
65
  continue;
65
66
  const record = stagingRecordSchema.parse(JSON.parse(line));
66
- await importRecord(database, runId, record);
67
+ await importRecord(database, runId, record, log);
67
68
  count += 1;
68
69
  }
69
70
  return count;
@@ -1,12 +1,14 @@
1
1
  import type { CatenceDatabase } from '../../storage/database.js';
2
2
  import type { CatencePaths } from '../../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../../core/logging.js';
3
4
  export declare class IntervalsExtractor {
4
5
  private readonly database;
5
6
  private readonly paths;
6
7
  private readonly apiKey;
7
8
  private readonly athleteId;
9
+ private readonly log;
8
10
  private readonly client;
9
- constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string);
11
+ constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string, log: SyncLogger);
10
12
  sync(runId: string, activityWindow: {
11
13
  fromDate: string;
12
14
  toDate: string;
@@ -42,12 +42,14 @@ export class IntervalsExtractor {
42
42
  paths;
43
43
  apiKey;
44
44
  athleteId;
45
+ log;
45
46
  client;
46
- constructor(database, paths, apiKey, athleteId) {
47
+ constructor(database, paths, apiKey, athleteId, log) {
47
48
  this.database = database;
48
49
  this.paths = paths;
49
50
  this.apiKey = apiKey;
50
51
  this.athleteId = athleteId;
52
+ this.log = log;
51
53
  // The typed client is used for provider-compatible authentication and the
52
54
  // primary identity request. Manifest-only calls below intentionally use
53
55
  // the same read-only credentials to cover endpoint families not wrapped by it.
@@ -189,6 +191,7 @@ export class IntervalsExtractor {
189
191
  catch (error) {
190
192
  const message = error instanceof Error ? error.message : String(error);
191
193
  await failWorkItem(this.database, workId, message);
194
+ this.log.warn('Endpoint capture failed', { runId, provider: 'intervals', endpoint, remoteId, error: message });
192
195
  await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
193
196
  return undefined;
194
197
  }
@@ -8,6 +8,7 @@ import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configu
8
8
  import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
9
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
10
10
  import { importJsonl } from '../../importer.js';
11
+ import { createSyncLogger } from '../../../../core/logging.js';
11
12
  import { withDataWriteLock } from '../../../storage/write-lock.js';
12
13
  const execFileAsync = promisify(execFile);
13
14
  export class StravaRateLimitError extends Error {
@@ -108,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
108
109
  async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
109
110
  return withDataWriteLock(paths, async () => {
110
111
  await ensurePaths(paths);
112
+ const log = createSyncLogger(paths);
111
113
  const database = await CatenceDatabase.open(paths);
112
114
  try {
113
115
  if (!refresh) {
@@ -117,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
117
119
  }
118
120
  await checkBudget(database, paths);
119
121
  const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
122
+ log.info('Starting Strava read operation', { runId, operation, resource: resource.id });
120
123
  const directory = path.join(paths.staging, 'strava');
121
124
  const stagingPath = path.join(directory, `${runId}.jsonl`);
122
125
  const resultPath = path.join(directory, `${runId}.result.json`);
@@ -124,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
124
127
  try {
125
128
  result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
126
129
  if (existsSync(stagingPath))
127
- await importJsonl(database, runId, stagingPath);
130
+ await importJsonl(database, runId, stagingPath, log);
128
131
  await storeRateState(database, result);
129
132
  await writeState(database, resource.type, resource.id, result);
130
133
  }
131
134
  catch (error) {
132
135
  const message = error instanceof Error ? error.message : String(error);
136
+ log.error('Strava read operation failed', { runId, operation, resource: resource.id, error: message, stack: error instanceof Error ? error.stack : undefined });
133
137
  await database.addError(runId, 'strava', operation, resource.id, message, true);
134
138
  throw error;
135
139
  }
136
140
  finally {
137
141
  await database.finishRun(runId);
142
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
143
+ log.info('Strava read operation finished', { runId, operation, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
138
144
  }
139
145
  if (result.status === 'error')
140
146
  throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
@@ -94,9 +94,11 @@ export class CatenceDatabase {
94
94
  const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
95
95
  const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
96
96
  const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
97
+ const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
97
98
  const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
98
99
  return {
99
100
  runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
101
+ errors: errorDetails,
100
102
  cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
101
103
  };
102
104
  }
@@ -272,6 +272,83 @@ Notes:
272
272
  `OPENCODE_LLM_PROXY_TOKEN`; otherwise any non-empty value satisfies the
273
273
  profile's required-environment check.
274
274
 
275
+ ### Opencode Zen
276
+
277
+ [OpenCode Zen](https://opencode.ai/zen) is a hosted model API with a curated
278
+ selection of coding and reasoning models. It exposes an OpenAI-compatible
279
+ endpoint at `https://opencode.ai/zen/v1` with a public `GET /v1/models`
280
+ listing. Several models are available for free (no billing details required);
281
+ paid models require an API key from [opencode.ai/auth](https://opencode.ai/auth).
282
+
283
+ The repository includes a discovery script that fetches the live model list and
284
+ merges ready-made Console profiles into `config.json`. From a Catence checkout:
285
+
286
+ ```sh
287
+ npm run discover:opencode-zen -- --write ~/.catence/config.json
288
+ ```
289
+
290
+ Add `--free-only` to include only the free models.
291
+
292
+ The script adds two profiles, because OpenCode Zen routes model families through
293
+ different API shapes:
294
+
295
+ - `opencode-zen` — chat/completions models (most models, including all free
296
+ models), base `https://opencode.ai/zen/v1` (LiteLLM `openai/<id>` names).
297
+ - `opencode-zen-responses` — responses API models (GPT-5.x series), base
298
+ `https://opencode.ai/zen/v1` (LiteLLM `openai/responses/<id>` names).
299
+
300
+ | Family | Profile prefix | Example |
301
+ | --- | --- | --- |
302
+ | Chat | `openai/<id>` | `openai/deepseek-v4-flash-free` |
303
+ | Responses | `openai/responses/<id>` | `openai/responses/gpt-5.6-luna` |
304
+
305
+ Free models carry a `-free` suffix (e.g., `deepseek-v4-flash-free`,
306
+ `nemotron-3-ultra-free`, `big-pickle`). The discovery script recognises them
307
+ automatically; `--free-only` filters the list to free models only.
308
+
309
+ ```sh
310
+ export OPENCODE_API_KEY='…' # required for paid models; any non-empty value for free
311
+ export OPENCODE_ZEN_API_BASE='https://opencode.ai/zen/v1'
312
+ export OPENCODE_ZEN_RESPONSES_API_BASE='https://opencode.ai/zen/v1'
313
+ ```
314
+
315
+ ```jsonc
316
+ {
317
+ "console": {
318
+ "defaultProfile": "opencode-zen",
319
+ "profiles": {
320
+ "opencode-zen": {
321
+ "label": "OpenCode Zen",
322
+ "defaultModel": "deepseek-v4-flash-free",
323
+ "models": {
324
+ "deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
325
+ "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
326
+ "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
327
+ },
328
+ "apiKeyEnv": "OPENCODE_API_KEY",
329
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
330
+ },
331
+ "opencode-zen-responses": {
332
+ "label": "OpenCode Zen (Responses)",
333
+ "defaultModel": "gpt-5.6-luna",
334
+ "models": {
335
+ "gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
336
+ "gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
337
+ },
338
+ "apiKeyEnv": "OPENCODE_API_KEY",
339
+ "apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
340
+ }
341
+ }
342
+ }
343
+ }
344
+ ```
345
+
346
+ Verify with `catence-console doctor`. The `responses` models
347
+ (`openai/responses/…`) go through LiteLLM's responses bridge and are the least
348
+ battle-tested path; smoke-test one chat turn before relying on them.
349
+
350
+ The same `OPENCODE_API_KEY` works for both Opencode Go and Opencode Zen.
351
+
275
352
  ## Multiple deployments per provider
276
353
 
277
354
  A profile can list several models under one set of credentials; the Console
@@ -296,6 +373,49 @@ settings then offer a per-deployment choice.
296
373
  }
297
374
  ```
298
375
 
376
+ ## Reasoning effort
377
+
378
+ Models expose a per-chat **Thinking effort** dropdown in the Console settings.
379
+ By default it offers the OpenAI-standard levels — `minimal`, `low`, `medium`,
380
+ `high`, `xhigh` — which suit OpenAI and most OpenAI-compatible endpoints.
381
+
382
+ Some models (for example DeepSeek V4 Flash) use their own reasoning-effort
383
+ scheme rather than the OpenAI-standard list. For those, declare `variants` on
384
+ the model: a mapping of dropdown label to the exact value sent to the provider.
385
+ The chat dropdown then shows `Provider default` plus each label instead of the
386
+ OpenAI-standard list.
387
+
388
+ ```jsonc
389
+ {
390
+ "console": {
391
+ "profiles": {
392
+ "opencode-zen": {
393
+ "label": "OpenCode Zen",
394
+ "defaultModel": "deepseek-v4-flash-free",
395
+ "models": {
396
+ "deepseek-v4-flash-free": {
397
+ "label": "DeepSeek V4 Flash Free",
398
+ "model": "openai/deepseek-v4-flash-free",
399
+ "variants": { "High": "high", "Max": "max" }
400
+ }
401
+ },
402
+ "apiKeyEnv": "OPENCODE_API_KEY",
403
+ "apiBaseEnv": "OPENCODE_ZEN_API_BASE"
404
+ }
405
+ }
406
+ }
407
+ }
408
+ ```
409
+
410
+ Selecting a variant sends that value verbatim as `reasoning_effort` (with
411
+ `allowed_openai_params` so LiteLLM forwards it for OpenAI-compatible models).
412
+ The `Provider default` option sends no reasoning effort at all.
413
+
414
+ You can also pin a fixed level with `reasoningEffort` on the model, or a
415
+ profile-wide default with `defaultReasoningEffort` (OpenAI-standard values
416
+ only). Precedence: per-chat dropdown selection > model `reasoningEffort` >
417
+ profile `defaultReasoningEffort` > provider default.
418
+
299
419
  ## Verifying a profile
300
420
 
301
421
  `catence-console doctor` reports, for each profile, which environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.0-beta.9",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "config.example.json",
30
30
  "docs",
31
31
  "scripts/discover-opencode-go.mjs",
32
+ "scripts/discover-opencode-zen.mjs",
32
33
  "pyproject.toml",
33
34
  "uv.lock",
34
35
  "python/__init__.py",
@@ -57,6 +58,7 @@
57
58
  "console": "uv run --project console catence-console serve",
58
59
  "console:doctor": "uv run --project console catence-console doctor",
59
60
  "discover:opencode-go": "node scripts/discover-opencode-go.mjs",
61
+ "discover:opencode-zen": "node scripts/discover-opencode-zen.mjs",
60
62
  "package:check": "npm pack --dry-run",
61
63
  "release:check": "node scripts/release-artifacts.mjs check",
62
64
  "mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
@@ -0,0 +1,218 @@
1
+ // Discover OpenCode Zen models and emit Catence Console profiles for them.
2
+ //
3
+ // OpenCode Zen serves its catalog through `GET /v1/models` at
4
+ // `https://opencode.ai/zen/v1`. Models are split between the chat/completions
5
+ // endpoint and the responses endpoint. Routing is determined by model ID prefix:
6
+ // GPT-5.x models use the responses API; everything else uses chat/completions.
7
+ //
8
+ // Usage:
9
+ // node scripts/discover-opencode-zen.mjs [--base-url <url>] [--write <config.json>] [--set-default] [--free-only]
10
+ //
11
+ // Flags:
12
+ // --base-url <url> Root of the OpenCode Zen API (default https://opencode.ai/zen/v1).
13
+ // --write <path> Merge the two profiles into an existing config.json instead of printing to stdout.
14
+ // --set-default With --write, set console.defaultProfile to opencode-zen even when one already exists.
15
+ // --free-only Only include free models (those with -free suffix or known free model IDs).
16
+
17
+ import process from 'node:process';
18
+ import { readFile, writeFile } from 'node:fs/promises';
19
+
20
+ const DEFAULT_BASE_URL = 'https://opencode.ai/zen/v1';
21
+ const API_KEY_ENV = 'OPENCODE_API_KEY';
22
+ const API_BASE_ENV = 'OPENCODE_ZEN_API_BASE';
23
+ const RESPONSES_BASE_ENV = 'OPENCODE_ZEN_RESPONSES_API_BASE';
24
+
25
+ // Models that use the OpenAI Responses API (gpt-5.x series)
26
+ const RESPONSES_PREFIXES = ['gpt-5', 'gpt-5.'];
27
+
28
+ // Known free model IDs (in case the API doesn't mark them explicitly)
29
+ const KNOWN_FREE_MODELS = new Set([
30
+ 'deepseek-v4-flash-free',
31
+ 'mimo-v2.5-free',
32
+ 'nemotron-3-ultra-free',
33
+ 'nemotron-3.5-lightning-free',
34
+ 'hy3-free',
35
+ 'laguna-s-2.1-free',
36
+ 'big-pickle',
37
+ 'north-mini-code-free',
38
+ 'longcat-2.0-free',
39
+ 'ling-3.0-flash-free',
40
+ 'ling-3.0-tiny-free',
41
+ ]);
42
+
43
+ const BRAND_NAMES = new Map([
44
+ ['deepseek', 'DeepSeek'],
45
+ ['gpt', 'GPT'],
46
+ ['mimo', 'MiMo'],
47
+ ['nemotron', 'Nemotron'],
48
+ ['hy3', 'Hy3'],
49
+ ['laguna', 'Laguna'],
50
+ ['big', 'Big'],
51
+ ['pickle', 'Pickle'],
52
+ ['north', 'North'],
53
+ ['mini', 'Mini'],
54
+ ['code', 'Code'],
55
+ ['longcat', 'LongCat'],
56
+ ['ling', 'Ling'],
57
+ ]);
58
+
59
+ function isFreeModel(id) {
60
+ return id.endsWith('-free') || KNOWN_FREE_MODELS.has(id);
61
+ }
62
+
63
+ function classify(id) {
64
+ for (const prefix of RESPONSES_PREFIXES) {
65
+ if (id.startsWith(prefix)) return { route: 'responses', guessed: false };
66
+ }
67
+ return { route: 'chat', guessed: false };
68
+ }
69
+
70
+ function modelReference(id, route) {
71
+ if (route === 'responses') return `openai/responses/${id}`;
72
+ return `openai/${id}`;
73
+ }
74
+
75
+ function labelFor(id) {
76
+ return id
77
+ .split('-')
78
+ .map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
79
+ .join(' ');
80
+ }
81
+
82
+ function usage() {
83
+ process.stderr.write(
84
+ 'Usage: node scripts/discover-opencode-zen.mjs [--base-url <url>] [--write <config.json>] [--set-default] [--free-only]\n',
85
+ );
86
+ }
87
+
88
+ async function main() {
89
+ const args = process.argv.slice(2);
90
+ let baseUrl = DEFAULT_BASE_URL;
91
+ let writePath = null;
92
+ let setDefault = false;
93
+ let freeOnly = false;
94
+
95
+ for (let i = 0; i < args.length; i += 1) {
96
+ const arg = args[i];
97
+ if (arg === '--base-url') {
98
+ baseUrl = args[i + 1];
99
+ if (!baseUrl) throw new Error('--base-url requires a URL.');
100
+ i += 1;
101
+ } else if (arg === '--write') {
102
+ writePath = args[i + 1];
103
+ if (!writePath) throw new Error('--write requires a config.json path.');
104
+ i += 1;
105
+ } else if (arg === '--set-default') {
106
+ setDefault = true;
107
+ } else if (arg === '--free-only') {
108
+ freeOnly = true;
109
+ } else if (arg === '--help' || arg === '-h') {
110
+ usage();
111
+ return;
112
+ } else {
113
+ throw new Error(`Unknown argument: ${arg}`);
114
+ }
115
+ }
116
+
117
+ if (!baseUrl.endsWith('/v1')) {
118
+ process.stderr.write(`Warning: --base-url ${baseUrl} does not end in /v1; chat and responses URLs may be wrong.\n`);
119
+ }
120
+ const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
121
+
122
+ let response;
123
+ try {
124
+ response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
125
+ } catch (error) {
126
+ throw new Error(`Could not reach ${modelsUrl}: ${error.message}`);
127
+ }
128
+ if (!response.ok) {
129
+ throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
130
+ }
131
+ const payload = await response.json();
132
+ const ids = (payload.data ?? [])
133
+ .map((entry) => entry.id)
134
+ .filter((id) => typeof id === 'string' && id);
135
+
136
+ const filteredIds = freeOnly ? ids.filter(isFreeModel) : ids;
137
+
138
+ const groups = { chat: [], responses: [] };
139
+ for (const id of filteredIds) {
140
+ const { route } = classify(id);
141
+ if (!(route in groups)) continue;
142
+ groups[route].push(id);
143
+ }
144
+ for (const route of Object.keys(groups)) groups[route].sort();
145
+
146
+ const chatProfileModels = {};
147
+ for (const id of groups.chat) {
148
+ chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'chat') };
149
+ }
150
+ const responsesProfileModels = {};
151
+ for (const id of groups.responses) {
152
+ responsesProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'responses') };
153
+ }
154
+
155
+ const chatDefault = groups.chat[0];
156
+ const responsesDefault = groups.responses[0];
157
+
158
+ const profiles = {};
159
+ if (groups.chat.length) {
160
+ profiles['opencode-zen'] = {
161
+ label: 'OpenCode Zen',
162
+ ...(chatDefault ? { defaultModel: chatDefault } : {}),
163
+ models: chatProfileModels,
164
+ apiKeyEnv: API_KEY_ENV,
165
+ apiBaseEnv: API_BASE_ENV,
166
+ };
167
+ }
168
+ if (groups.responses.length) {
169
+ profiles['opencode-zen-responses'] = {
170
+ label: 'OpenCode Zen (Responses)',
171
+ ...(responsesDefault ? { defaultModel: responsesDefault } : {}),
172
+ models: responsesProfileModels,
173
+ apiKeyEnv: API_KEY_ENV,
174
+ apiBaseEnv: RESPONSES_BASE_ENV,
175
+ };
176
+ }
177
+
178
+ const consoleSection = { defaultProfile: 'opencode-zen', profiles };
179
+
180
+ process.stderr.write(
181
+ `Discovered ${ids.length} models (${groups.chat.length} chat, ${groups.responses.length} responses).` +
182
+ (freeOnly ? ` Filtered to ${filteredIds.length} free models.` : '') + '\n',
183
+ );
184
+
185
+ if (writePath) {
186
+ let root = {};
187
+ try {
188
+ root = JSON.parse(await readFile(writePath, 'utf8'));
189
+ } catch (error) {
190
+ if (error.code !== 'ENOENT') throw new Error(`Could not read ${writePath}: ${error.message}`);
191
+ }
192
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
193
+ throw new Error(`${writePath} must contain a JSON object.`);
194
+ }
195
+ const existingConsole = typeof root.console === 'object' && root.console !== null ? root.console : {};
196
+ const mergedProfiles = { ...(existingConsole.profiles ?? {}), ...profiles };
197
+ const defaultProfile =
198
+ existingConsole.defaultProfile && !setDefault ? existingConsole.defaultProfile : 'opencode-zen';
199
+ root.console = { ...existingConsole, defaultProfile, profiles: mergedProfiles };
200
+ await writeFile(writePath, `${JSON.stringify(root, null, 2)}\n`);
201
+ process.stderr.write(`Merged profiles into ${writePath} (defaultProfile: ${defaultProfile}).\n`);
202
+ return;
203
+ }
204
+
205
+ process.stderr.write(
206
+ `Export the key and base URLs, then add the JSON below under "console" in config.json:\n` +
207
+ ` export ${API_KEY_ENV}='sk-…'\n` +
208
+ ` export ${API_BASE_ENV}='${baseUrl}'\n` +
209
+ ` export ${RESPONSES_BASE_ENV}='${baseUrl}'\n` +
210
+ `(or rerun with --write ~/.catence/config.json to merge into an existing config.)\n`,
211
+ );
212
+ process.stdout.write(`${JSON.stringify(consoleSection, null, 2)}\n`);
213
+ }
214
+
215
+ await main().catch((error) => {
216
+ process.stderr.write(`Error: ${error.message}\n`);
217
+ process.exit(1);
218
+ });