catence 0.2.0-beta.16 → 0.2.0-beta.18
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 +2 -2
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- 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 +52 -0
- package/dist/core/runtime/opencode-go.js +189 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +40 -0
- package/dist/elt/application/detached-sync.js +78 -0
- package/dist/elt/application/management.js +102 -1
- package/dist/elt/storage/database.d.ts +6 -0
- package/dist/elt/storage/database.js +18 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +104 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +43 -13
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +2 -0
- package/docs/configuration.md +22 -0
- package/docs/deployment/local-console.md +24 -0
- package/docs/deployment/local-mcp.md +16 -0
- package/docs/development.md +325 -0
- package/docs/llm-providers.md +6 -0
- package/package.json +1 -1
- package/python/catence/providers/garmin/cli.py +7 -0
- package/python/catence/providers/garmin/swim_backfill.py +22 -5
- package/python/catence/providers/garmin/worker.py +138 -4
- package/scripts/discover-opencode-go.mjs +36 -144
|
@@ -271,6 +271,24 @@ export class CatenceDatabase {
|
|
|
271
271
|
const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
|
|
272
272
|
return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
|
|
273
273
|
}
|
|
274
|
+
async hasGarminLactateThresholdHistory() {
|
|
275
|
+
const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
|
|
276
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
277
|
+
}
|
|
278
|
+
async earliestActivityStartDate(provider) {
|
|
279
|
+
const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
|
|
280
|
+
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
|
|
281
|
+
return toIsoDate(rows[0]?.earliest ?? null);
|
|
282
|
+
}
|
|
283
|
+
async garminSwimActivitySources() {
|
|
284
|
+
const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
|
|
285
|
+
return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
|
|
286
|
+
FROM activity_sources AS source
|
|
287
|
+
JOIN activities AS activity USING (activity_id)
|
|
288
|
+
WHERE source.provider = 'garmin'
|
|
289
|
+
AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
|
|
290
|
+
ORDER BY activity.started_at_utc`);
|
|
291
|
+
}
|
|
274
292
|
async latestSourceDate(provider, cursorName) {
|
|
275
293
|
const observations = cursorName === 'daily'
|
|
276
294
|
? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
export type CatenceHttpServerOptions = {
|
|
4
4
|
/** A shared catalog for public servers. All data APIs then require athleteId. */
|
|
5
5
|
catalogPaths?: CatalogPaths;
|
|
6
6
|
/** Internal compatibility hook for tests and embedded single-store callers. */
|
|
7
7
|
paths?: CatencePaths;
|
|
8
8
|
allowedOrigins?: readonly string[];
|
|
9
|
+
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
10
|
+
startSync?: typeof startDetachedSync;
|
|
11
|
+
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
12
|
+
syncSpawnProcess?: DetachedSyncSpawner;
|
|
13
|
+
/** Internal compatibility hook for tests: override OpenCode Go model discovery. */
|
|
14
|
+
mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
|
|
9
15
|
};
|
|
10
16
|
/**
|
|
11
17
|
* Create Catence's local Streamable HTTP server.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
-
import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
|
|
3
|
+
import { catenceRuntimeHealth, DashboardSnapshotService, dataStatus, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
|
|
4
4
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
5
5
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
6
6
|
function json(response, statusCode, value) {
|
|
@@ -85,6 +85,65 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
|
|
|
85
85
|
return staticPaths;
|
|
86
86
|
throw new Error('No Catence data store is configured.');
|
|
87
87
|
}
|
|
88
|
+
function parseSyncBody(body) {
|
|
89
|
+
if (body === undefined)
|
|
90
|
+
return {};
|
|
91
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
92
|
+
throw new Error('Request body must be a JSON object.');
|
|
93
|
+
const raw = body;
|
|
94
|
+
for (const key of Object.keys(raw)) {
|
|
95
|
+
if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
|
|
96
|
+
throw new Error(`Unknown sync field: ${key}.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
|
|
100
|
+
throw new Error('athleteId must be a string.');
|
|
101
|
+
if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
|
|
102
|
+
throw new Error('provider must be intervals, garmin, strava, or all.');
|
|
103
|
+
}
|
|
104
|
+
if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
|
|
105
|
+
throw new Error('from must use YYYY-MM-DD.');
|
|
106
|
+
}
|
|
107
|
+
for (const flag of ['refresh', 'refreshModels']) {
|
|
108
|
+
if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
|
|
109
|
+
throw new Error(`${flag} must be a boolean.`);
|
|
110
|
+
}
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
async function resolveSyncPaths(catalogPaths, staticPaths, body) {
|
|
114
|
+
if (catalogPaths) {
|
|
115
|
+
if (!body.athleteId)
|
|
116
|
+
throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
|
|
117
|
+
return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
|
|
118
|
+
}
|
|
119
|
+
if (staticPaths)
|
|
120
|
+
return staticPaths;
|
|
121
|
+
throw new Error('No Catence data store is configured.');
|
|
122
|
+
}
|
|
123
|
+
/** Best-effort last-success timestamps; null while a run holds the write lock. */
|
|
124
|
+
async function lastSyncSummary(paths) {
|
|
125
|
+
try {
|
|
126
|
+
const status = await dataStatus(paths);
|
|
127
|
+
const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
|
|
128
|
+
const completed = runs.filter((run) => typeof run.completed_at === 'string' && String(run.status).startsWith('completed'));
|
|
129
|
+
const providers = {};
|
|
130
|
+
let lastCompletedAt = null;
|
|
131
|
+
for (const run of completed) {
|
|
132
|
+
const completedAt = String(run.completed_at);
|
|
133
|
+
const provider = String(run.provider ?? 'unknown');
|
|
134
|
+
if (!providers[provider] || providers[provider] < completedAt)
|
|
135
|
+
providers[provider] = completedAt;
|
|
136
|
+
if (!lastCompletedAt || lastCompletedAt < completedAt)
|
|
137
|
+
lastCompletedAt = completedAt;
|
|
138
|
+
}
|
|
139
|
+
return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof ReadOnlyDatabaseError)
|
|
143
|
+
return null;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
88
147
|
async function handleMcpRequest(request, response, paths) {
|
|
89
148
|
if (request.method !== 'POST') {
|
|
90
149
|
methodNotAllowed(response);
|
|
@@ -143,6 +202,8 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
143
202
|
const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
|
|
144
203
|
const staticPaths = options.paths ?? null;
|
|
145
204
|
const allowedOrigins = options.allowedOrigins ?? [];
|
|
205
|
+
const startSync = options.startSync ?? startDetachedSync;
|
|
206
|
+
const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
|
|
146
207
|
return createServer((request, response) => {
|
|
147
208
|
void (async () => {
|
|
148
209
|
if (!addCorsHeaders(request, response, allowedOrigins)) {
|
|
@@ -182,6 +243,48 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
182
243
|
}
|
|
183
244
|
return;
|
|
184
245
|
}
|
|
246
|
+
if (pathname === '/api/v1/sync' && request.method === 'POST') {
|
|
247
|
+
let body;
|
|
248
|
+
try {
|
|
249
|
+
body = parseSyncBody(await readJsonBody(request));
|
|
250
|
+
const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
|
|
251
|
+
// Optional model self-discovery runs before the data sync so new
|
|
252
|
+
// OpenCode Go models appear without a manual script run. Failure is
|
|
253
|
+
// non-fatal: it must never block or fail a data sync.
|
|
254
|
+
let discoveryWarning = null;
|
|
255
|
+
if (body.refreshModels) {
|
|
256
|
+
try {
|
|
257
|
+
await mergeModels({ configPath: paths.config, setDefault: false });
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const handle = await startSync({ paths, athleteId: body.athleteId ?? 'local', provider: body.provider, from: body.from, refresh: body.refresh }, { spawnProcess: options.syncSpawnProcess });
|
|
264
|
+
json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
|
|
268
|
+
error: {
|
|
269
|
+
code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
|
|
270
|
+
message: error instanceof Error ? error.message : String(error),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
279
|
+
const paths = await dashboardPaths(catalogPaths, staticPaths, url);
|
|
280
|
+
const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
|
|
281
|
+
json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
185
288
|
if (pathname === '/mcp') {
|
|
186
289
|
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
187
290
|
return;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { type CatalogPaths, hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type CatalogPaths, hydrateStravaActivity, startDetachedSync, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
type StravaActivityHydrator = typeof hydrateStravaActivity;
|
|
4
4
|
type McpDependencies = {
|
|
5
5
|
hydrateStravaActivity?: StravaActivityHydrator;
|
|
6
|
+
startDetachedSync?: typeof startDetachedSync;
|
|
6
7
|
};
|
|
7
8
|
export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
|
|
8
9
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
|
|
4
|
+
import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
|
|
5
5
|
const filterSchema = z.object({
|
|
6
6
|
column: z.string().min(1),
|
|
7
7
|
op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
|
|
@@ -32,17 +32,19 @@ function textResult(value) {
|
|
|
32
32
|
return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
33
33
|
}
|
|
34
34
|
function errorResult(error) {
|
|
35
|
-
const classified = error instanceof
|
|
36
|
-
? { code: '
|
|
37
|
-
: error instanceof
|
|
38
|
-
? { code: '
|
|
39
|
-
: error instanceof
|
|
40
|
-
? { code: '
|
|
41
|
-
: error instanceof
|
|
42
|
-
? { code:
|
|
43
|
-
: error instanceof
|
|
44
|
-
? { code:
|
|
45
|
-
:
|
|
35
|
+
const classified = error instanceof DetachedSyncBusyError
|
|
36
|
+
? { code: 'sync_in_progress', message: error.message, retryable: true, runId: error.runId }
|
|
37
|
+
: error instanceof DataWriteBusyError
|
|
38
|
+
? { code: 'data_sync_in_progress', message: error.message, retryable: true }
|
|
39
|
+
: error instanceof StravaRateLimitError
|
|
40
|
+
? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
|
|
41
|
+
: error instanceof StravaEnrichmentError
|
|
42
|
+
? { code: 'strava_connection_error', message: error.message }
|
|
43
|
+
: error instanceof ReadOnlyDatabaseError
|
|
44
|
+
? { code: error.code, message: error.message }
|
|
45
|
+
: error instanceof QueryValidationError
|
|
46
|
+
? { code: 'invalid_request', message: error.message }
|
|
47
|
+
: { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
|
|
46
48
|
return { ...textResult({ data: null, provenance: {}, query: {}, caveats: [classified.message], error: classified }), isError: true };
|
|
47
49
|
}
|
|
48
50
|
function hydrationFailure(error) {
|
|
@@ -123,6 +125,7 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
|
|
|
123
125
|
export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
|
|
124
126
|
const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
125
127
|
const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
|
|
128
|
+
const startSync = dependencies.startDetachedSync ?? startDetachedSync;
|
|
126
129
|
const catalogPaths = isCatalogPaths(paths) ? paths : null;
|
|
127
130
|
const staticPaths = isCatalogPaths(paths) ? null : paths;
|
|
128
131
|
const scope = new AsyncLocalStorage();
|
|
@@ -176,7 +179,12 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
176
179
|
...(demoStore ? { demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate }, caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'] } : {}),
|
|
177
180
|
});
|
|
178
181
|
};
|
|
179
|
-
return
|
|
182
|
+
// `return await` matters here: a bare `return run()` would let a
|
|
183
|
+
// rejected promise escape the try block, bypassing the classified
|
|
184
|
+
// errorResult payload entirely.
|
|
185
|
+
if (selected)
|
|
186
|
+
return await scope.run(selected, run);
|
|
187
|
+
return await run();
|
|
180
188
|
}
|
|
181
189
|
catch (error) {
|
|
182
190
|
return errorResult(error);
|
|
@@ -238,6 +246,28 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
238
246
|
data: await repository.progress(),
|
|
239
247
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
240
248
|
}))));
|
|
249
|
+
server.registerTool('start_detached_sync', {
|
|
250
|
+
title: 'Start a detached data sync',
|
|
251
|
+
description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion.',
|
|
252
|
+
inputSchema: {
|
|
253
|
+
provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
|
|
254
|
+
from: z.string().date().optional(),
|
|
255
|
+
refresh: z.boolean().optional(),
|
|
256
|
+
},
|
|
257
|
+
}, tool('start_detached_sync', async (input) => {
|
|
258
|
+
const paths = activePaths();
|
|
259
|
+
const athleteId = activeAthlete()?.id ?? 'local';
|
|
260
|
+
const handle = await startSync({ paths, athleteId, provider: input.provider, from: input.from, refresh: input.refresh });
|
|
261
|
+
return {
|
|
262
|
+
data: { ...handle, progressTool: 'catence_sync_progress' },
|
|
263
|
+
provenance: { operation: 'detached_sync_spawn' },
|
|
264
|
+
query: { ...input },
|
|
265
|
+
caveats: [
|
|
266
|
+
'The sync runs in its own process and keeps running if this session ends; its log file records the full worker output.',
|
|
267
|
+
'Only one sync run may be active per athlete store.',
|
|
268
|
+
],
|
|
269
|
+
};
|
|
270
|
+
}));
|
|
241
271
|
server.registerTool('review_daily_recovery_load', {
|
|
242
272
|
title: 'Review daily recovery and training load',
|
|
243
273
|
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
|
30
30
|
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
31
31
|
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
32
32
|
export type { ProviderChoice } from '../elt/application/management.js';
|
|
33
|
+
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
34
|
+
export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
|
|
35
|
+
export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
|
|
36
|
+
export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
|
|
33
37
|
export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
|
|
34
38
|
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
35
39
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -16,6 +16,8 @@ export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
|
|
|
16
16
|
export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
17
17
|
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
18
18
|
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
19
|
+
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
20
|
+
export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
|
|
19
21
|
export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
20
22
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
21
23
|
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
package/docs/configuration.md
CHANGED
|
@@ -160,6 +160,12 @@ Precedence for reasoning effort: per-chat dropdown selection > model
|
|
|
160
160
|
`reasoningEffort` > profile `defaultReasoningEffort` > provider default. See
|
|
161
161
|
[`llm-providers.md`](llm-providers.md#reasoning-effort) for the full behavior.
|
|
162
162
|
|
|
163
|
+
The Console's **Models** page edits this section in place (enable/disable,
|
|
164
|
+
add custom variants, remove, set defaults). Disabled choices are stored in the
|
|
165
|
+
Console database rather than here; every write preserves all non-console
|
|
166
|
+
sections and is re-validated with the same strict parser before the file is
|
|
167
|
+
replaced.
|
|
168
|
+
|
|
163
169
|
### The Console wizard
|
|
164
170
|
|
|
165
171
|
On the first authenticated chat, the Console wizard (`write_provider_setup`)
|
|
@@ -185,6 +191,22 @@ into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
|
|
|
185
191
|
`STRAVA_*` variables are stripped first so an athlete without a provider cannot
|
|
186
192
|
inherit its credentials.
|
|
187
193
|
|
|
194
|
+
For local and Docker development you can also keep credentials in env without
|
|
195
|
+
`secret set` — see the two fallback modes below. File values always win over
|
|
196
|
+
env, so a `providers.json` entry overrides any env fallback for that field.
|
|
197
|
+
|
|
198
|
+
### Provider credentials via env (dev fallback)
|
|
199
|
+
|
|
200
|
+
Two opt-in env fallbacks are checked **only when a `providers.json` field is
|
|
201
|
+
missing** — file values always win. See `src/core/runtime/secrets.ts:51`.
|
|
202
|
+
|
|
203
|
+
| Mode | Variables | When allowed | Example for athlete `alex` |
|
|
204
|
+
|------|-----------|--------------|----------------------------|
|
|
205
|
+
| **Per-athlete scoped** (recommended, multi-athlete safe) | `CATENCE_ATHLETE_<ID>_<PROVIDER_VAR>` — prefix is `CATENCE_ATHLETE_` + upper-cased athlete id with `-` → `_` + `_` | **Always** — checked even when the generic flag is off. Only maps to that one athlete. | `CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=alex@example.com` `CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY=…` |
|
|
206
|
+
| **Generic** (single-athlete dev, opt-in) | `GARMIN_EMAIL`, `GARMIN_PASSWORD`, `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET` | Only when `CATENCE_ALLOW_ENV_SECRETS` (or alias `CATENCE_SECRETS_FROM_ENV`) is `1`/`true`/`yes`/`on` in the worker's env. Without the flag, bare provider env vars are stripped. | `CATENCE_ALLOW_ENV_SECRETS=1` + `GARMIN_EMAIL=…` |
|
|
207
|
+
|
|
208
|
+
Resolution order per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if flag set) `VAR`. Set `CATENCE_ALLOW_ENV_SECRETS=1` in `.env.dev` / `docker-compose.dev.yml` for single-athlete Docker dev, or prefer per-athlete vars for a shared catalog.
|
|
209
|
+
|
|
188
210
|
### Console auth and serving
|
|
189
211
|
|
|
190
212
|
| Variable | Purpose |
|
|
@@ -119,6 +119,30 @@ Preflight with:
|
|
|
119
119
|
catence-console doctor
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
### Manage models in the Console
|
|
123
|
+
|
|
124
|
+
The **Models** page (header button) manages the model list without editing
|
|
125
|
+
`config.json` by hand:
|
|
126
|
+
|
|
127
|
+
- Enable/disable toggles hide models from the chat's Model dropdown. Disabled
|
|
128
|
+
choices are stored per machine in the Console database
|
|
129
|
+
(`console/chat-history.sqlite3`), never in `config.json`, and the Console
|
|
130
|
+
refuses to disable the only enabled model.
|
|
131
|
+
- **Add a custom model** appends one deployment to an existing profile — id,
|
|
132
|
+
display label, LiteLLM reference (`openai/…`, `anthropic/…`,
|
|
133
|
+
`openai/responses/…`), and optional fixed reasoning effort or a custom
|
|
134
|
+
variants map.
|
|
135
|
+
- **Remove / Make default** edit `config.json`'s console section directly;
|
|
136
|
+
every other section is preserved, the result is re-validated with the same
|
|
137
|
+
strict parser used at startup before it replaces the file, and secrets stay
|
|
138
|
+
forbidden (only environment-variable names are ever stored).
|
|
139
|
+
|
|
140
|
+
The dashboard header has a **Sync data** button that starts the same detached
|
|
141
|
+
sync as `catence-data sync --provider all` through the authenticated Console
|
|
142
|
+
origin, shows live progress while the run is active, and displays the last
|
|
143
|
+
completed sync afterwards. Each manual sync also refreshes OpenCode Go model
|
|
144
|
+
profiles first; a discovery failure never blocks the data sync.
|
|
145
|
+
|
|
122
146
|
## Console serve options
|
|
123
147
|
|
|
124
148
|
`catence-console serve` accepts:
|
|
@@ -100,6 +100,11 @@ athletes never leak into each other.
|
|
|
100
100
|
- Rate limits are tracked **per athlete**: each athlete has its own sliding
|
|
101
101
|
window for server, tool, and resource limits.
|
|
102
102
|
- MCP responses carry an `athlete` field identifying which store served them.
|
|
103
|
+
- `start_detached_sync` is the one explicitly named write tool besides the
|
|
104
|
+
Strava hydration tools: it spawns a detached `catence-data sync` child for
|
|
105
|
+
the selected athlete and returns immediately with the run handle and log
|
|
106
|
+
file. It refuses to start while another sync run is active (`sync_in_progress`);
|
|
107
|
+
follow the run with `catence_sync_progress`.
|
|
103
108
|
|
|
104
109
|
### Adding another athlete
|
|
105
110
|
|
|
@@ -298,6 +303,17 @@ override the host and port when the flags are omitted.
|
|
|
298
303
|
- `GET /api/v1/dashboard` requires `athleteId`, for example
|
|
299
304
|
`http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28` (`endDate`
|
|
300
305
|
is an optional `YYYY-MM-DD`; `days` ranges 1-90 and defaults to 28).
|
|
306
|
+
- `POST /api/v1/sync` starts a detached data sync for one athlete:
|
|
307
|
+
`{"athleteId": "alex", "provider": "all", "from": "2026-01-01", "refresh": false}`.
|
|
308
|
+
The spawned child outlives the request, logs to `<home>/logs/`, and the call
|
|
309
|
+
returns `202` with the run handle immediately; an already-active run is
|
|
310
|
+
refused with `409 sync_in_progress`. Pass `"refreshModels": true` to also
|
|
311
|
+
refresh OpenCode Go model profiles first (failure is a non-fatal warning in
|
|
312
|
+
the response).
|
|
313
|
+
- `GET /api/v1/sync/status?athleteId=alex` reports live progress heartbeats,
|
|
314
|
+
recent runs, and best-effort last-completion timestamps per provider. It
|
|
315
|
+
keeps working while a sync holds the write lock (`lastSync` becomes `null`
|
|
316
|
+
then).
|
|
301
317
|
- Browser origins must be listed with `--allow-origin` (repeatable). The
|
|
302
318
|
packaged Console instead proxies the dashboard through its authenticated
|
|
303
319
|
same-origin route.
|