catence 0.2.0-beta.17 → 0.2.0-beta.19
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/query/repository.js +6 -4
- 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 +55 -0
- package/dist/core/runtime/opencode-go.js +237 -0
- 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 +1 -1
- package/dist/elt/storage/progress-sidecar.d.ts +1 -1
- package/dist/elt/storage/progress-sidecar.js +26 -6
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +133 -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 +6 -0
- package/docs/deployment/local-console.md +33 -0
- package/docs/deployment/local-mcp.md +21 -0
- package/docs/llm-providers.md +12 -1
- package/package.json +1 -1
- package/scripts/discover-opencode-go.mjs +36 -144
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ Catence is a local-first fitness MCP server for Garmin, Intervals.icu, and targe
|
|
|
7
7
|
## What is included
|
|
8
8
|
|
|
9
9
|
- Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
|
|
10
|
-
- Read-only MCP tools, plus explicitly named, lock-guarded Strava hydration
|
|
10
|
+
- Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
|
|
11
11
|
- One shared Catence agent can serve several isolated athlete stores. Every personal-data MCP call names an `athleteId`; Catence never silently combines athletes.
|
|
12
|
-
- A password-protected Chainlit Console with an authenticated dashboard.
|
|
12
|
+
- A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
|
|
13
13
|
- A generated demo catalog for safe evaluation in Glama, desktop MCP clients, or local development.
|
|
14
14
|
|
|
15
15
|
Catence helps explore recovery, training load, trends, activity detail, swimming/cycling/running progress, segments, gear, and data quality. It reports the evidence and coverage it can see; it does not diagnose or prescribe training.
|
|
@@ -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.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.19";
|
|
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.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.19';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { SYNC_STAGES } from '../../contracts/progress.js';
|
|
4
|
-
import { mergeProgress,
|
|
4
|
+
import { mergeProgress, readRunningProgress } from '../../elt/storage/progress-sidecar.js';
|
|
5
5
|
import { sqlString } from './sql.js';
|
|
6
6
|
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
7
7
|
export class ReadOnlyRepository {
|
|
@@ -165,9 +165,11 @@ export class ReadOnlyRepository {
|
|
|
165
165
|
LIMIT 10
|
|
166
166
|
`);
|
|
167
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.
|
|
169
|
-
|
|
170
|
-
|
|
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);
|
|
171
173
|
}
|
|
172
174
|
async coverage() {
|
|
173
175
|
const rows = await this.rows(`
|
|
@@ -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,55 @@
|
|
|
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
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Fetch the live OpenCode Go catalog and merge the two Console profiles into
|
|
43
|
+
* `configPath`, preserving every other section plus existing console fields
|
|
44
|
+
* (`limits`, other profiles) and the existing `defaultProfile` unless
|
|
45
|
+
* `setDefault` is passed. Model routing (`model`) is refreshed from the live
|
|
46
|
+
* catalog, while per-model customizations (custom label, `reasoningEffort`,
|
|
47
|
+
* `variants`) and models that disappeared upstream are kept. The written file
|
|
48
|
+
* is re-validated with the same strict schema the runtime applies on load,
|
|
49
|
+
* then replaced atomically.
|
|
50
|
+
*/
|
|
51
|
+
export declare function mergeOpenCodeGoConsoleProfiles(options: {
|
|
52
|
+
configPath: string;
|
|
53
|
+
baseUrl?: string;
|
|
54
|
+
setDefault?: boolean;
|
|
55
|
+
}): Promise<OpenCodeGoMergeResult>;
|
|
@@ -0,0 +1,237 @@
|
|
|
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, while per-model customizations (custom label, `reasoningEffort`,
|
|
167
|
+
* `variants`) and models that disappeared upstream are kept. The written file
|
|
168
|
+
* is re-validated with the same strict schema the runtime applies on load,
|
|
169
|
+
* then replaced atomically.
|
|
170
|
+
*/
|
|
171
|
+
export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
172
|
+
const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
|
|
173
|
+
const modelIds = await fetchOpenCodeGoModelIds(baseUrl);
|
|
174
|
+
const built = buildOpenCodeGoConsoleProfiles(modelIds, baseUrl);
|
|
175
|
+
const root = await readConfigRoot(options.configPath);
|
|
176
|
+
const existingConsole = typeof root.console === 'object' && root.console !== null && !Array.isArray(root.console)
|
|
177
|
+
? root.console
|
|
178
|
+
: {};
|
|
179
|
+
const existingProfiles = typeof existingConsole.profiles === 'object' && existingConsole.profiles !== null && !Array.isArray(existingConsole.profiles)
|
|
180
|
+
? existingConsole.profiles
|
|
181
|
+
: {};
|
|
182
|
+
const defaultProfile = typeof existingConsole.defaultProfile === 'string' && existingConsole.defaultProfile && !options.setDefault
|
|
183
|
+
? existingConsole.defaultProfile
|
|
184
|
+
: built.defaultProfile;
|
|
185
|
+
// Merge per model, not per profile: discovery owns routing and the id list,
|
|
186
|
+
// but a human's label, reasoningEffort, and variants must survive every
|
|
187
|
+
// re-discovery. Models that vanished upstream stay configured.
|
|
188
|
+
const mergedProfiles = {};
|
|
189
|
+
for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
|
|
190
|
+
const previousProfile = existingProfiles[profileId];
|
|
191
|
+
const rawModels = typeof previousProfile === 'object' && previousProfile !== null
|
|
192
|
+
? previousProfile.models
|
|
193
|
+
: undefined;
|
|
194
|
+
const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
|
|
195
|
+
? rawModels
|
|
196
|
+
: {};
|
|
197
|
+
const models = { ...previousModels };
|
|
198
|
+
for (const [modelId, entry] of Object.entries(builtProfile.models)) {
|
|
199
|
+
models[modelId] = {
|
|
200
|
+
...preservedModelFields(previousModels[modelId]),
|
|
201
|
+
label: entry.label,
|
|
202
|
+
model: entry.model,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
|
|
206
|
+
? previousProfile.defaultModel
|
|
207
|
+
: undefined;
|
|
208
|
+
mergedProfiles[profileId] = {
|
|
209
|
+
...builtProfile,
|
|
210
|
+
...(typeof previousDefaultModel === 'string' && previousDefaultModel in models
|
|
211
|
+
? { defaultModel: previousDefaultModel }
|
|
212
|
+
: {}),
|
|
213
|
+
models,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const updatedConsole = {
|
|
217
|
+
...existingConsole,
|
|
218
|
+
defaultProfile,
|
|
219
|
+
profiles: { ...existingProfiles, ...mergedProfiles },
|
|
220
|
+
};
|
|
221
|
+
// Validate before writing anything: the runtime rejects invalid configs at
|
|
222
|
+
// load time, so a bad merge must never reach disk.
|
|
223
|
+
const candidate = { ...root, console: updatedConsole };
|
|
224
|
+
catenceConfigSchema.parse(candidate);
|
|
225
|
+
await mkdir(path.dirname(options.configPath), { recursive: true });
|
|
226
|
+
const temporaryPath = `${options.configPath}.tmp`;
|
|
227
|
+
await writeFile(temporaryPath, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
|
|
228
|
+
await rename(temporaryPath, options.configPath);
|
|
229
|
+
return {
|
|
230
|
+
configPath: options.configPath,
|
|
231
|
+
mergedProfileIds: Object.keys(built.profiles),
|
|
232
|
+
defaultProfile,
|
|
233
|
+
counts: built.counts,
|
|
234
|
+
guessedRoutes: built.guessedRoutes,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=opencode-go.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
export declare const DETACHED_SYNC_PROVIDERS: readonly ["intervals", "garmin", "strava", "all"];
|
|
4
|
+
export type DetachedSyncProvider = (typeof DETACHED_SYNC_PROVIDERS)[number];
|
|
5
|
+
export type DetachedSyncRequest = {
|
|
6
|
+
paths: CatencePaths;
|
|
7
|
+
athleteId: string;
|
|
8
|
+
provider?: DetachedSyncProvider;
|
|
9
|
+
from?: string;
|
|
10
|
+
refresh?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type DetachedSyncHandle = {
|
|
13
|
+
started: true;
|
|
14
|
+
athleteId: string;
|
|
15
|
+
provider: DetachedSyncProvider;
|
|
16
|
+
logFile: string;
|
|
17
|
+
};
|
|
18
|
+
/** Raised when another sync run is already active for this store. */
|
|
19
|
+
export declare class DetachedSyncBusyError extends Error {
|
|
20
|
+
readonly runId: string;
|
|
21
|
+
constructor(runId: string);
|
|
22
|
+
}
|
|
23
|
+
/** Minimal structural spawner so tests can inject a fake child process. */
|
|
24
|
+
export type DetachedSyncSpawner = (command: string, arguments_: readonly string[], options: SpawnOptions) => {
|
|
25
|
+
unref(): void;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Spawn one detached `catence-data sync` process for an athlete store and
|
|
29
|
+
* return immediately.
|
|
30
|
+
*
|
|
31
|
+
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
32
|
+
* file under the catalog home, and is guarded by the same live-progress
|
|
33
|
+
* heartbeat check the progress tools use. The guard and the spawn are not
|
|
34
|
+
* atomic; the single-writer DuckDB lock remains the authoritative guard, so a
|
|
35
|
+
* racing second child fails cleanly in its own log file.
|
|
36
|
+
*/
|
|
37
|
+
export declare function startDetachedSync(request: DetachedSyncRequest, dependencies?: {
|
|
38
|
+
now?: () => Date;
|
|
39
|
+
spawnProcess?: DetachedSyncSpawner;
|
|
40
|
+
}): Promise<DetachedSyncHandle>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { open, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { syncProgress } from './management.js';
|
|
6
|
+
export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
|
|
7
|
+
/** Raised when another sync run is already active for this store. */
|
|
8
|
+
export class DetachedSyncBusyError extends Error {
|
|
9
|
+
runId;
|
|
10
|
+
constructor(runId) {
|
|
11
|
+
super(`A Catence sync run is already active (${runId}). Watch it with the progress tools instead of starting another.`);
|
|
12
|
+
this.name = 'DetachedSyncBusyError';
|
|
13
|
+
this.runId = runId;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the packaged `catence-data` CLI entry next to this module. In a
|
|
18
|
+
* built install both live under dist/; source checkouts run through tsx and
|
|
19
|
+
* tests inject their own spawner.
|
|
20
|
+
*/
|
|
21
|
+
function cliScriptPath() {
|
|
22
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../interfaces/cli/main.js');
|
|
23
|
+
}
|
|
24
|
+
function assertValidRequest(request) {
|
|
25
|
+
if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
|
|
26
|
+
throw new Error('athleteId is required and must be a lowercase athlete identifier.');
|
|
27
|
+
}
|
|
28
|
+
if (request.provider !== undefined && !DETACHED_SYNC_PROVIDERS.includes(request.provider)) {
|
|
29
|
+
throw new Error('provider must be intervals, garmin, strava, or all.');
|
|
30
|
+
}
|
|
31
|
+
if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
|
|
32
|
+
throw new Error('from must use YYYY-MM-DD.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Spawn one detached `catence-data sync` process for an athlete store and
|
|
37
|
+
* return immediately.
|
|
38
|
+
*
|
|
39
|
+
* The child outlives this process (SSH deaths, Console restarts), logs to a
|
|
40
|
+
* file under the catalog home, and is guarded by the same live-progress
|
|
41
|
+
* heartbeat check the progress tools use. The guard and the spawn are not
|
|
42
|
+
* atomic; the single-writer DuckDB lock remains the authoritative guard, so a
|
|
43
|
+
* racing second child fails cleanly in its own log file.
|
|
44
|
+
*/
|
|
45
|
+
export async function startDetachedSync(request, dependencies = {}) {
|
|
46
|
+
assertValidRequest(request);
|
|
47
|
+
const provider = request.provider ?? 'all';
|
|
48
|
+
const snapshot = await syncProgress(request.paths);
|
|
49
|
+
const activeRun = snapshot.running[0];
|
|
50
|
+
if (activeRun)
|
|
51
|
+
throw new DetachedSyncBusyError(activeRun.runId);
|
|
52
|
+
const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
|
|
53
|
+
const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
|
|
54
|
+
await mkdir(path.dirname(logFile), { recursive: true });
|
|
55
|
+
await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
|
|
56
|
+
const arguments_ = [cliScriptPath(), '--home', request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
|
|
57
|
+
if (request.from)
|
|
58
|
+
arguments_.push('--from', request.from);
|
|
59
|
+
if (request.refresh)
|
|
60
|
+
arguments_.push('--refresh');
|
|
61
|
+
// The file handle stays open only for the spawn; the detached child holds
|
|
62
|
+
// its own duplicate of the descriptor afterwards.
|
|
63
|
+
const logHandle = await open(logFile, 'a');
|
|
64
|
+
try {
|
|
65
|
+
const spawnProcess = dependencies.spawnProcess ?? spawn;
|
|
66
|
+
const child = spawnProcess(process.execPath, arguments_, {
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd],
|
|
69
|
+
env: process.env,
|
|
70
|
+
});
|
|
71
|
+
child.unref();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await logHandle.close();
|
|
75
|
+
}
|
|
76
|
+
return { started: true, athleteId: request.athleteId, provider, logFile };
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=detached-sync.js.map
|
|
@@ -332,7 +332,7 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
332
332
|
}
|
|
333
333
|
else {
|
|
334
334
|
await database.finishRun(runId);
|
|
335
|
-
pump.publishFinal(baseProgress(runId, provider, 'completed'));
|
|
335
|
+
await pump.publishFinal(baseProgress(runId, provider, 'completed'));
|
|
336
336
|
}
|
|
337
337
|
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
338
338
|
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
@@ -56,6 +56,6 @@ export declare class ProgressPump {
|
|
|
56
56
|
/** Transition the run to the JSONL import stage; keep-alives persist it. */
|
|
57
57
|
setStageImporting(): void;
|
|
58
58
|
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
59
|
-
publishFinal(state: SyncProgressState): void
|
|
59
|
+
publishFinal(state: SyncProgressState): Promise<void>;
|
|
60
60
|
stop(): void;
|
|
61
61
|
}
|
|
@@ -50,15 +50,33 @@ export async function readProgressSidecars(paths) {
|
|
|
50
50
|
/** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
|
|
51
51
|
export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
|
|
52
52
|
const now = Date.now();
|
|
53
|
+
const directory = path.join(paths.staging, 'garmin');
|
|
54
|
+
if (!existsSync(directory))
|
|
55
|
+
return [];
|
|
56
|
+
const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
|
|
53
57
|
const running = [];
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const prunable = [];
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
let state = null;
|
|
61
|
+
try {
|
|
62
|
+
state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Torn or partial sidecar write; it is not authoritative.
|
|
66
|
+
}
|
|
67
|
+
if (!state)
|
|
56
68
|
continue;
|
|
57
69
|
const ageMs = now - Date.parse(state.heartbeatAt);
|
|
58
|
-
if (!Number.isFinite(ageMs) || ageMs > staleAfterMs)
|
|
70
|
+
if (TERMINAL_STAGES.has(state.stage) || !Number.isFinite(ageMs) || ageMs > staleAfterMs) {
|
|
71
|
+
// A terminal heartbeat that survived its own cleanup (a crash between
|
|
72
|
+
// the final publish and the sidecar removal) must never read as an
|
|
73
|
+
// active run again, so sweep it instead of leaving it behind forever.
|
|
74
|
+
prunable.push(file);
|
|
59
75
|
continue;
|
|
76
|
+
}
|
|
60
77
|
running.push(state);
|
|
61
78
|
}
|
|
79
|
+
await Promise.all(prunable.map((file) => rm(path.join(directory, file), { force: true }).catch(() => undefined)));
|
|
62
80
|
return running;
|
|
63
81
|
}
|
|
64
82
|
/**
|
|
@@ -159,14 +177,16 @@ export class ProgressPump {
|
|
|
159
177
|
this.setStage('importing');
|
|
160
178
|
}
|
|
161
179
|
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
162
|
-
publishFinal(state) {
|
|
180
|
+
async publishFinal(state) {
|
|
163
181
|
this.lastHeartbeatAt = Date.now();
|
|
164
182
|
this.lastProgress = state;
|
|
165
183
|
const { database, paths, log } = this.options;
|
|
166
|
-
|
|
184
|
+
// Both terminal writes complete before the caller removes the sidecar, so
|
|
185
|
+
// a finished run can never resurrect its own progress file after cleanup.
|
|
186
|
+
await database.heartbeatRun(this.runId, state).catch((error) => {
|
|
167
187
|
log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
168
188
|
});
|
|
169
|
-
|
|
189
|
+
await writeProgressSidecar(paths, this.runId, state).catch((error) => {
|
|
170
190
|
log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
171
191
|
});
|
|
172
192
|
}
|
|
@@ -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,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
-
import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
|
+
import { catenceRuntimeHealth, DashboardSnapshotService, dataStatus, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
|
|
4
5
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
5
6
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
6
7
|
function json(response, statusCode, value) {
|
|
@@ -85,6 +86,72 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
|
|
|
85
86
|
return staticPaths;
|
|
86
87
|
throw new Error('No Catence data store is configured.');
|
|
87
88
|
}
|
|
89
|
+
function parseSyncBody(body) {
|
|
90
|
+
if (body === undefined)
|
|
91
|
+
return {};
|
|
92
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
93
|
+
throw new Error('Request body must be a JSON object.');
|
|
94
|
+
const raw = body;
|
|
95
|
+
for (const key of Object.keys(raw)) {
|
|
96
|
+
if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
|
|
97
|
+
throw new Error(`Unknown sync field: ${key}.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
|
|
101
|
+
throw new Error('athleteId must be a string.');
|
|
102
|
+
if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
|
|
103
|
+
throw new Error('provider must be intervals, garmin, strava, or all.');
|
|
104
|
+
}
|
|
105
|
+
if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
|
|
106
|
+
throw new Error('from must use YYYY-MM-DD.');
|
|
107
|
+
}
|
|
108
|
+
for (const flag of ['refresh', 'refreshModels']) {
|
|
109
|
+
if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
|
|
110
|
+
throw new Error(`${flag} must be a boolean.`);
|
|
111
|
+
}
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
114
|
+
async function resolveSyncPaths(catalogPaths, staticPaths, body) {
|
|
115
|
+
if (catalogPaths) {
|
|
116
|
+
if (!body.athleteId)
|
|
117
|
+
throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
|
|
118
|
+
return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
|
|
119
|
+
}
|
|
120
|
+
if (staticPaths)
|
|
121
|
+
return staticPaths;
|
|
122
|
+
throw new Error('No Catence data store is configured.');
|
|
123
|
+
}
|
|
124
|
+
/** Best-effort last-success timestamps; null while a run holds the write lock. */
|
|
125
|
+
async function lastSyncSummary(paths) {
|
|
126
|
+
const completedAtIso = (value) => {
|
|
127
|
+
if (value instanceof Date)
|
|
128
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
129
|
+
if (typeof value === 'string' && value.trim())
|
|
130
|
+
return value;
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
const status = await dataStatus(paths);
|
|
135
|
+
const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
|
|
136
|
+
const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
|
|
137
|
+
const providers = {};
|
|
138
|
+
let lastCompletedAt = null;
|
|
139
|
+
for (const run of completed) {
|
|
140
|
+
const completedAt = completedAtIso(run.completed_at);
|
|
141
|
+
const provider = String(run.provider ?? 'unknown');
|
|
142
|
+
if (!providers[provider] || providers[provider] < completedAt)
|
|
143
|
+
providers[provider] = completedAt;
|
|
144
|
+
if (!lastCompletedAt || lastCompletedAt < completedAt)
|
|
145
|
+
lastCompletedAt = completedAt;
|
|
146
|
+
}
|
|
147
|
+
return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof ReadOnlyDatabaseError)
|
|
151
|
+
return null;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
88
155
|
async function handleMcpRequest(request, response, paths) {
|
|
89
156
|
if (request.method !== 'POST') {
|
|
90
157
|
methodNotAllowed(response);
|
|
@@ -143,6 +210,8 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
143
210
|
const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
|
|
144
211
|
const staticPaths = options.paths ?? null;
|
|
145
212
|
const allowedOrigins = options.allowedOrigins ?? [];
|
|
213
|
+
const startSync = options.startSync ?? startDetachedSync;
|
|
214
|
+
const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
|
|
146
215
|
return createServer((request, response) => {
|
|
147
216
|
void (async () => {
|
|
148
217
|
if (!addCorsHeaders(request, response, allowedOrigins)) {
|
|
@@ -182,6 +251,69 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
182
251
|
}
|
|
183
252
|
return;
|
|
184
253
|
}
|
|
254
|
+
if (pathname === '/api/v1/sync' && request.method === 'POST') {
|
|
255
|
+
let body;
|
|
256
|
+
try {
|
|
257
|
+
body = parseSyncBody(await readJsonBody(request));
|
|
258
|
+
const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
|
|
259
|
+
// Optional model self-discovery runs before the data sync so new
|
|
260
|
+
// OpenCode Go models appear without a manual script run. Failure is
|
|
261
|
+
// non-fatal: it must never block or fail a data sync. The Console
|
|
262
|
+
// reads <CATENCE_HOME>/config.json, so catalog deployments merge
|
|
263
|
+
// into the catalog root's config instead of the athlete store.
|
|
264
|
+
const discoveryConfigPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : paths.config;
|
|
265
|
+
let discoveryWarning = null;
|
|
266
|
+
if (body.refreshModels) {
|
|
267
|
+
try {
|
|
268
|
+
await mergeModels({ configPath: discoveryConfigPath, setDefault: false });
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const handle = await startSync({ paths, athleteId: body.athleteId ?? 'local', provider: body.provider, from: body.from, refresh: body.refresh }, { spawnProcess: options.syncSpawnProcess });
|
|
275
|
+
json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
|
|
279
|
+
error: {
|
|
280
|
+
code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
|
|
281
|
+
message: error instanceof Error ? error.message : String(error),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
|
|
288
|
+
// Refresh the OpenCode Go profiles without starting a data sync. The
|
|
289
|
+
// Console's Models page calls this behind its authenticated proxy.
|
|
290
|
+
try {
|
|
291
|
+
const body = await readJsonBody(request).catch(() => ({}));
|
|
292
|
+
const raw = (body ?? {});
|
|
293
|
+
if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
|
|
294
|
+
throw new Error('setDefault must be a boolean.');
|
|
295
|
+
const configPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : staticPaths?.config;
|
|
296
|
+
if (!configPath)
|
|
297
|
+
throw new Error('No Catence data store is configured.');
|
|
298
|
+
json(response, 200, jsonSafe(await mergeModels({ configPath, setDefault: raw.setDefault === true })));
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
json(response, 400, { error: { code: 'model_discovery_failed', message: error instanceof Error ? error.message : String(error) } });
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
308
|
+
const paths = await dashboardPaths(catalogPaths, staticPaths, url);
|
|
309
|
+
const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
|
|
310
|
+
json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
185
317
|
if (pathname === '/mcp') {
|
|
186
318
|
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
187
319
|
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`)
|
|
@@ -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:
|
|
@@ -172,12 +196,21 @@ script fetches it and merges two ready-made profiles into `config.json`:
|
|
|
172
196
|
`opencode-go` (chat + responses models, base `…/zen/go/v1`) and
|
|
173
197
|
`opencode-go-messages` (messages models, base `…/zen/go`). Existing profiles,
|
|
174
198
|
limits, and `defaultProfile` are preserved unless `--set-default` is passed.
|
|
199
|
+
Re-discovery refreshes each known model's routing reference but keeps your
|
|
200
|
+
custom label, `reasoningEffort`, and `variants`, and keeps models that have
|
|
201
|
+
disappeared from the live catalog.
|
|
175
202
|
|
|
176
203
|
```sh
|
|
177
204
|
# Local (from a checkout)
|
|
178
205
|
npm run discover:opencode-go -- --write ~/.catence/config.json
|
|
179
206
|
```
|
|
180
207
|
|
|
208
|
+
Inside the Console's Models page, **Discover OpenCode Go models** runs the
|
|
209
|
+
same merge for the current home without touching athlete data (runtime route
|
|
210
|
+
`POST /api/v1/models/discover`, proxied behind the Console login). Models can
|
|
211
|
+
also be edited in place there — the update action rewrites the `console`
|
|
212
|
+
section atomically and never stores secret values.
|
|
213
|
+
|
|
181
214
|
Then set the credentials and verify:
|
|
182
215
|
|
|
183
216
|
```sh
|
|
@@ -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,22 @@ 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). Finished runs are never reported as active, so a completed or
|
|
317
|
+
crashed run can never wedge the busy guard.
|
|
318
|
+
- `POST /api/v1/models/discover` refreshes the OpenCode Go profiles in
|
|
319
|
+
`<home>/config.json` without starting a data sync:
|
|
320
|
+
`{"setDefault": false}` (both fields optional). Returns the merged profile
|
|
321
|
+
ids, per-route counts, and any prefix-guessed routes.
|
|
301
322
|
- Browser origins must be listed with `--allow-origin` (repeatable). The
|
|
302
323
|
packaged Console instead proxies the dashboard through its authenticated
|
|
303
324
|
same-origin route.
|
package/docs/llm-providers.md
CHANGED
|
@@ -196,7 +196,18 @@ different API shapes:
|
|
|
196
196
|
|
|
197
197
|
The script warns on stderr and skips any model that does not fit one of these
|
|
198
198
|
families. Rerun it to refresh the model lists; existing profiles, limits, and
|
|
199
|
-
`defaultProfile` are preserved unless `--set-default` is passed.
|
|
199
|
+
`defaultProfile` are preserved unless `--set-default` is passed. Re-running
|
|
200
|
+
never discards your own tweaks: per-model custom labels, `reasoningEffort`
|
|
201
|
+
pins, and `variants` maps survive every refresh, and models that have dropped
|
|
202
|
+
out of the live catalog stay configured.
|
|
203
|
+
|
|
204
|
+
You do not have to run the script manually: the Console dashboard's **Sync
|
|
205
|
+
data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
|
|
206
|
+
these profiles from the live catalog before starting a data sync, and the
|
|
207
|
+
Models page's **Discover OpenCode Go models** button (or
|
|
208
|
+
`POST /api/v1/models/discover`) refreshes them without syncing anything.
|
|
209
|
+
Discovery failures — offline machine, catalog unreachable — are reported as a
|
|
210
|
+
warning and never block or fail the sync itself.
|
|
200
211
|
|
|
201
212
|
```sh
|
|
202
213
|
export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
// Discover OpenCode Go models and emit Catence Console profiles for them.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Responses API, and MiniMax and Qwen speak the Anthropic Messages API. The
|
|
4
|
+
// Thin CLI wrapper around the runtime's src/core/runtime/opencode-go.ts
|
|
5
|
+
// module, which owns the "live discovery + trusted metadata" logic: the
|
|
6
6
|
// public `GET /models` endpoint returns only model ids, not which API each
|
|
7
|
-
// model needs, so routing comes from
|
|
8
|
-
//
|
|
9
|
-
// OpenClaw uses).
|
|
7
|
+
// model needs, so routing comes from a trusted table intersected with the
|
|
8
|
+
// live list (the same approach OpenClaw uses).
|
|
10
9
|
//
|
|
11
10
|
// Usage:
|
|
12
11
|
// node scripts/discover-opencode-go.mjs [--base-url <url>] [--write <config.json>] [--set-default]
|
|
@@ -17,62 +16,19 @@
|
|
|
17
16
|
// --set-default With --write, set console.defaultProfile to opencode-go even when one already exists.
|
|
18
17
|
|
|
19
18
|
import process from 'node:process';
|
|
20
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// anthropic/<id> -> {base minus /v1}/v1/messages
|
|
32
|
-
const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
|
|
33
|
-
const MESSAGES_MODELS = new Set([
|
|
34
|
-
'minimax-m2.5',
|
|
35
|
-
'minimax-m2.7',
|
|
36
|
-
'minimax-m3',
|
|
37
|
-
'qwen3.5-plus',
|
|
38
|
-
'qwen3.6-plus',
|
|
39
|
-
'qwen3.7-plus',
|
|
40
|
-
'qwen3.7-max',
|
|
41
|
-
'qwen3.8-max',
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const BRAND_NAMES = new Map([
|
|
45
|
-
['deepseek', 'DeepSeek'],
|
|
46
|
-
['glm', 'GLM'],
|
|
47
|
-
['gpt', 'GPT'],
|
|
48
|
-
['grok', 'Grok'],
|
|
49
|
-
['hy3', 'Hy3'],
|
|
50
|
-
['kimi', 'Kimi'],
|
|
51
|
-
['mimo', 'MiMo'],
|
|
52
|
-
['minimax', 'MiniMax'],
|
|
53
|
-
['qwen', 'Qwen'],
|
|
54
|
-
]);
|
|
55
|
-
|
|
56
|
-
function classify(id) {
|
|
57
|
-
if (RESPONSES_MODELS.has(id)) return { route: 'responses', guessed: false };
|
|
58
|
-
if (MESSAGES_MODELS.has(id)) return { route: 'messages', guessed: false };
|
|
59
|
-
if (/^(grok-|gpt-)/.test(id)) return { route: 'responses', guessed: true };
|
|
60
|
-
if (/^(minimax-|qwen)/.test(id)) return { route: 'messages', guessed: true };
|
|
61
|
-
return { route: 'chat', guessed: false };
|
|
20
|
+
let runtime;
|
|
21
|
+
try {
|
|
22
|
+
runtime = await import(new URL('../dist/core/runtime/opencode-go.js', import.meta.url).href);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
|
25
|
+
process.stderr.write(
|
|
26
|
+
'The discovery module is not built yet. Run `npm run build` in the catence checkout first, or use an installed `catence` package.\n',
|
|
27
|
+
);
|
|
28
|
+
process.exit(1);
|
|
62
29
|
}
|
|
63
30
|
|
|
64
|
-
|
|
65
|
-
if (route === 'responses') return `openai/responses/${id}`;
|
|
66
|
-
if (route === 'messages') return `anthropic/${id}`;
|
|
67
|
-
return `openai/${id}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function labelFor(id) {
|
|
71
|
-
return id
|
|
72
|
-
.split('-')
|
|
73
|
-
.map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
74
|
-
.join(' ');
|
|
75
|
-
}
|
|
31
|
+
const { OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_MESSAGES_API_BASE_ENV } = runtime;
|
|
76
32
|
|
|
77
33
|
function usage() {
|
|
78
34
|
process.stderr.write(
|
|
@@ -82,7 +38,7 @@ function usage() {
|
|
|
82
38
|
|
|
83
39
|
async function main() {
|
|
84
40
|
const args = process.argv.slice(2);
|
|
85
|
-
let baseUrl =
|
|
41
|
+
let baseUrl = runtime.OPENCODE_GO_DEFAULT_BASE_URL;
|
|
86
42
|
let writePath = null;
|
|
87
43
|
let setDefault = false;
|
|
88
44
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -108,100 +64,36 @@ async function main() {
|
|
|
108
64
|
if (!baseUrl.endsWith('/v1')) {
|
|
109
65
|
process.stderr.write(`Warning: --base-url ${baseUrl} does not end in /v1; chat and responses URLs may be wrong.\n`);
|
|
110
66
|
}
|
|
111
|
-
const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
112
67
|
const messagesBase = baseUrl.replace(/\/v1\/?$/, '');
|
|
113
68
|
|
|
114
|
-
let response;
|
|
115
|
-
try {
|
|
116
|
-
response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
|
|
117
|
-
} catch (error) {
|
|
118
|
-
throw new Error(`Could not reach ${modelsUrl}: ${error.message}`);
|
|
119
|
-
}
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
|
|
122
|
-
}
|
|
123
|
-
const payload = await response.json();
|
|
124
|
-
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && id);
|
|
125
|
-
|
|
126
|
-
const groups = { chat: [], responses: [], messages: [] };
|
|
127
|
-
const guessed = [];
|
|
128
|
-
for (const id of ids) {
|
|
129
|
-
const { route, guessed: wasGuessed } = classify(id);
|
|
130
|
-
if (!(route in groups)) continue;
|
|
131
|
-
groups[route].push(id);
|
|
132
|
-
if (wasGuessed) guessed.push(`${id} -> ${route}`);
|
|
133
|
-
}
|
|
134
|
-
for (const route of Object.keys(groups)) groups[route].sort();
|
|
135
|
-
|
|
136
|
-
const chatProfileModels = {};
|
|
137
|
-
for (const id of groups.chat) {
|
|
138
|
-
chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'chat') };
|
|
139
|
-
}
|
|
140
|
-
for (const id of groups.responses) {
|
|
141
|
-
chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'responses') };
|
|
142
|
-
}
|
|
143
|
-
const messagesProfileModels = {};
|
|
144
|
-
for (const id of groups.messages) {
|
|
145
|
-
messagesProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const chatDefault = groups.chat[0] ?? groups.responses[0];
|
|
149
|
-
const profiles = {};
|
|
150
|
-
profiles['opencode-go'] = {
|
|
151
|
-
label: 'OpenCode Go',
|
|
152
|
-
...(chatDefault ? { defaultModel: chatDefault } : {}),
|
|
153
|
-
models: chatProfileModels,
|
|
154
|
-
apiKeyEnv: API_KEY_ENV,
|
|
155
|
-
apiBaseEnv: API_BASE_ENV,
|
|
156
|
-
};
|
|
157
|
-
if (messagesProfileModels && groups.messages.length) {
|
|
158
|
-
profiles['opencode-go-messages'] = {
|
|
159
|
-
label: 'OpenCode Go (Anthropic)',
|
|
160
|
-
defaultModel: groups.messages[0],
|
|
161
|
-
models: messagesProfileModels,
|
|
162
|
-
apiKeyEnv: API_KEY_ENV,
|
|
163
|
-
apiBaseEnv: MESSAGES_BASE_ENV,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const consoleSection = { defaultProfile: 'opencode-go', profiles };
|
|
168
|
-
|
|
169
|
-
if (guessed.length) {
|
|
170
|
-
process.stderr.write('Routed by prefix heuristic (verify before use):\n');
|
|
171
|
-
for (const line of guessed) process.stderr.write(` ${line}\n`);
|
|
172
|
-
}
|
|
173
|
-
process.stderr.write(
|
|
174
|
-
`Discovered ${ids.length} models (${groups.chat.length} chat, ${groups.responses.length} responses, ${groups.messages.length} messages).\n`,
|
|
175
|
-
);
|
|
176
|
-
|
|
177
69
|
if (writePath) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (error.code !== 'ENOENT') throw new Error(`Could not read ${writePath}: ${error.message}`);
|
|
70
|
+
const result = await runtime.mergeOpenCodeGoConsoleProfiles({ configPath: writePath, baseUrl, setDefault });
|
|
71
|
+
if (result.guessedRoutes.length) {
|
|
72
|
+
process.stderr.write('Routed by prefix heuristic (verify before use):\n');
|
|
73
|
+
for (const line of result.guessedRoutes) process.stderr.write(` ${line}\n`);
|
|
183
74
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const mergedProfiles = { ...(existingConsole.profiles ?? {}), ...profiles };
|
|
189
|
-
const defaultProfile =
|
|
190
|
-
existingConsole.defaultProfile && !setDefault ? existingConsole.defaultProfile : 'opencode-go';
|
|
191
|
-
root.console = { ...existingConsole, defaultProfile, profiles: mergedProfiles };
|
|
192
|
-
await writeFile(writePath, `${JSON.stringify(root, null, 2)}\n`);
|
|
193
|
-
process.stderr.write(`Merged profiles into ${writePath} (defaultProfile: ${defaultProfile}).\n`);
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`Merged ${result.counts.chat} chat, ${result.counts.responses} responses, and ${result.counts.messages} messages models into ${result.configPath}` +
|
|
77
|
+
` as ${result.mergedProfileIds.join(' + ')} (defaultProfile: ${result.defaultProfile}).\n`,
|
|
78
|
+
);
|
|
194
79
|
return;
|
|
195
80
|
}
|
|
196
81
|
|
|
82
|
+
const ids = await runtime.fetchOpenCodeGoModelIds(baseUrl);
|
|
83
|
+
const built = runtime.buildOpenCodeGoConsoleProfiles(ids, baseUrl);
|
|
84
|
+
if (built.guessedRoutes.length) {
|
|
85
|
+
process.stderr.write('Routed by prefix heuristic (verify before use):\n');
|
|
86
|
+
for (const line of built.guessedRoutes) process.stderr.write(` ${line}\n`);
|
|
87
|
+
}
|
|
197
88
|
process.stderr.write(
|
|
198
|
-
`
|
|
199
|
-
`
|
|
200
|
-
` export ${
|
|
201
|
-
` export ${
|
|
89
|
+
`Discovered ${ids.length} models (${built.counts.chat} chat, ${built.counts.responses} responses, ${built.counts.messages} messages).\n` +
|
|
90
|
+
`Export the key and base URLs, then add the JSON below under "console" in config.json:\n` +
|
|
91
|
+
` export ${OPENCODE_GO_API_KEY_ENV}='sk-…'\n` +
|
|
92
|
+
` export ${OPENCODE_GO_API_BASE_ENV}='${baseUrl}'\n` +
|
|
93
|
+
` export ${OPENCODE_GO_MESSAGES_API_BASE_ENV}='${messagesBase}'\n` +
|
|
202
94
|
`(or rerun with --write ~/.catence/config.json to merge into an existing config.)\n`,
|
|
203
95
|
);
|
|
204
|
-
process.stdout.write(`${JSON.stringify(
|
|
96
|
+
process.stdout.write(`${JSON.stringify({ defaultProfile: built.defaultProfile, profiles: built.profiles }, null, 2)}\n`);
|
|
205
97
|
}
|
|
206
98
|
|
|
207
99
|
await main().catch((error) => {
|