@gehlich/calicomp-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/index.js +4040 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4040 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/schemas.ts
|
|
8
|
+
import { z as z2 } from "zod";
|
|
9
|
+
|
|
10
|
+
// src/coach-parameters.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// src/http.ts
|
|
14
|
+
var HttpError = class extends Error {
|
|
15
|
+
constructor(status, path) {
|
|
16
|
+
super(`HTTP ${status} from ${path}`);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.name = "HttpError";
|
|
19
|
+
}
|
|
20
|
+
status;
|
|
21
|
+
};
|
|
22
|
+
async function assertOk(res, path) {
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new HttpError(res.status, path);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
28
|
+
var DATA_PULL_PATH = "/api/mcp/data/pull";
|
|
29
|
+
var PROFILE_PATH = "/api/mcp/profile";
|
|
30
|
+
var CATALOG_PATH = "/api/exercises";
|
|
31
|
+
var SUGGEST_PATH = "/api/mcp/suggest";
|
|
32
|
+
var COACH_SUGGESTIONS_PATH = "/api/mcp/coach/suggestions";
|
|
33
|
+
var COACH_PARAMETERS_PATH = "/api/mcp/coach/parameters";
|
|
34
|
+
async function fetchSnapshot(pat2, serverUrl2) {
|
|
35
|
+
const path = DATA_PULL_PATH;
|
|
36
|
+
const res = await fetch(`${serverUrl2}${path}`, {
|
|
37
|
+
headers: { Authorization: `Bearer ${pat2}` },
|
|
38
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
39
|
+
});
|
|
40
|
+
await assertOk(res, path);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
async function fetchProfile(pat2, serverUrl2) {
|
|
44
|
+
const path = PROFILE_PATH;
|
|
45
|
+
const res = await fetch(`${serverUrl2}${path}`, {
|
|
46
|
+
headers: { Authorization: `Bearer ${pat2}` },
|
|
47
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
48
|
+
});
|
|
49
|
+
await assertOk(res, path);
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
async function fetchCatalog(serverUrl2) {
|
|
53
|
+
const path = CATALOG_PATH;
|
|
54
|
+
const res = await fetch(`${serverUrl2}${path}`, {
|
|
55
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
56
|
+
});
|
|
57
|
+
await assertOk(res, path);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
async function postSuggest(body, pat2, serverUrl2) {
|
|
61
|
+
const path = SUGGEST_PATH;
|
|
62
|
+
const res = await fetch(`${serverUrl2}${path}`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${pat2}`,
|
|
66
|
+
"Content-Type": "application/json"
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
70
|
+
});
|
|
71
|
+
await assertOk(res, path);
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
async function fetchSuggestions(cfg, status, limit) {
|
|
75
|
+
const path = COACH_SUGGESTIONS_PATH;
|
|
76
|
+
const params = new URLSearchParams();
|
|
77
|
+
if (status !== void 0) params.set("status", status);
|
|
78
|
+
if (limit !== void 0) params.set("limit", String(limit));
|
|
79
|
+
const query = params.toString();
|
|
80
|
+
const res = await fetch(`${cfg.serverUrl}${path}${query ? `?${query}` : ""}`, {
|
|
81
|
+
headers: { Authorization: `Bearer ${cfg.pat}` },
|
|
82
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
83
|
+
});
|
|
84
|
+
await assertOk(res, path);
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
87
|
+
async function fetchSuggestion(cfg, id) {
|
|
88
|
+
const path = `${COACH_SUGGESTIONS_PATH}/${encodeURIComponent(id)}`;
|
|
89
|
+
const res = await fetch(`${cfg.serverUrl}${path}`, {
|
|
90
|
+
headers: { Authorization: `Bearer ${cfg.pat}` },
|
|
91
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
92
|
+
});
|
|
93
|
+
if (res.status === 404) return null;
|
|
94
|
+
await assertOk(res, path);
|
|
95
|
+
return res.json();
|
|
96
|
+
}
|
|
97
|
+
async function postWithdraw(cfg, id) {
|
|
98
|
+
const path = `${COACH_SUGGESTIONS_PATH}/${encodeURIComponent(id)}/withdraw`;
|
|
99
|
+
const res = await fetch(`${cfg.serverUrl}${path}`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { Authorization: `Bearer ${cfg.pat}` },
|
|
102
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
103
|
+
});
|
|
104
|
+
if (res.status === 404) return "not-withdrawable";
|
|
105
|
+
await assertOk(res, path);
|
|
106
|
+
return "withdrawn";
|
|
107
|
+
}
|
|
108
|
+
async function fetchCoachParameters(cfg) {
|
|
109
|
+
const path = COACH_PARAMETERS_PATH;
|
|
110
|
+
const res = await fetch(`${cfg.serverUrl}${path}`, {
|
|
111
|
+
headers: { Authorization: `Bearer ${cfg.pat}` },
|
|
112
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
113
|
+
});
|
|
114
|
+
if (res.status === 404) return null;
|
|
115
|
+
await assertOk(res, path);
|
|
116
|
+
return res.json();
|
|
117
|
+
}
|
|
118
|
+
async function putCoachParameters(cfg, params) {
|
|
119
|
+
const path = COACH_PARAMETERS_PATH;
|
|
120
|
+
const res = await fetch(`${cfg.serverUrl}${path}`, {
|
|
121
|
+
method: "PUT",
|
|
122
|
+
headers: {
|
|
123
|
+
Authorization: `Bearer ${cfg.pat}`,
|
|
124
|
+
"Content-Type": "application/json"
|
|
125
|
+
},
|
|
126
|
+
body: JSON.stringify({ params }),
|
|
127
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
128
|
+
});
|
|
129
|
+
if (res.status === 400) return "rejected";
|
|
130
|
+
await assertOk(res, path);
|
|
131
|
+
return res.json();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/coach-parameters.ts
|
|
135
|
+
var COACH_PARAMETER_DEFAULTS = {
|
|
136
|
+
toleranceDays: 1,
|
|
137
|
+
adherenceWindowWeeks: 4,
|
|
138
|
+
consistencyWindowWeeks: 12,
|
|
139
|
+
exerciseTrendPoints: 10,
|
|
140
|
+
recentExerciseCount: 5,
|
|
141
|
+
matchThreshold: 0.542,
|
|
142
|
+
uncertainThreshold: 0.454
|
|
143
|
+
};
|
|
144
|
+
var COACH_PARAMETER_RANGES = {
|
|
145
|
+
toleranceDays: {
|
|
146
|
+
min: 0,
|
|
147
|
+
max: 7,
|
|
148
|
+
integer: true,
|
|
149
|
+
description: "Calendar-day slack for counting a session as matching its planned day."
|
|
150
|
+
},
|
|
151
|
+
adherenceWindowWeeks: {
|
|
152
|
+
min: 1,
|
|
153
|
+
max: 52,
|
|
154
|
+
integer: true,
|
|
155
|
+
description: "Rolling window, in weeks, the adherence calculator scores against."
|
|
156
|
+
},
|
|
157
|
+
consistencyWindowWeeks: {
|
|
158
|
+
min: 4,
|
|
159
|
+
max: 52,
|
|
160
|
+
integer: true,
|
|
161
|
+
description: "Rolling window, in weeks, the training-consistency calculator considers."
|
|
162
|
+
},
|
|
163
|
+
exerciseTrendPoints: {
|
|
164
|
+
min: 3,
|
|
165
|
+
max: 30,
|
|
166
|
+
integer: true,
|
|
167
|
+
description: "How many recent data points a per-exercise trend series returns."
|
|
168
|
+
},
|
|
169
|
+
recentExerciseCount: {
|
|
170
|
+
min: 1,
|
|
171
|
+
max: 20,
|
|
172
|
+
integer: true,
|
|
173
|
+
description: 'How many recent sessions the "recent exercises" overview lists.'
|
|
174
|
+
},
|
|
175
|
+
matchThreshold: {
|
|
176
|
+
min: 0,
|
|
177
|
+
max: 1,
|
|
178
|
+
integer: false,
|
|
179
|
+
description: "Ruzicka similarity at/above which an unplanned session counts as a matched candidate."
|
|
180
|
+
},
|
|
181
|
+
uncertainThreshold: {
|
|
182
|
+
min: 0,
|
|
183
|
+
max: 1,
|
|
184
|
+
integer: false,
|
|
185
|
+
description: "Ruzicka similarity at/above which an unplanned session counts as an uncertain candidate. Must stay strictly below matchThreshold."
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
function baseCoachParametersObject() {
|
|
189
|
+
const r = COACH_PARAMETER_RANGES;
|
|
190
|
+
return z.object({
|
|
191
|
+
toleranceDays: z.number().int().min(r.toleranceDays.min).max(r.toleranceDays.max).optional(),
|
|
192
|
+
adherenceWindowWeeks: z.number().int().min(r.adherenceWindowWeeks.min).max(r.adherenceWindowWeeks.max).optional(),
|
|
193
|
+
consistencyWindowWeeks: z.number().int().min(r.consistencyWindowWeeks.min).max(r.consistencyWindowWeeks.max).optional(),
|
|
194
|
+
exerciseTrendPoints: z.number().int().min(r.exerciseTrendPoints.min).max(r.exerciseTrendPoints.max).optional(),
|
|
195
|
+
recentExerciseCount: z.number().int().min(r.recentExerciseCount.min).max(r.recentExerciseCount.max).optional(),
|
|
196
|
+
matchThreshold: z.number().min(r.matchThreshold.min).max(r.matchThreshold.max).optional(),
|
|
197
|
+
uncertainThreshold: z.number().min(r.uncertainThreshold.min).max(r.uncertainThreshold.max).optional()
|
|
198
|
+
}).strict();
|
|
199
|
+
}
|
|
200
|
+
function coachParametersSchemaFor(current) {
|
|
201
|
+
return baseCoachParametersObject().refine(
|
|
202
|
+
(data) => {
|
|
203
|
+
const effectiveMatch = data.matchThreshold ?? current.matchThreshold;
|
|
204
|
+
const effectiveUncertain = data.uncertainThreshold ?? current.uncertainThreshold;
|
|
205
|
+
return effectiveUncertain < effectiveMatch;
|
|
206
|
+
},
|
|
207
|
+
{ message: "uncertainThreshold must be strictly less than matchThreshold" }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
var CoachParametersSchema = coachParametersSchemaFor(COACH_PARAMETER_DEFAULTS);
|
|
211
|
+
function mergeCoachParameters(partial) {
|
|
212
|
+
if (partial === null) return { ...COACH_PARAMETER_DEFAULTS };
|
|
213
|
+
return { ...COACH_PARAMETER_DEFAULTS, ...partial };
|
|
214
|
+
}
|
|
215
|
+
var UNCALIBRATED_NOTE = "These seven starting values are uncalibrated defaults, not measured quantities. Consider setting them deliberately after an initial conversation with the athlete about their training rhythm and how strict a session-to-plan match should be.";
|
|
216
|
+
function describeCoachParameters(current, source) {
|
|
217
|
+
const params = {};
|
|
218
|
+
for (const key of Object.keys(COACH_PARAMETER_DEFAULTS)) {
|
|
219
|
+
const range = COACH_PARAMETER_RANGES[key];
|
|
220
|
+
params[key] = {
|
|
221
|
+
value: current[key],
|
|
222
|
+
default: COACH_PARAMETER_DEFAULTS[key],
|
|
223
|
+
min: range.min,
|
|
224
|
+
max: range.max,
|
|
225
|
+
integer: range.integer,
|
|
226
|
+
description: range.description
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { params, source, note: UNCALIBRATED_NOTE };
|
|
230
|
+
}
|
|
231
|
+
async function loadCoachParameters(cfg) {
|
|
232
|
+
try {
|
|
233
|
+
const fetched = await fetchCoachParameters(cfg);
|
|
234
|
+
if (fetched === null) {
|
|
235
|
+
return { params: { ...COACH_PARAMETER_DEFAULTS }, source: "defaults" };
|
|
236
|
+
}
|
|
237
|
+
return { params: mergeCoachParameters(fetched.params), source: "server" };
|
|
238
|
+
} catch {
|
|
239
|
+
return { params: { ...COACH_PARAMETER_DEFAULTS }, source: "defaults" };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/schemas.ts
|
|
244
|
+
var ISO_DATE = z2.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
|
|
245
|
+
var UUID_STRING = z2.string().uuid();
|
|
246
|
+
var MUSCLE_KEY = z2.enum([
|
|
247
|
+
"chest",
|
|
248
|
+
"back",
|
|
249
|
+
"lats",
|
|
250
|
+
"shoulders",
|
|
251
|
+
"traps",
|
|
252
|
+
"biceps",
|
|
253
|
+
"triceps",
|
|
254
|
+
"forearms",
|
|
255
|
+
"abs",
|
|
256
|
+
"obliques",
|
|
257
|
+
"lower_back",
|
|
258
|
+
"quads",
|
|
259
|
+
"hamstrings",
|
|
260
|
+
"glutes",
|
|
261
|
+
"calves",
|
|
262
|
+
"hip_flexors",
|
|
263
|
+
"adductors",
|
|
264
|
+
"neck"
|
|
265
|
+
]);
|
|
266
|
+
var GetProfileSchema = z2.object({});
|
|
267
|
+
var ListTemplatesSchema = z2.object({});
|
|
268
|
+
var GetTemplateSchema = z2.object({
|
|
269
|
+
templateId: UUID_STRING
|
|
270
|
+
});
|
|
271
|
+
var OUTPUT_FILE_FLAG = z2.boolean().optional();
|
|
272
|
+
var GetHistorySchema = z2.object({
|
|
273
|
+
from: ISO_DATE,
|
|
274
|
+
to: ISO_DATE,
|
|
275
|
+
exercise: UUID_STRING.optional(),
|
|
276
|
+
limit: z2.number().int().min(1).max(500).optional().default(200),
|
|
277
|
+
outputFile: OUTPUT_FILE_FLAG
|
|
278
|
+
}).refine((d) => d.from <= d.to, { message: "`to` must be >= `from`" });
|
|
279
|
+
var GetPlannedWorkoutsSchema = z2.object({
|
|
280
|
+
from: ISO_DATE,
|
|
281
|
+
to: ISO_DATE
|
|
282
|
+
}).refine((d) => d.from <= d.to, { message: "`to` must be >= `from`" }).refine(
|
|
283
|
+
(d) => (new Date(d.to).getTime() - new Date(d.from).getTime()) / 864e5 <= 366,
|
|
284
|
+
{ message: "`to` - `from` must be at most 366 days" }
|
|
285
|
+
);
|
|
286
|
+
var GetAdherenceSchema = z2.object({
|
|
287
|
+
from: ISO_DATE.optional(),
|
|
288
|
+
to: ISO_DATE.optional(),
|
|
289
|
+
toleranceDays: z2.number().int().min(0).max(7).optional(),
|
|
290
|
+
windowWeeks: z2.number().int().min(1).max(52).optional(),
|
|
291
|
+
outputFile: OUTPUT_FILE_FLAG
|
|
292
|
+
}).refine((d) => d.from === void 0 || d.to === void 0 || d.from <= d.to, {
|
|
293
|
+
message: "`to` must be >= `from`"
|
|
294
|
+
}).refine(
|
|
295
|
+
(d) => d.from === void 0 || d.to === void 0 || (new Date(d.to).getTime() - new Date(d.from).getTime()) / 864e5 <= 366,
|
|
296
|
+
{ message: "`to` - `from` must be at most 366 days" }
|
|
297
|
+
).refine((d) => !(d.windowWeeks !== void 0 && d.from !== void 0 && d.to !== void 0), {
|
|
298
|
+
message: "`windowWeeks` cannot be combined with a complete `from`/`to` pair"
|
|
299
|
+
});
|
|
300
|
+
var GetStatsSchema = z2.discriminatedUnion("by", [
|
|
301
|
+
z2.object({ by: z2.literal("exercise"), exerciseId: UUID_STRING }),
|
|
302
|
+
z2.object({ by: z2.literal("muscle"), muscle: MUSCLE_KEY }),
|
|
303
|
+
z2.object({ by: z2.literal("capabilities") }).strict()
|
|
304
|
+
]);
|
|
305
|
+
var GetProgressSchema = z2.discriminatedUnion("kind", [
|
|
306
|
+
z2.object({
|
|
307
|
+
kind: z2.literal("exercise"),
|
|
308
|
+
exerciseId: UUID_STRING.optional(),
|
|
309
|
+
points: z2.number().int().min(3).max(30).optional(),
|
|
310
|
+
outputFile: OUTPUT_FILE_FLAG
|
|
311
|
+
}).strict(),
|
|
312
|
+
z2.object({
|
|
313
|
+
kind: z2.literal("format"),
|
|
314
|
+
templateId: UUID_STRING.optional(),
|
|
315
|
+
points: z2.number().int().min(3).max(30).optional(),
|
|
316
|
+
outputFile: OUTPUT_FILE_FLAG
|
|
317
|
+
}).strict()
|
|
318
|
+
]);
|
|
319
|
+
var GetExerciseCatalogSchema = z2.object({
|
|
320
|
+
lang: z2.string().min(2).max(5).optional()
|
|
321
|
+
});
|
|
322
|
+
var GetTrainingStateSchema = z2.object({});
|
|
323
|
+
var ExerciseRefSchema = z2.discriminatedUnion("source", [
|
|
324
|
+
z2.object({ source: z2.literal("catalog"), exerciseId: UUID_STRING }),
|
|
325
|
+
z2.object({ source: z2.literal("new"), tempId: z2.string().min(1).max(64) })
|
|
326
|
+
]);
|
|
327
|
+
var RoundTargetInputSchema = z2.object({
|
|
328
|
+
round: z2.number().int().min(1).max(100),
|
|
329
|
+
targetReps: z2.number().int().min(0).optional(),
|
|
330
|
+
targetTimeSeconds: z2.number().int().min(0).optional(),
|
|
331
|
+
targetWeight: z2.number().min(0).optional()
|
|
332
|
+
});
|
|
333
|
+
var PlanOpSchema = z2.discriminatedUnion("op", [
|
|
334
|
+
z2.object({
|
|
335
|
+
op: z2.literal("addExercise"),
|
|
336
|
+
blockId: UUID_STRING.nullable(),
|
|
337
|
+
// null = standalone (top-level) exercise
|
|
338
|
+
exercise: ExerciseRefSchema,
|
|
339
|
+
mode: z2.enum(["REPS", "TIME", "MAX"]),
|
|
340
|
+
// Phase 134 (G-134-12): widened to accept an explicit JSON `null` (not just an
|
|
341
|
+
// omitted key) — the shared formatProposal corpus's `format-params-required-ops-
|
|
342
|
+
// against-circuit` vector encodes "not set" as literal `null` for these four
|
|
343
|
+
// fields, mirroring the Kotlin side's `Int?`/`Double?`/`List<...>? = null`
|
|
344
|
+
// nullable defaults (`PlanOp.AddExercise`, ProposalPayload.kt). `blockId` above
|
|
345
|
+
// already accepted `null`; these four did not until now.
|
|
346
|
+
targetReps: z2.number().int().min(0).nullable().optional(),
|
|
347
|
+
targetTimeSeconds: z2.number().int().min(0).nullable().optional(),
|
|
348
|
+
restTimeSeconds: z2.number().int().min(0),
|
|
349
|
+
sets: z2.number().int().min(1),
|
|
350
|
+
targetWeight: z2.number().min(0).nullable().optional(),
|
|
351
|
+
orderIndex: z2.number().int().min(0),
|
|
352
|
+
roundTargets: z2.array(RoundTargetInputSchema).max(50).nullable().optional()
|
|
353
|
+
}),
|
|
354
|
+
z2.object({ op: z2.literal("removeExercise"), workoutExerciseId: UUID_STRING }),
|
|
355
|
+
z2.object({
|
|
356
|
+
op: z2.literal("updateSetsReps"),
|
|
357
|
+
workoutExerciseId: UUID_STRING,
|
|
358
|
+
sets: z2.number().int().min(1).optional(),
|
|
359
|
+
targetReps: z2.number().int().min(0).optional(),
|
|
360
|
+
targetTimeSeconds: z2.number().int().min(0).optional(),
|
|
361
|
+
targetWeight: z2.number().min(0).optional(),
|
|
362
|
+
restTimeSeconds: z2.number().int().min(0).optional(),
|
|
363
|
+
roundTargets: z2.array(RoundTargetInputSchema).max(50).optional()
|
|
364
|
+
}),
|
|
365
|
+
z2.object({
|
|
366
|
+
op: z2.literal("reorder"),
|
|
367
|
+
blockId: UUID_STRING.nullable(),
|
|
368
|
+
order: z2.array(UUID_STRING).min(1).max(200)
|
|
369
|
+
// full ordered list of workoutExerciseIds in this block/standalone group
|
|
370
|
+
})
|
|
371
|
+
]);
|
|
372
|
+
var NewExerciseDefSchema = z2.object({
|
|
373
|
+
tempId: z2.string().min(1).max(64),
|
|
374
|
+
name: z2.string().min(1).max(200),
|
|
375
|
+
mode: z2.enum(["REPS", "TIME", "MAX"]),
|
|
376
|
+
usesWeight: z2.boolean(),
|
|
377
|
+
// Phase 134 (G-134-12): widened to accept an explicit JSON `null` — mirrors the
|
|
378
|
+
// Kotlin side's `description: String? = null` (NewExerciseDef, ProposalPayload.kt);
|
|
379
|
+
// the shared formatProposal corpus's tempId vector encodes an absent description
|
|
380
|
+
// as literal `null`, not an omitted key.
|
|
381
|
+
description: z2.string().max(2e3).nullable().optional()
|
|
382
|
+
});
|
|
383
|
+
var FormatExerciseEntrySchema = z2.object({
|
|
384
|
+
exercise: ExerciseRefSchema,
|
|
385
|
+
mode: z2.enum(["REPS", "TIME"]),
|
|
386
|
+
targetReps: z2.number().int().min(0).nullable().optional(),
|
|
387
|
+
targetTimeSeconds: z2.number().int().min(0).nullable().optional(),
|
|
388
|
+
orderIndex: z2.number().int().min(0),
|
|
389
|
+
targetWeight: z2.number().min(0).nullable().optional()
|
|
390
|
+
}).strict();
|
|
391
|
+
var FormatSlotEntrySchema = z2.object({
|
|
392
|
+
exercise: ExerciseRefSchema,
|
|
393
|
+
orderIndex: z2.number().int().min(0)
|
|
394
|
+
}).strict();
|
|
395
|
+
var FormatWeightEntrySchema = z2.object({
|
|
396
|
+
exercise: ExerciseRefSchema,
|
|
397
|
+
orderIndex: z2.number().int().min(0),
|
|
398
|
+
targetWeight: z2.number().min(0).nullable().optional()
|
|
399
|
+
}).strict();
|
|
400
|
+
function addTargetInvalidIssues(entries, ctx) {
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
if (entry.mode === "REPS" && entry.targetReps == null) {
|
|
403
|
+
ctx.addIssue("FORMAT_PARAMS_INVALID: a REPS entry requires targetReps");
|
|
404
|
+
}
|
|
405
|
+
if (entry.mode === "TIME" && entry.targetTimeSeconds == null) {
|
|
406
|
+
ctx.addIssue("FORMAT_PARAMS_INVALID: a TIME entry requires targetTimeSeconds");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
var EMOM_WINDOW_MIN_SECONDS = 30;
|
|
411
|
+
var EMOM_WINDOW_MAX_SECONDS = 300;
|
|
412
|
+
function clampEmomWindowSeconds(proposed) {
|
|
413
|
+
return Math.min(Math.max(proposed, EMOM_WINDOW_MIN_SECONDS), EMOM_WINDOW_MAX_SECONDS);
|
|
414
|
+
}
|
|
415
|
+
var FormatParamsSchema = z2.discriminatedUnion("workoutType", [
|
|
416
|
+
z2.object({
|
|
417
|
+
workoutType: z2.literal("CIRCUIT"),
|
|
418
|
+
rounds: z2.number().int().min(1).optional().default(3),
|
|
419
|
+
restSeconds: z2.number().int().min(0).optional().default(60),
|
|
420
|
+
exercises: z2.array(FormatExerciseEntrySchema).min(1).max(50)
|
|
421
|
+
}).strict().superRefine((val, ctx) => addTargetInvalidIssues(val.exercises, ctx)),
|
|
422
|
+
/**
|
|
423
|
+
* `rounds` is the TOTAL number of intervals across the whole workout, not
|
|
424
|
+
* per-station (Protocol §2.5, "Where the EMOM minute slot lives") — a station's
|
|
425
|
+
* minute slot is derived entirely from its `orderIndex` within `stations`.
|
|
426
|
+
*
|
|
427
|
+
* `windowSeconds` is passed through UNCHANGED — the MCP proposes what the coach
|
|
428
|
+
* means and does not alter the value (`134-21-PLAN.md:277`'s original mandate,
|
|
429
|
+
* restored by `T-134-21-01` after `134-21` clamped it here by mistake instead). The
|
|
430
|
+
* App normalizes it to the editor's stepper range at its own save boundary and
|
|
431
|
+
* REPORTS the normalization in `applied_payload` (`SaveNormalization`, Plan 134-45;
|
|
432
|
+
* Protocol v1.8 §2.5, `accepted_modified` per §5) — the window bends, visibly. A
|
|
433
|
+
* TIME station whose target is not strictly below the window still does NOT bend:
|
|
434
|
+
* it remains a `FORMAT_PARAMS_INVALID` refusal (D-03/T-123-03-01), exactly
|
|
435
|
+
* mirroring `FormatProposalApplier.validateParams`'s EMOM branch (Kotlin). That
|
|
436
|
+
* refusal rule compares against the NORMALIZED window (the helper declared above,
|
|
437
|
+
* not the raw field) — not an inconsistency, but the condition under which the MCP
|
|
438
|
+
* and the App's `SaveNormalization` decide every boundary case identically
|
|
439
|
+
* (T-134-21-03). Do not reintroduce a clamp on the field itself.
|
|
440
|
+
*/
|
|
441
|
+
z2.object({
|
|
442
|
+
workoutType: z2.literal("EMOM"),
|
|
443
|
+
windowSeconds: z2.number().int().min(1).optional().default(60),
|
|
444
|
+
rounds: z2.number().int().min(1).optional().default(12),
|
|
445
|
+
stations: z2.array(FormatExerciseEntrySchema).min(1).max(50)
|
|
446
|
+
}).strict().superRefine((val, ctx) => {
|
|
447
|
+
addTargetInvalidIssues(val.stations, ctx);
|
|
448
|
+
const normalizedWindowSeconds = clampEmomWindowSeconds(val.windowSeconds);
|
|
449
|
+
for (const station of val.stations) {
|
|
450
|
+
if (station.mode === "TIME" && station.targetTimeSeconds != null && station.targetTimeSeconds >= normalizedWindowSeconds) {
|
|
451
|
+
ctx.addIssue("FORMAT_PARAMS_INVALID: a TIME station target must be strictly less than the (normalized) EMOM window");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}),
|
|
455
|
+
/** An AMRAP round is the `exercises` list itself, repeated until `timeCapMinutes` elapses. */
|
|
456
|
+
z2.object({
|
|
457
|
+
workoutType: z2.literal("AMRAP"),
|
|
458
|
+
timeCapMinutes: z2.number().int().min(1).optional().default(10),
|
|
459
|
+
exercises: z2.array(FormatExerciseEntrySchema).min(1).max(50)
|
|
460
|
+
}).strict().superRefine((val, ctx) => addTargetInvalidIssues(val.exercises, ctx)),
|
|
461
|
+
/** TABATA carries no per-entry mode or target — the work interval comes from
|
|
462
|
+
* `workSeconds` alone and the compiler sets `mode = TIME` on the authoring side.
|
|
463
|
+
* `exercises` therefore uses `formatSlotEntry`, which has no field for either —
|
|
464
|
+
* a payload entry carrying `mode` or `targetReps` is rejected by `.strict()`. */
|
|
465
|
+
z2.object({
|
|
466
|
+
workoutType: z2.literal("TABATA"),
|
|
467
|
+
workSeconds: z2.number().int().min(1).optional().default(20),
|
|
468
|
+
restSeconds: z2.number().int().min(0).optional().default(10),
|
|
469
|
+
rounds: z2.number().int().min(1).optional().default(8),
|
|
470
|
+
exercises: z2.array(FormatSlotEntrySchema).min(1).max(50)
|
|
471
|
+
}).strict(),
|
|
472
|
+
/**
|
|
473
|
+
* `repsPerRound` is AUTHORITATIVE — its length is the round count. `pattern`/
|
|
474
|
+
* `startReps`/`step` are generator inputs kept only so the wrapper's re-display
|
|
475
|
+
* can offer a starting point for further editing; they never regenerate the ramp
|
|
476
|
+
* (Protocol §2.5, "Where the ladder ramp lives") — a proposal that changes the
|
|
477
|
+
* progression changes `repsPerRound` directly.
|
|
478
|
+
*/
|
|
479
|
+
z2.object({
|
|
480
|
+
workoutType: z2.literal("LADDER"),
|
|
481
|
+
pattern: z2.enum(["ASCENDING", "DESCENDING", "PYRAMID", "PYRAMID_REVERSE"]).optional().default("ASCENDING"),
|
|
482
|
+
startReps: z2.number().int().min(1).optional().default(1),
|
|
483
|
+
step: z2.number().int().min(1).optional().default(1),
|
|
484
|
+
restSeconds: z2.number().int().min(0).optional().default(60),
|
|
485
|
+
repsPerRound: z2.array(z2.number().int().min(1)).min(1).max(100),
|
|
486
|
+
exercises: z2.array(FormatWeightEntrySchema).min(1).max(50)
|
|
487
|
+
}).strict(),
|
|
488
|
+
z2.object({
|
|
489
|
+
workoutType: z2.literal("FOR_TIME"),
|
|
490
|
+
rounds: z2.number().int().min(1).optional().default(1),
|
|
491
|
+
raceTimer: z2.boolean().optional().default(true),
|
|
492
|
+
exercises: z2.array(FormatExerciseEntrySchema).min(1).max(50)
|
|
493
|
+
}).strict().superRefine((val, ctx) => addTargetInvalidIssues(val.exercises, ctx)),
|
|
494
|
+
/**
|
|
495
|
+
* Shares FOR_TIME's schema exactly (Protocol §2.5) — INCLUDING `rounds`, even
|
|
496
|
+
* though the app always overwrites it to `1` at apply time
|
|
497
|
+
* (`FormatProposalApplier.toChipperParams`, Kotlin). `rounds` stays on the wire
|
|
498
|
+
* deliberately: the frozen shared corpus's `chipper-rounds-normalized-not-rejected`
|
|
499
|
+
* vector carries `rounds: 3`, and `ProposalPayloadParser`'s `ignoreUnknownKeys =
|
|
500
|
+
* false` means omitting the field here would make that ACCEPTED corpus vector
|
|
501
|
+
* fail to parse on the Kotlin side's decoder — the two enforcement points must
|
|
502
|
+
* decode the identical wire shape. `rounds` is deliberately NOT validated as a
|
|
503
|
+
* `FORMAT_PARAMS_INVALID` condition (Protocol §2.5, "Normalizing rather than
|
|
504
|
+
* rejecting CHIPPER's rounds") — any value is accepted; the app normalizes it.
|
|
505
|
+
*
|
|
506
|
+
* WR-03 (134-REVIEW.md): unlike every sibling member's `rounds` field, this one
|
|
507
|
+
* deliberately has NO `.min(1)` — `FormatProposalApplier.validateParams`'s CHIPPER
|
|
508
|
+
* branch (Kotlin) never checks `rounds` at all, so `rounds: 0` or a negative value
|
|
509
|
+
* must reach the app to be normalized to `1`, not be refused here before the coach
|
|
510
|
+
* can even propose it. A `.min(1)` here would make the MCP strictly MORE restrictive
|
|
511
|
+
* than the documented, Kotlin-enforced contract this schema is supposed to mirror.
|
|
512
|
+
*/
|
|
513
|
+
z2.object({
|
|
514
|
+
workoutType: z2.literal("CHIPPER"),
|
|
515
|
+
rounds: z2.number().int().optional().default(1),
|
|
516
|
+
raceTimer: z2.boolean().optional().default(true),
|
|
517
|
+
exercises: z2.array(FormatExerciseEntrySchema).min(1).max(50)
|
|
518
|
+
}).strict().superRefine((val, ctx) => addTargetInvalidIssues(val.exercises, ctx)),
|
|
519
|
+
/** Same shape as LADDER minus `pattern`: an ascending ladder run per-minute until
|
|
520
|
+
* failure. `roundCap` is a forward-compatibility bound with no wrapper UI element. */
|
|
521
|
+
z2.object({
|
|
522
|
+
workoutType: z2.literal("DEATH_BY"),
|
|
523
|
+
startReps: z2.number().int().min(1).optional().default(1),
|
|
524
|
+
step: z2.number().int().min(1).optional().default(1),
|
|
525
|
+
roundCap: z2.number().int().min(1).optional().default(30),
|
|
526
|
+
exercises: z2.array(FormatWeightEntrySchema).min(1).max(50)
|
|
527
|
+
}).strict()
|
|
528
|
+
]);
|
|
529
|
+
function formatParamsEntries(formatParams) {
|
|
530
|
+
switch (formatParams.workoutType) {
|
|
531
|
+
case "CIRCUIT":
|
|
532
|
+
return formatParams.exercises;
|
|
533
|
+
case "EMOM":
|
|
534
|
+
return formatParams.stations;
|
|
535
|
+
case "AMRAP":
|
|
536
|
+
return formatParams.exercises;
|
|
537
|
+
case "TABATA":
|
|
538
|
+
return formatParams.exercises;
|
|
539
|
+
case "LADDER":
|
|
540
|
+
return formatParams.exercises;
|
|
541
|
+
case "FOR_TIME":
|
|
542
|
+
return formatParams.exercises;
|
|
543
|
+
case "CHIPPER":
|
|
544
|
+
return formatParams.exercises;
|
|
545
|
+
case "DEATH_BY":
|
|
546
|
+
return formatParams.exercises;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
var ProposePlanUpdateSchema = z2.object({
|
|
550
|
+
templateId: UUID_STRING,
|
|
551
|
+
rationale: z2.string().min(1).max(2e3),
|
|
552
|
+
newExercises: z2.array(NewExerciseDefSchema).max(20).optional(),
|
|
553
|
+
ops: z2.array(PlanOpSchema).max(50).optional(),
|
|
554
|
+
formatParams: FormatParamsSchema.optional()
|
|
555
|
+
}).refine(
|
|
556
|
+
(d) => {
|
|
557
|
+
const definedTempIds = new Set((d.newExercises ?? []).map((e) => e.tempId));
|
|
558
|
+
const referencedTempIds = (d.ops ?? []).filter((op) => op.op === "addExercise" && op.exercise.source === "new").map((op) => op.exercise.tempId);
|
|
559
|
+
return referencedTempIds.every((id) => definedTempIds.has(id));
|
|
560
|
+
},
|
|
561
|
+
{ message: 'Every addExercise op with source:"new" must reference a tempId defined in newExercises[]' }
|
|
562
|
+
).refine(
|
|
563
|
+
(d) => {
|
|
564
|
+
const ids = (d.newExercises ?? []).map((e) => e.tempId);
|
|
565
|
+
return new Set(ids).size === ids.length;
|
|
566
|
+
},
|
|
567
|
+
{ message: "newExercises[].tempId values must be unique" }
|
|
568
|
+
).superRefine((d, ctx) => {
|
|
569
|
+
const hasOps = (d.ops ?? []).length > 0;
|
|
570
|
+
const hasFormatParams = d.formatParams !== void 0;
|
|
571
|
+
if (hasOps && hasFormatParams) {
|
|
572
|
+
ctx.addIssue("OPS_AND_FORMAT_PARAMS_EXCLUSIVE: a proposal may carry ops[] or formatParams, never both");
|
|
573
|
+
} else if (!hasOps && !hasFormatParams) {
|
|
574
|
+
ctx.addIssue("FORMAT_PARAMS_REQUIRED: a proposal must carry a non-empty ops[] or formatParams");
|
|
575
|
+
}
|
|
576
|
+
}).superRefine((d, ctx) => {
|
|
577
|
+
if (d.formatParams === void 0) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const definedTempIds = new Set((d.newExercises ?? []).map((e) => e.tempId));
|
|
581
|
+
for (const entry of formatParamsEntries(d.formatParams)) {
|
|
582
|
+
if (entry.exercise.source === "new" && !definedTempIds.has(entry.exercise.tempId)) {
|
|
583
|
+
ctx.addIssue(
|
|
584
|
+
`FORMAT_PARAMS_INVALID: a formatParams entry references a tempId not declared in newExercises[] (${entry.exercise.tempId})`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
var ProposeNewPlanSchema = z2.object({
|
|
590
|
+
name: z2.string().min(1).max(200),
|
|
591
|
+
rationale: z2.string().min(1).max(2e3),
|
|
592
|
+
newExercises: z2.array(NewExerciseDefSchema).max(20).optional(),
|
|
593
|
+
blocks: z2.array(
|
|
594
|
+
z2.object({
|
|
595
|
+
tempBlockId: z2.string().min(1).max(64),
|
|
596
|
+
// LLM-facing placeholder, mirrors tempId pattern
|
|
597
|
+
rounds: z2.number().int().min(1),
|
|
598
|
+
orderIndex: z2.number().int().min(0),
|
|
599
|
+
exercises: z2.array(
|
|
600
|
+
z2.object({
|
|
601
|
+
exercise: ExerciseRefSchema,
|
|
602
|
+
mode: z2.enum(["REPS", "TIME", "MAX"]),
|
|
603
|
+
targetReps: z2.number().int().min(0).optional(),
|
|
604
|
+
targetTimeSeconds: z2.number().int().min(0).optional(),
|
|
605
|
+
restTimeSeconds: z2.number().int().min(0),
|
|
606
|
+
sets: z2.number().int().min(1),
|
|
607
|
+
targetWeight: z2.number().min(0).optional(),
|
|
608
|
+
orderIndex: z2.number().int().min(0),
|
|
609
|
+
roundTargets: z2.array(RoundTargetInputSchema).max(50).optional()
|
|
610
|
+
})
|
|
611
|
+
).min(1).max(50)
|
|
612
|
+
})
|
|
613
|
+
).min(1).max(20)
|
|
614
|
+
}).refine(
|
|
615
|
+
(d) => {
|
|
616
|
+
const definedTempIds = new Set((d.newExercises ?? []).map((e) => e.tempId));
|
|
617
|
+
return d.blocks.every(
|
|
618
|
+
(b) => b.exercises.every(
|
|
619
|
+
(ex) => ex.exercise.source !== "new" || definedTempIds.has(ex.exercise.tempId)
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
},
|
|
623
|
+
{ message: 'Every exercise ref with source:"new" must reference a tempId defined in newExercises[]' }
|
|
624
|
+
).refine(
|
|
625
|
+
(d) => {
|
|
626
|
+
const ids = (d.newExercises ?? []).map((e) => e.tempId);
|
|
627
|
+
return new Set(ids).size === ids.length;
|
|
628
|
+
},
|
|
629
|
+
{ message: "newExercises[].tempId values must be unique" }
|
|
630
|
+
).refine(
|
|
631
|
+
(d) => {
|
|
632
|
+
const ids = d.blocks.map((b) => b.tempBlockId);
|
|
633
|
+
return new Set(ids).size === ids.length;
|
|
634
|
+
},
|
|
635
|
+
{ message: "blocks[].tempBlockId values must be unique" }
|
|
636
|
+
);
|
|
637
|
+
var ProposeNewExerciseSchema = z2.object({
|
|
638
|
+
name: z2.string().min(1).max(200),
|
|
639
|
+
mode: z2.enum(["REPS", "TIME", "MAX"]),
|
|
640
|
+
usesWeight: z2.boolean(),
|
|
641
|
+
rationale: z2.string().min(1).max(2e3),
|
|
642
|
+
description: z2.string().max(2e3).optional()
|
|
643
|
+
});
|
|
644
|
+
var ProposePlannedUpdateSchema = z2.discriminatedUnion("intent", [
|
|
645
|
+
z2.object({
|
|
646
|
+
intent: z2.literal("move_occurrence"),
|
|
647
|
+
rootId: UUID_STRING,
|
|
648
|
+
occurrenceDate: ISO_DATE,
|
|
649
|
+
newDate: ISO_DATE,
|
|
650
|
+
newTime: z2.string().optional(),
|
|
651
|
+
rationale: z2.string().min(1).max(2e3)
|
|
652
|
+
}),
|
|
653
|
+
z2.object({
|
|
654
|
+
intent: z2.literal("cancel_occurrence"),
|
|
655
|
+
rootId: UUID_STRING,
|
|
656
|
+
occurrenceDate: ISO_DATE,
|
|
657
|
+
rationale: z2.string().min(1).max(2e3)
|
|
658
|
+
}),
|
|
659
|
+
z2.object({
|
|
660
|
+
intent: z2.literal("schedule_workout"),
|
|
661
|
+
templateId: UUID_STRING.optional(),
|
|
662
|
+
chainSuggestionId: UUID_STRING.optional(),
|
|
663
|
+
date: ISO_DATE,
|
|
664
|
+
time: z2.string().optional(),
|
|
665
|
+
recurrenceRule: z2.string().optional(),
|
|
666
|
+
rationale: z2.string().min(1).max(2e3)
|
|
667
|
+
}).superRefine((d, ctx) => {
|
|
668
|
+
const hasTemplateId = d.templateId !== void 0;
|
|
669
|
+
const hasChain = d.chainSuggestionId !== void 0;
|
|
670
|
+
if (hasTemplateId === hasChain) {
|
|
671
|
+
ctx.addIssue(
|
|
672
|
+
"CHAIN_EXCLUSIVITY: schedule_workout requires exactly one of templateId or chainSuggestionId, never both, never neither"
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}),
|
|
676
|
+
z2.object({
|
|
677
|
+
intent: z2.literal("change_series_rule"),
|
|
678
|
+
rootId: UUID_STRING,
|
|
679
|
+
scope: z2.enum(["this_and_following", "whole_series"]),
|
|
680
|
+
newRecurrenceRule: z2.string().min(1),
|
|
681
|
+
cutoffDate: ISO_DATE.optional(),
|
|
682
|
+
rationale: z2.string().min(1).max(2e3)
|
|
683
|
+
}).superRefine((val, ctx) => {
|
|
684
|
+
if (val.scope === "this_and_following" && val.cutoffDate === void 0) {
|
|
685
|
+
ctx.addIssue("cutoffDate is required when scope is this_and_following");
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
]);
|
|
689
|
+
var SUGGESTION_STATUS = z2.enum([
|
|
690
|
+
"pending",
|
|
691
|
+
"accepted",
|
|
692
|
+
"accepted_modified",
|
|
693
|
+
"rejected",
|
|
694
|
+
"expired",
|
|
695
|
+
"withdrawn",
|
|
696
|
+
"obsolete"
|
|
697
|
+
]);
|
|
698
|
+
var GetSuggestionsSchema = z2.object({
|
|
699
|
+
status: SUGGESTION_STATUS.optional(),
|
|
700
|
+
limit: z2.number().int().optional()
|
|
701
|
+
});
|
|
702
|
+
var GetSuggestionSchema = z2.object({
|
|
703
|
+
id: UUID_STRING
|
|
704
|
+
});
|
|
705
|
+
var WithdrawSuggestionSchema = z2.object({
|
|
706
|
+
id: UUID_STRING
|
|
707
|
+
});
|
|
708
|
+
var GetCoachParametersSchema = z2.object({});
|
|
709
|
+
var SetCoachParametersSchema = CoachParametersSchema;
|
|
710
|
+
|
|
711
|
+
// src/crypto.ts
|
|
712
|
+
import { createDecipheriv } from "crypto";
|
|
713
|
+
function decrypt(encryptedJson, keyBase64) {
|
|
714
|
+
const { iv: ivB64, ct: ctB64 } = JSON.parse(encryptedJson);
|
|
715
|
+
const key = Buffer.from(keyBase64, "base64");
|
|
716
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
717
|
+
const ctBuf = Buffer.from(ctB64, "base64");
|
|
718
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
719
|
+
decipher.setAuthTag(ctBuf.subarray(-16));
|
|
720
|
+
const plaintext = Buffer.concat([
|
|
721
|
+
decipher.update(ctBuf.subarray(0, -16)),
|
|
722
|
+
// actual ciphertext (without tag)
|
|
723
|
+
decipher.final()
|
|
724
|
+
// throws on GCM auth failure
|
|
725
|
+
]);
|
|
726
|
+
return plaintext.toString("utf8");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/cache.ts
|
|
730
|
+
var TTL_MS = 6e4;
|
|
731
|
+
var cached = null;
|
|
732
|
+
var inflightPromise = null;
|
|
733
|
+
function decryptSession(s, keyB642) {
|
|
734
|
+
const { encryptedPayload, startTime, endTime, ...rest } = s;
|
|
735
|
+
if (encryptedPayload) {
|
|
736
|
+
const d = JSON.parse(decrypt(encryptedPayload, keyB642));
|
|
737
|
+
return {
|
|
738
|
+
...rest,
|
|
739
|
+
startTime: d.st,
|
|
740
|
+
endTime: d.et > 0 ? d.et : void 0
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
return { ...rest, startTime, endTime };
|
|
744
|
+
}
|
|
745
|
+
function decryptSetLog(sl, keyB642) {
|
|
746
|
+
const {
|
|
747
|
+
encryptedPayload,
|
|
748
|
+
completedReps,
|
|
749
|
+
completedTimeSeconds,
|
|
750
|
+
weightUsed,
|
|
751
|
+
startedAt,
|
|
752
|
+
measuredTimeSeconds,
|
|
753
|
+
...rest
|
|
754
|
+
} = sl;
|
|
755
|
+
if (encryptedPayload) {
|
|
756
|
+
const d = JSON.parse(decrypt(encryptedPayload, keyB642));
|
|
757
|
+
return {
|
|
758
|
+
...rest,
|
|
759
|
+
completedReps: d.r ?? null,
|
|
760
|
+
completedTimeSeconds: d.t ?? null,
|
|
761
|
+
weightUsed: d.w ?? null,
|
|
762
|
+
startedAt: d.sa ?? null,
|
|
763
|
+
measuredTimeSeconds: d.mt ?? null
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
...rest,
|
|
768
|
+
completedReps: completedReps ?? null,
|
|
769
|
+
completedTimeSeconds: completedTimeSeconds ?? null,
|
|
770
|
+
weightUsed: weightUsed ?? null,
|
|
771
|
+
startedAt: startedAt ?? null,
|
|
772
|
+
measuredTimeSeconds: measuredTimeSeconds ?? null
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function decryptHrSample(hr, keyB642) {
|
|
776
|
+
const { encryptedPayload, timestampMs, bpm, ...rest } = hr;
|
|
777
|
+
if (encryptedPayload) {
|
|
778
|
+
const d = JSON.parse(decrypt(encryptedPayload, keyB642));
|
|
779
|
+
return { ...rest, timestampMs: d.ts, bpm: d.bpm };
|
|
780
|
+
}
|
|
781
|
+
return { ...rest, timestampMs, bpm };
|
|
782
|
+
}
|
|
783
|
+
function decryptPlannedWorkout(row, keyB642) {
|
|
784
|
+
if (!row.encryptedPayload) {
|
|
785
|
+
throw new Error(
|
|
786
|
+
`planned_workout row ${row.id} arrived with an empty encryptedPayload \u2014 this is the tombstone shape the server should already filter out of plannedWorkouts (133 D-03); the pull contract may have changed`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
const plaintext = JSON.parse(decrypt(row.encryptedPayload, keyB642));
|
|
790
|
+
if (plaintext.v !== 1) {
|
|
791
|
+
throw new Error(`planned_workout row ${row.id} has unknown schema version v=${plaintext.v}`);
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
id: plaintext.id,
|
|
795
|
+
templateId: plaintext.templateId,
|
|
796
|
+
scheduledDate: plaintext.scheduledDate,
|
|
797
|
+
scheduledTime: plaintext.scheduledTime ?? null,
|
|
798
|
+
note: plaintext.note ?? null,
|
|
799
|
+
recurrenceRule: plaintext.recurrenceRule ?? null,
|
|
800
|
+
recurrenceGroupId: plaintext.recurrenceGroupId ?? null,
|
|
801
|
+
deletedOccurrencesRaw: plaintext.deletedOccurrences ?? null,
|
|
802
|
+
completedSessionId: plaintext.completedSessionId ?? null
|
|
803
|
+
// calendarEventId, createdAt: D-06 — deliberately not copied; the type has no
|
|
804
|
+
// field to carry them.
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function decryptSetting(row, keyB642) {
|
|
808
|
+
if (!row.encryptedPayload) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`settings row ${row.id} (key "${row.clientLocalId}") arrived with an empty encryptedPayload \u2014 this is the tombstone shape the server should already filter out of settings; the pull contract may have changed`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const plaintext = JSON.parse(decrypt(row.encryptedPayload, keyB642));
|
|
814
|
+
if (plaintext.v !== 1) {
|
|
815
|
+
throw new Error(`settings row ${row.id} (key "${row.clientLocalId}") has unknown schema version v=${plaintext.v}`);
|
|
816
|
+
}
|
|
817
|
+
if (!plaintext.t) {
|
|
818
|
+
throw new Error(`settings row ${row.id} (key "${row.clientLocalId}") is missing its type tag`);
|
|
819
|
+
}
|
|
820
|
+
return { key: row.clientLocalId, type: plaintext.t, value: plaintext.value };
|
|
821
|
+
}
|
|
822
|
+
function normalizeWireBlock(b) {
|
|
823
|
+
return { ...b, rounds: b.rounds ?? 1 };
|
|
824
|
+
}
|
|
825
|
+
function normalizeWireTemplateExercise(te) {
|
|
826
|
+
return { ...te, sets: te.sets ?? 1, exerciseSource: te.exerciseSource ?? "CATALOG" };
|
|
827
|
+
}
|
|
828
|
+
function decryptMerge(raw, keyB642) {
|
|
829
|
+
return {
|
|
830
|
+
syncedAt: raw.syncedAt,
|
|
831
|
+
exercises: raw.exercises,
|
|
832
|
+
exerciseTranslations: raw.exerciseTranslations,
|
|
833
|
+
templates: raw.templates,
|
|
834
|
+
blocks: raw.blocks.map(normalizeWireBlock),
|
|
835
|
+
templateExercises: raw.templateExercises.map(normalizeWireTemplateExercise),
|
|
836
|
+
sessions: raw.sessions.map((s) => decryptSession(s, keyB642)),
|
|
837
|
+
setLogs: raw.setLogs.map((sl) => decryptSetLog(sl, keyB642)),
|
|
838
|
+
hrSamples: raw.hrSamples.map((hr) => decryptHrSample(hr, keyB642)),
|
|
839
|
+
// `?? []` is what makes the optional wire key (SyncPullResponse.plannedWorkouts)
|
|
840
|
+
// safe for a user with no planned workouts — see that field's own doc in types.ts.
|
|
841
|
+
plannedWorkouts: (raw.plannedWorkouts ?? []).map((pw) => decryptPlannedWorkout(pw, keyB642)),
|
|
842
|
+
// `?? []` — same reasoning as plannedWorkouts above, for a user with no synced settings.
|
|
843
|
+
settings: (raw.settings ?? []).map((s) => decryptSetting(s, keyB642))
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function normalizeCatalog(wireCatalog) {
|
|
847
|
+
return wireCatalog.map((ex) => ({
|
|
848
|
+
...ex,
|
|
849
|
+
capabilities: ex.capabilities ?? [],
|
|
850
|
+
equipment: ex.equipment ?? [],
|
|
851
|
+
isSkill: ex.isSkill ?? false
|
|
852
|
+
}));
|
|
853
|
+
}
|
|
854
|
+
async function doFetch(pat2, keyB642, serverUrl2) {
|
|
855
|
+
const [rawSnapshot, profile, rawCatalog] = await Promise.all([
|
|
856
|
+
fetchSnapshot(pat2, serverUrl2),
|
|
857
|
+
fetchProfile(pat2, serverUrl2),
|
|
858
|
+
fetchCatalog(serverUrl2)
|
|
859
|
+
]);
|
|
860
|
+
const snapshot = decryptMerge(rawSnapshot, keyB642);
|
|
861
|
+
const catalog = normalizeCatalog(rawCatalog);
|
|
862
|
+
return { snapshot, catalog, profile, fetchedAt: Date.now() };
|
|
863
|
+
}
|
|
864
|
+
async function getSnapshot(pat2, keyB642, serverUrl2) {
|
|
865
|
+
const now = Date.now();
|
|
866
|
+
if (cached && now - cached.fetchedAt < TTL_MS) return cached;
|
|
867
|
+
if (inflightPromise) return inflightPromise;
|
|
868
|
+
inflightPromise = doFetch(pat2, keyB642, serverUrl2).finally(() => {
|
|
869
|
+
inflightPromise = null;
|
|
870
|
+
});
|
|
871
|
+
cached = await inflightPromise;
|
|
872
|
+
return cached;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/file-channel.ts
|
|
876
|
+
import { mkdir, readdir, stat, unlink, writeFile } from "fs/promises";
|
|
877
|
+
import { randomUUID } from "crypto";
|
|
878
|
+
import { tmpdir } from "os";
|
|
879
|
+
import { join } from "path";
|
|
880
|
+
var EXPORT_DIR = join(tmpdir(), "calicomp-mcp-exports");
|
|
881
|
+
var EXPORT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
882
|
+
async function writeExportFile(payload, itemCount, dir = EXPORT_DIR) {
|
|
883
|
+
await mkdir(dir, { recursive: true });
|
|
884
|
+
const json = JSON.stringify(payload);
|
|
885
|
+
const path = join(dir, `${randomUUID()}.json`);
|
|
886
|
+
await writeFile(path, json, { mode: 384 });
|
|
887
|
+
return { path, bytes: Buffer.byteLength(json), itemCount };
|
|
888
|
+
}
|
|
889
|
+
async function pruneExports(now = Date.now(), dir = EXPORT_DIR) {
|
|
890
|
+
let entries;
|
|
891
|
+
try {
|
|
892
|
+
entries = await readdir(dir);
|
|
893
|
+
} catch {
|
|
894
|
+
return 0;
|
|
895
|
+
}
|
|
896
|
+
let deleted = 0;
|
|
897
|
+
for (const entry of entries) {
|
|
898
|
+
const entryPath = join(dir, entry);
|
|
899
|
+
try {
|
|
900
|
+
const info = await stat(entryPath);
|
|
901
|
+
if (now - info.mtimeMs > EXPORT_TTL_MS) {
|
|
902
|
+
await unlink(entryPath);
|
|
903
|
+
deleted++;
|
|
904
|
+
}
|
|
905
|
+
} catch {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return deleted;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/tools/get_history.ts
|
|
913
|
+
function getHistory(args, snapshot) {
|
|
914
|
+
const { from, to, exercise, limit } = args;
|
|
915
|
+
const fromEpoch = new Date(from).getTime();
|
|
916
|
+
const toEpoch = (/* @__PURE__ */ new Date(to + "T23:59:59.999Z")).getTime();
|
|
917
|
+
let sessions = snapshot.sessions.filter(
|
|
918
|
+
(s) => s.startTime >= fromEpoch && s.startTime <= toEpoch
|
|
919
|
+
);
|
|
920
|
+
if (exercise !== void 0) {
|
|
921
|
+
const sessionIdsWithExercise = new Set(
|
|
922
|
+
snapshot.setLogs.filter((sl) => sl.exerciseId === exercise).map((sl) => sl.sessionId)
|
|
923
|
+
);
|
|
924
|
+
sessions = sessions.filter((s) => sessionIdsWithExercise.has(s.id));
|
|
925
|
+
}
|
|
926
|
+
sessions = [...sessions].sort((a, b) => b.startTime - a.startTime);
|
|
927
|
+
sessions = sessions.slice(0, limit);
|
|
928
|
+
const retainedSessionIds = new Set(sessions.map((s) => s.id));
|
|
929
|
+
const setLogs = snapshot.setLogs.filter(
|
|
930
|
+
(sl) => retainedSessionIds.has(sl.sessionId)
|
|
931
|
+
);
|
|
932
|
+
return { sessions, setLogs };
|
|
933
|
+
}
|
|
934
|
+
function registerToolGetHistory(server2, cfg) {
|
|
935
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
936
|
+
server2.registerTool(
|
|
937
|
+
"get_history",
|
|
938
|
+
{
|
|
939
|
+
title: "Get Workout History",
|
|
940
|
+
description: "Return sessions and set-logs within a date range (YYYY-MM-DD), newest-first, for a TARGETED detail lookup \u2014 a specific window, a specific exercise. For the athlete's overall standing, call `get_training_state` first; use this tool once you need the underlying raw sessions and set-logs. Optionally filter by exercise UUID. Results are capped by `limit` (default 200, max 500). Set `outputFile: true` to write the full result to a local file instead of returning it inline \u2014 the tool result then reports only the file path, byte size and item count.",
|
|
941
|
+
inputSchema: GetHistorySchema,
|
|
942
|
+
annotations: { readOnlyHint: true }
|
|
943
|
+
},
|
|
944
|
+
async (args) => {
|
|
945
|
+
try {
|
|
946
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
947
|
+
const result = getHistory(args, data.snapshot);
|
|
948
|
+
if (args.outputFile) {
|
|
949
|
+
const itemCount = result.sessions.length + result.setLogs.length;
|
|
950
|
+
const handle = await writeExportFile(result, itemCount);
|
|
951
|
+
const summary = {
|
|
952
|
+
outputFile: { path: handle.path, bytes: handle.bytes, itemCount: handle.itemCount },
|
|
953
|
+
from: args.from,
|
|
954
|
+
to: args.to,
|
|
955
|
+
exercise: args.exercise,
|
|
956
|
+
limit: args.limit
|
|
957
|
+
};
|
|
958
|
+
return { content: [{ type: "text", text: JSON.stringify(summary) }] };
|
|
959
|
+
}
|
|
960
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
961
|
+
} catch (err) {
|
|
962
|
+
return {
|
|
963
|
+
isError: true,
|
|
964
|
+
content: [{ type: "text", text: `History error: ${err.message}` }]
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/e1rm.ts
|
|
972
|
+
var REP_CAP = 12;
|
|
973
|
+
function setE1rm(weightKg, reps) {
|
|
974
|
+
const w = weightKg ?? 0;
|
|
975
|
+
const r = reps ?? 0;
|
|
976
|
+
if (w <= 0 || r < 1) return null;
|
|
977
|
+
return w * (1 + Math.min(r, REP_CAP) / 30);
|
|
978
|
+
}
|
|
979
|
+
function bestE1rm(sets) {
|
|
980
|
+
let best = null;
|
|
981
|
+
for (const s of sets) {
|
|
982
|
+
const e = setE1rm(s.weightUsed, s.completedReps);
|
|
983
|
+
if (e !== null && (best === null || e > best)) best = e;
|
|
984
|
+
}
|
|
985
|
+
return best;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/training-state/muscle-balance.ts
|
|
989
|
+
var MUSCLE_TO_RADAR_CATEGORY = {
|
|
990
|
+
chest: "chest",
|
|
991
|
+
back: "back",
|
|
992
|
+
lats: "back",
|
|
993
|
+
traps: "back",
|
|
994
|
+
lower_back: "back",
|
|
995
|
+
neck: "back",
|
|
996
|
+
quads: "legs",
|
|
997
|
+
hamstrings: "legs",
|
|
998
|
+
glutes: "legs",
|
|
999
|
+
calves: "legs",
|
|
1000
|
+
adductors: "legs",
|
|
1001
|
+
hip_flexors: "legs",
|
|
1002
|
+
shoulders: "shoulders",
|
|
1003
|
+
biceps: "arms",
|
|
1004
|
+
triceps: "arms",
|
|
1005
|
+
forearms: "arms",
|
|
1006
|
+
abs: "core",
|
|
1007
|
+
obliques: "core"
|
|
1008
|
+
};
|
|
1009
|
+
var RADAR_CATEGORIES = ["chest", "shoulders", "back", "legs", "core", "arms"];
|
|
1010
|
+
function toRadarValues(muscleSetCounts) {
|
|
1011
|
+
const categorySums = {};
|
|
1012
|
+
for (const category of RADAR_CATEGORIES) categorySums[category] = 0;
|
|
1013
|
+
for (const [muscleKey, sets] of Object.entries(muscleSetCounts)) {
|
|
1014
|
+
const category = MUSCLE_TO_RADAR_CATEGORY[muscleKey];
|
|
1015
|
+
if (category === void 0) continue;
|
|
1016
|
+
categorySums[category] = (categorySums[category] ?? 0) + sets;
|
|
1017
|
+
}
|
|
1018
|
+
const maxVal = Math.max(...RADAR_CATEGORIES.map((category) => categorySums[category]));
|
|
1019
|
+
if (maxVal === 0) {
|
|
1020
|
+
const zeroed = {};
|
|
1021
|
+
for (const category of RADAR_CATEGORIES) zeroed[category] = 0;
|
|
1022
|
+
return zeroed;
|
|
1023
|
+
}
|
|
1024
|
+
const result = {};
|
|
1025
|
+
for (const category of RADAR_CATEGORIES) result[category] = categorySums[category] / maxVal;
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
var WEIGHT_PRIMARY = 1;
|
|
1029
|
+
var WEIGHT_SECONDARY = 0.5;
|
|
1030
|
+
var WEIGHT_STABILIZER = 0.25;
|
|
1031
|
+
function weightFor(level) {
|
|
1032
|
+
switch (level) {
|
|
1033
|
+
case "SECONDARY":
|
|
1034
|
+
return WEIGHT_SECONDARY;
|
|
1035
|
+
case "STABILIZER":
|
|
1036
|
+
return WEIGHT_STABILIZER;
|
|
1037
|
+
case "PRIMARY":
|
|
1038
|
+
case null:
|
|
1039
|
+
case void 0:
|
|
1040
|
+
return WEIGHT_PRIMARY;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function muscleSetCountsInPeriod(snapshot, catalog, sinceMs) {
|
|
1044
|
+
const catalogById = new Map(catalog.map((ex) => [ex.id, ex]));
|
|
1045
|
+
const templateExerciseById = new Map(snapshot.templateExercises.map((te) => [te.id, te]));
|
|
1046
|
+
const sessionById = new Map(snapshot.sessions.map((s) => [s.id, s]));
|
|
1047
|
+
const counts = {};
|
|
1048
|
+
for (const setLog of snapshot.setLogs) {
|
|
1049
|
+
if (setLog.deletedAt !== void 0) continue;
|
|
1050
|
+
const session = sessionById.get(setLog.sessionId);
|
|
1051
|
+
if (session === void 0) continue;
|
|
1052
|
+
if (session.deletedAt !== void 0) continue;
|
|
1053
|
+
if (session.endTime === void 0) continue;
|
|
1054
|
+
if (setLog.createdAt < sinceMs) continue;
|
|
1055
|
+
const exerciseId = setLog.exerciseId ?? (setLog.workoutExerciseId !== void 0 ? templateExerciseById.get(setLog.workoutExerciseId)?.exerciseId : void 0);
|
|
1056
|
+
if (exerciseId === void 0) continue;
|
|
1057
|
+
const exercise = catalogById.get(exerciseId);
|
|
1058
|
+
if (exercise === void 0) continue;
|
|
1059
|
+
for (const muscleGroup of exercise.muscleGroups) {
|
|
1060
|
+
counts[muscleGroup.key] = (counts[muscleGroup.key] ?? 0) + weightFor(muscleGroup.involvementLevel);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return counts;
|
|
1064
|
+
}
|
|
1065
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
1066
|
+
function computeMuscleBalance(snapshot, catalog, nowMs) {
|
|
1067
|
+
const windowFor = (sinceMs) => {
|
|
1068
|
+
const setCounts = muscleSetCountsInPeriod(snapshot, catalog, sinceMs);
|
|
1069
|
+
return { setCounts, radar: toRadarValues(setCounts) };
|
|
1070
|
+
};
|
|
1071
|
+
return {
|
|
1072
|
+
last7Days: windowFor(nowMs - 7 * MS_PER_DAY),
|
|
1073
|
+
last30Days: windowFor(nowMs - 30 * MS_PER_DAY),
|
|
1074
|
+
allTime: windowFor(0)
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/training-state/capability-balance.ts
|
|
1079
|
+
var WEIGHT_HAUPTREIZ = 1;
|
|
1080
|
+
var WEIGHT_MITTRAINIERT = 0.5;
|
|
1081
|
+
var WEIGHT_GERING = 0.25;
|
|
1082
|
+
function weightFor2(level) {
|
|
1083
|
+
switch (level) {
|
|
1084
|
+
case "HAUPTREIZ":
|
|
1085
|
+
return WEIGHT_HAUPTREIZ;
|
|
1086
|
+
case "MITTRAINIERT":
|
|
1087
|
+
return WEIGHT_MITTRAINIERT;
|
|
1088
|
+
case "GERING":
|
|
1089
|
+
return WEIGHT_GERING;
|
|
1090
|
+
case null:
|
|
1091
|
+
case void 0:
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
"capability-balance.weightFor: level must not be null/undefined \u2014 a present exercise_capabilities row always carries an explicit level (D-04 of Phase 138.1). A missing level here is a bug, not a valid default."
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function weightedCounts(rows) {
|
|
1098
|
+
const sums = {};
|
|
1099
|
+
for (const row of rows) {
|
|
1100
|
+
const contribution = row.setCount * weightFor2(row.capabilityLevel);
|
|
1101
|
+
sums[row.capabilityAxisKey] = (sums[row.capabilityAxisKey] ?? 0) + contribution;
|
|
1102
|
+
}
|
|
1103
|
+
return sums;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/tools/get_stats.ts
|
|
1107
|
+
function computeAggregates(matchingSetLogs, snapshot) {
|
|
1108
|
+
if (matchingSetLogs.length === 0) {
|
|
1109
|
+
return {
|
|
1110
|
+
bestE1rm: null,
|
|
1111
|
+
totalVolume: 0,
|
|
1112
|
+
avgVolumePerSession: 0,
|
|
1113
|
+
setCount: 0,
|
|
1114
|
+
sessionCount: 0,
|
|
1115
|
+
firstSessionDate: null,
|
|
1116
|
+
lastSessionDate: null,
|
|
1117
|
+
trend: null
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
const touchedSessionIds = new Set(matchingSetLogs.map((sl) => sl.sessionId));
|
|
1121
|
+
const touchedSessions = snapshot.sessions.filter((s) => touchedSessionIds.has(s.id)).sort((a, b) => a.startTime - b.startTime);
|
|
1122
|
+
const sessionCount = touchedSessions.length;
|
|
1123
|
+
const overallBestE1rm = bestE1rm(matchingSetLogs);
|
|
1124
|
+
let totalVolume = 0;
|
|
1125
|
+
for (const sl of matchingSetLogs) {
|
|
1126
|
+
const w = sl.weightUsed;
|
|
1127
|
+
const r = sl.completedReps;
|
|
1128
|
+
if (w !== null && w > 0 && r !== null && r > 0) {
|
|
1129
|
+
totalVolume += r * w;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const avgVolumePerSession = sessionCount > 0 ? totalVolume / sessionCount : 0;
|
|
1133
|
+
const firstSession = touchedSessions[0];
|
|
1134
|
+
const lastSession = touchedSessions[touchedSessions.length - 1];
|
|
1135
|
+
const firstSessionDate = firstSession ? new Date(firstSession.startTime).toISOString().slice(0, 10) : null;
|
|
1136
|
+
const lastSessionDate = lastSession ? new Date(lastSession.startTime).toISOString().slice(0, 10) : null;
|
|
1137
|
+
let trend = null;
|
|
1138
|
+
if (touchedSessions.length > 0) {
|
|
1139
|
+
const points = touchedSessions.map((session) => {
|
|
1140
|
+
const sessionLogs = matchingSetLogs.filter((sl) => sl.sessionId === session.id);
|
|
1141
|
+
return {
|
|
1142
|
+
date: new Date(session.startTime).toISOString().slice(0, 10),
|
|
1143
|
+
sessionId: session.id,
|
|
1144
|
+
e1rm: bestE1rm(sessionLogs)
|
|
1145
|
+
};
|
|
1146
|
+
});
|
|
1147
|
+
const firstE1rm = points[0].e1rm;
|
|
1148
|
+
const lastE1rm = points[points.length - 1].e1rm;
|
|
1149
|
+
const delta = firstE1rm !== null && lastE1rm !== null ? lastE1rm - firstE1rm : null;
|
|
1150
|
+
trend = { firstE1rm, lastE1rm, delta, points };
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
bestE1rm: overallBestE1rm,
|
|
1154
|
+
totalVolume,
|
|
1155
|
+
avgVolumePerSession,
|
|
1156
|
+
setCount: matchingSetLogs.length,
|
|
1157
|
+
sessionCount,
|
|
1158
|
+
firstSessionDate,
|
|
1159
|
+
lastSessionDate,
|
|
1160
|
+
trend
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function getStatsByExercise(args, data) {
|
|
1164
|
+
const matchingSetLogs = data.snapshot.setLogs.filter(
|
|
1165
|
+
(sl) => sl.exerciseId === args.exerciseId
|
|
1166
|
+
);
|
|
1167
|
+
return {
|
|
1168
|
+
by: "exercise",
|
|
1169
|
+
exerciseId: args.exerciseId,
|
|
1170
|
+
...computeAggregates(matchingSetLogs, data.snapshot)
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
function getStatsByMuscle(args, data) {
|
|
1174
|
+
const matchingExerciseIds = [];
|
|
1175
|
+
const weightByExerciseId = /* @__PURE__ */ new Map();
|
|
1176
|
+
for (const ex of data.catalog) {
|
|
1177
|
+
const muscleGroup = ex.muscleGroups.find((mg) => mg.key === args.muscle);
|
|
1178
|
+
if (muscleGroup === void 0) continue;
|
|
1179
|
+
matchingExerciseIds.push(ex.id);
|
|
1180
|
+
weightByExerciseId.set(ex.id, weightFor(muscleGroup.involvementLevel));
|
|
1181
|
+
}
|
|
1182
|
+
const matchingExerciseIdSet = new Set(matchingExerciseIds);
|
|
1183
|
+
const matchingSetLogs = data.snapshot.setLogs.filter(
|
|
1184
|
+
(sl) => sl.exerciseId !== void 0 && matchingExerciseIdSet.has(sl.exerciseId)
|
|
1185
|
+
);
|
|
1186
|
+
let weightedSetCount = 0;
|
|
1187
|
+
for (const sl of matchingSetLogs) {
|
|
1188
|
+
weightedSetCount += weightByExerciseId.get(sl.exerciseId) ?? 0;
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
by: "muscle",
|
|
1192
|
+
muscle: args.muscle,
|
|
1193
|
+
matchingExerciseIds,
|
|
1194
|
+
weightedSetCount,
|
|
1195
|
+
...computeAggregates(matchingSetLogs, data.snapshot)
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function getStatsByCapabilities(data) {
|
|
1199
|
+
const axisNames = /* @__PURE__ */ new Map();
|
|
1200
|
+
const capabilitiesByExerciseId = /* @__PURE__ */ new Map();
|
|
1201
|
+
for (const ex of data.catalog) {
|
|
1202
|
+
if (ex.capabilities.length === 0) continue;
|
|
1203
|
+
capabilitiesByExerciseId.set(ex.id, ex.capabilities);
|
|
1204
|
+
for (const axis of ex.capabilities) {
|
|
1205
|
+
if (axisNames.has(axis.key)) continue;
|
|
1206
|
+
const nameEn = axis.translations.find((t) => t.languageCode === "en")?.name ?? axis.key;
|
|
1207
|
+
const nameDe = axis.translations.find((t) => t.languageCode === "de")?.name ?? axis.key;
|
|
1208
|
+
axisNames.set(axis.key, { nameEn, nameDe });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const accByAxis = /* @__PURE__ */ new Map();
|
|
1212
|
+
const accumulatorFor = (key) => {
|
|
1213
|
+
let acc = accByAxis.get(key);
|
|
1214
|
+
if (acc === void 0) {
|
|
1215
|
+
acc = { setCount: 0, sessionIds: /* @__PURE__ */ new Set(), exerciseIds: /* @__PURE__ */ new Set(), lastSessionStart: null };
|
|
1216
|
+
accByAxis.set(key, acc);
|
|
1217
|
+
}
|
|
1218
|
+
return acc;
|
|
1219
|
+
};
|
|
1220
|
+
const allRows = [];
|
|
1221
|
+
const sessionById = new Map(data.snapshot.sessions.map((s) => [s.id, s]));
|
|
1222
|
+
for (const setLog of data.snapshot.setLogs) {
|
|
1223
|
+
if (setLog.exerciseId === void 0) continue;
|
|
1224
|
+
const axes2 = capabilitiesByExerciseId.get(setLog.exerciseId);
|
|
1225
|
+
if (axes2 === void 0) continue;
|
|
1226
|
+
const session = sessionById.get(setLog.sessionId);
|
|
1227
|
+
for (const axis of axes2) {
|
|
1228
|
+
allRows.push({ capabilityAxisKey: axis.key, capabilityLevel: axis.capabilityLevel, setCount: 1 });
|
|
1229
|
+
const acc = accumulatorFor(axis.key);
|
|
1230
|
+
acc.setCount += 1;
|
|
1231
|
+
acc.exerciseIds.add(setLog.exerciseId);
|
|
1232
|
+
if (session !== void 0) {
|
|
1233
|
+
acc.sessionIds.add(session.id);
|
|
1234
|
+
if (acc.lastSessionStart === null || session.startTime > acc.lastSessionStart) {
|
|
1235
|
+
acc.lastSessionStart = session.startTime;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const weightedByAxis = weightedCounts(allRows);
|
|
1241
|
+
const axes = [];
|
|
1242
|
+
for (const [key, names] of axisNames) {
|
|
1243
|
+
const acc = accByAxis.get(key);
|
|
1244
|
+
axes.push({
|
|
1245
|
+
key,
|
|
1246
|
+
nameEn: names.nameEn,
|
|
1247
|
+
nameDe: names.nameDe,
|
|
1248
|
+
setCount: acc?.setCount ?? 0,
|
|
1249
|
+
weightedSetCount: weightedByAxis[key] ?? 0,
|
|
1250
|
+
sessionCount: acc?.sessionIds.size ?? 0,
|
|
1251
|
+
contributingExerciseIds: acc !== void 0 ? [...acc.exerciseIds] : [],
|
|
1252
|
+
lastSessionDate: acc?.lastSessionStart !== null && acc?.lastSessionStart !== void 0 ? new Date(acc.lastSessionStart).toISOString().slice(0, 10) : null
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
return { by: "capabilities", axes };
|
|
1256
|
+
}
|
|
1257
|
+
function getStats(args, data) {
|
|
1258
|
+
if (args.by === "exercise") {
|
|
1259
|
+
return getStatsByExercise(args, data);
|
|
1260
|
+
}
|
|
1261
|
+
if (args.by === "muscle") {
|
|
1262
|
+
return getStatsByMuscle(args, data);
|
|
1263
|
+
}
|
|
1264
|
+
return getStatsByCapabilities(data);
|
|
1265
|
+
}
|
|
1266
|
+
function registerToolGetStats(server2, cfg) {
|
|
1267
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1268
|
+
server2.registerTool(
|
|
1269
|
+
"get_stats",
|
|
1270
|
+
{
|
|
1271
|
+
title: "Get Training Stats",
|
|
1272
|
+
description: 'Compute app-consistent training statistics (e1RM, volume, set/session counts, trend) for a specific exercise (`by: "exercise"`) or all exercises targeting a muscle group (`by: "muscle"`), or break down over every capability axis at once with no further argument (`by: "capabilities"` \u2014 balance, mobility, breath, etc.). e1RM uses the same Epley formula as the app. `trend.points` carries the full per-session e1RM series alongside the existing first-vs-last delta.',
|
|
1273
|
+
inputSchema: GetStatsSchema,
|
|
1274
|
+
annotations: { readOnlyHint: true }
|
|
1275
|
+
},
|
|
1276
|
+
async (args) => {
|
|
1277
|
+
try {
|
|
1278
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1279
|
+
const result = getStats(args, {
|
|
1280
|
+
snapshot: data.snapshot,
|
|
1281
|
+
catalog: data.catalog
|
|
1282
|
+
});
|
|
1283
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
return {
|
|
1286
|
+
isError: true,
|
|
1287
|
+
content: [{ type: "text", text: `Stats error: ${err.message}` }]
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/tools/get_profile.ts
|
|
1295
|
+
function getProfile(profile) {
|
|
1296
|
+
return profile;
|
|
1297
|
+
}
|
|
1298
|
+
function registerToolGetProfile(server2, cfg) {
|
|
1299
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1300
|
+
server2.registerTool(
|
|
1301
|
+
"get_profile",
|
|
1302
|
+
{
|
|
1303
|
+
title: "Get User Profile",
|
|
1304
|
+
description: "Return the authenticated user's profile: email, displayName, avatarUrl, isPremium, and account createdAt epoch.",
|
|
1305
|
+
inputSchema: GetProfileSchema,
|
|
1306
|
+
annotations: { readOnlyHint: true }
|
|
1307
|
+
},
|
|
1308
|
+
async () => {
|
|
1309
|
+
try {
|
|
1310
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1311
|
+
const result = getProfile(data.profile);
|
|
1312
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
return {
|
|
1315
|
+
isError: true,
|
|
1316
|
+
content: [{ type: "text", text: `Profile error: ${err.message}` }]
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/tools/list_templates.ts
|
|
1324
|
+
function listTemplates(snapshot) {
|
|
1325
|
+
return snapshot.templates.filter((t) => t.deletedAt == null).map((t) => ({
|
|
1326
|
+
id: t.id,
|
|
1327
|
+
name: t.name,
|
|
1328
|
+
isFavoriteForWatch: t.isFavoriteForWatch,
|
|
1329
|
+
blockCount: snapshot.blocks.filter((b) => b.templateId === t.id).length,
|
|
1330
|
+
exerciseCount: snapshot.templateExercises.filter((te) => te.templateId === t.id).length,
|
|
1331
|
+
// CR-01 (134-REVIEW.md): `!= null` catches both `undefined` (CLASSIC/pre-Phase-134 row)
|
|
1332
|
+
// and `null` (a post-migration row not yet re-synced — the server emits an explicit wire
|
|
1333
|
+
// `null`, see SyncTemplateDto's KDoc) — a strict `!== undefined` let `null` through verbatim.
|
|
1334
|
+
...t.workoutType != null ? { workoutType: t.workoutType } : {}
|
|
1335
|
+
}));
|
|
1336
|
+
}
|
|
1337
|
+
function registerToolListTemplates(server2, cfg) {
|
|
1338
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1339
|
+
server2.registerTool(
|
|
1340
|
+
"list_templates",
|
|
1341
|
+
{
|
|
1342
|
+
title: "List Workout Templates",
|
|
1343
|
+
description: "Return all non-deleted workout templates with their block and exercise counts. Use `get_template` to fetch a specific template's full detail (blocks + exercises).",
|
|
1344
|
+
inputSchema: ListTemplatesSchema,
|
|
1345
|
+
annotations: { readOnlyHint: true }
|
|
1346
|
+
},
|
|
1347
|
+
async () => {
|
|
1348
|
+
try {
|
|
1349
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1350
|
+
const result = listTemplates(data.snapshot);
|
|
1351
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
return {
|
|
1354
|
+
isError: true,
|
|
1355
|
+
content: [{ type: "text", text: `List templates error: ${err.message}` }]
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/tools/get_template.ts
|
|
1363
|
+
function getTemplate(args, snapshot) {
|
|
1364
|
+
const template = snapshot.templates.find((t) => t.id === args.templateId);
|
|
1365
|
+
if (!template) return null;
|
|
1366
|
+
const blocks = snapshot.blocks.filter(
|
|
1367
|
+
(b) => b.templateId === args.templateId
|
|
1368
|
+
);
|
|
1369
|
+
const templateExercises = snapshot.templateExercises.filter(
|
|
1370
|
+
(te) => te.templateId === args.templateId
|
|
1371
|
+
);
|
|
1372
|
+
return {
|
|
1373
|
+
id: template.id,
|
|
1374
|
+
name: template.name,
|
|
1375
|
+
isFavoriteForWatch: template.isFavoriteForWatch,
|
|
1376
|
+
createdAt: template.createdAt,
|
|
1377
|
+
updatedAt: template.updatedAt,
|
|
1378
|
+
...template.deletedAt !== void 0 ? { deletedAt: template.deletedAt } : {},
|
|
1379
|
+
// CR-01 (134-REVIEW.md): `!= null` catches BOTH `undefined` (a pre-Phase-134/CLASSIC row,
|
|
1380
|
+
// the key never existed) AND `null` (a post-migration row whose column has not been
|
|
1381
|
+
// (re-)written since deploy, the server emits an explicit JSON `null` — see
|
|
1382
|
+
// SyncTemplateDto's KDoc). A strict `!== undefined` check let a `null` through verbatim,
|
|
1383
|
+
// violating this projection's declared `string`-only optional type.
|
|
1384
|
+
...template.workoutType != null ? { workoutType: template.workoutType } : {},
|
|
1385
|
+
...template.formatParams != null ? { formatParams: template.formatParams } : {},
|
|
1386
|
+
blocks,
|
|
1387
|
+
templateExercises
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function registerToolGetTemplate(server2, cfg) {
|
|
1391
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1392
|
+
server2.registerTool(
|
|
1393
|
+
"get_template",
|
|
1394
|
+
{
|
|
1395
|
+
title: "Get Workout Template",
|
|
1396
|
+
description: "Return one workout template with its blocks and template-exercises joined. Pass the UUID from `list_templates`. Returns an error if the template is not found.",
|
|
1397
|
+
inputSchema: GetTemplateSchema,
|
|
1398
|
+
annotations: { readOnlyHint: true }
|
|
1399
|
+
},
|
|
1400
|
+
async (args) => {
|
|
1401
|
+
try {
|
|
1402
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1403
|
+
const result = getTemplate(args, data.snapshot);
|
|
1404
|
+
if (!result) {
|
|
1405
|
+
return {
|
|
1406
|
+
isError: true,
|
|
1407
|
+
content: [{ type: "text", text: `Template not found: ${args.templateId}` }]
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
return {
|
|
1413
|
+
isError: true,
|
|
1414
|
+
content: [{ type: "text", text: `Get template error: ${err.message}` }]
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/tools/get_exercise_catalog.ts
|
|
1422
|
+
function getExerciseCatalog(args, catalog) {
|
|
1423
|
+
if (!args.lang) {
|
|
1424
|
+
return catalog;
|
|
1425
|
+
}
|
|
1426
|
+
return catalog.map((ex) => {
|
|
1427
|
+
const translation = ex.translations.find((t) => t.languageCode === args.lang);
|
|
1428
|
+
return {
|
|
1429
|
+
...ex,
|
|
1430
|
+
localizedName: translation ? translation.name : ex.nameEn
|
|
1431
|
+
};
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
function registerToolGetExerciseCatalog(server2, cfg) {
|
|
1435
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1436
|
+
server2.registerTool(
|
|
1437
|
+
"get_exercise_catalog",
|
|
1438
|
+
{
|
|
1439
|
+
title: "Get Exercise Catalog",
|
|
1440
|
+
description: 'Return the full exercise catalog with muscle groups, equipment, and optional localized names. Provide `lang` (e.g. "de", "fr") to add a `localizedName` field (falls back to `nameEn` when no translation exists). Use `muscleGroups[].key` with `get_stats { by: "muscle" }` to query aggregates by muscle group.',
|
|
1441
|
+
inputSchema: GetExerciseCatalogSchema,
|
|
1442
|
+
annotations: { readOnlyHint: true }
|
|
1443
|
+
},
|
|
1444
|
+
async (args) => {
|
|
1445
|
+
try {
|
|
1446
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1447
|
+
const result = getExerciseCatalog(args, data.catalog);
|
|
1448
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1449
|
+
} catch (err) {
|
|
1450
|
+
return {
|
|
1451
|
+
isError: true,
|
|
1452
|
+
content: [{ type: "text", text: `Exercise catalog error: ${err.message}` }]
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/recurrence.ts
|
|
1460
|
+
var RRULE_DAY_ORDER = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
|
1461
|
+
function epochDay(date) {
|
|
1462
|
+
const yy = date.y - (date.m <= 2 ? 1 : 0);
|
|
1463
|
+
const era = Math.floor((yy >= 0 ? yy : yy - 399) / 400);
|
|
1464
|
+
const yoe = yy - era * 400;
|
|
1465
|
+
const doy = Math.floor((153 * (date.m + (date.m > 2 ? -3 : 9)) + 2) / 5) + date.d - 1;
|
|
1466
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
1467
|
+
return era * 146097 + doe - 719468;
|
|
1468
|
+
}
|
|
1469
|
+
function fromEpochDay(epoch) {
|
|
1470
|
+
const z3 = epoch + 719468;
|
|
1471
|
+
const era = Math.floor((z3 >= 0 ? z3 : z3 - 146096) / 146097);
|
|
1472
|
+
const doe = z3 - era * 146097;
|
|
1473
|
+
const yoe = Math.floor(
|
|
1474
|
+
(doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365
|
|
1475
|
+
);
|
|
1476
|
+
const y = yoe + era * 400;
|
|
1477
|
+
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
|
1478
|
+
const mp = Math.floor((5 * doy + 2) / 153);
|
|
1479
|
+
const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
|
1480
|
+
const m = mp + (mp < 10 ? 3 : -9);
|
|
1481
|
+
return { y: y + (m <= 2 ? 1 : 0), m, d };
|
|
1482
|
+
}
|
|
1483
|
+
function parseIsoDate(s) {
|
|
1484
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
1485
|
+
if (!match) throw new Error(`Invalid ISO date: "${s}"`);
|
|
1486
|
+
const y = Number(match[1]);
|
|
1487
|
+
const m = Number(match[2]);
|
|
1488
|
+
const d = Number(match[3]);
|
|
1489
|
+
if (m < 1 || m > 12) throw new Error(`Invalid ISO date: "${s}"`);
|
|
1490
|
+
if (d < 1 || d > lengthOfMonth(y, m)) throw new Error(`Invalid ISO date: "${s}"`);
|
|
1491
|
+
return { y, m, d };
|
|
1492
|
+
}
|
|
1493
|
+
function formatIsoDate(date) {
|
|
1494
|
+
const y = String(date.y).padStart(4, "0");
|
|
1495
|
+
const m = String(date.m).padStart(2, "0");
|
|
1496
|
+
const d = String(date.d).padStart(2, "0");
|
|
1497
|
+
return `${y}-${m}-${d}`;
|
|
1498
|
+
}
|
|
1499
|
+
function plusDays(date, n) {
|
|
1500
|
+
return fromEpochDay(epochDay(date) + n);
|
|
1501
|
+
}
|
|
1502
|
+
function plusWeeks(date, n) {
|
|
1503
|
+
return plusDays(date, n * 7);
|
|
1504
|
+
}
|
|
1505
|
+
function lengthOfMonth(y, m) {
|
|
1506
|
+
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
1507
|
+
}
|
|
1508
|
+
function plusMonths(date, n) {
|
|
1509
|
+
const totalMonths = date.y * 12 + (date.m - 1) + n;
|
|
1510
|
+
const y = Math.floor(totalMonths / 12);
|
|
1511
|
+
const m = (totalMonths % 12 + 12) % 12 + 1;
|
|
1512
|
+
const d = Math.min(date.d, lengthOfMonth(y, m));
|
|
1513
|
+
return { y, m, d };
|
|
1514
|
+
}
|
|
1515
|
+
function withDayOfMonth(date, day) {
|
|
1516
|
+
return { y: date.y, m: date.m, d: day };
|
|
1517
|
+
}
|
|
1518
|
+
function dayOfWeekIso(date) {
|
|
1519
|
+
const ed = epochDay(date);
|
|
1520
|
+
return ((ed + 3) % 7 + 7) % 7 + 1;
|
|
1521
|
+
}
|
|
1522
|
+
function mondayOf(date) {
|
|
1523
|
+
return plusDays(date, -(dayOfWeekIso(date) - 1));
|
|
1524
|
+
}
|
|
1525
|
+
function compareCalDate(a, b) {
|
|
1526
|
+
return epochDay(a) - epochDay(b);
|
|
1527
|
+
}
|
|
1528
|
+
function isWithinRange(date, start, end) {
|
|
1529
|
+
return compareCalDate(date, start) >= 0 && compareCalDate(date, end) <= 0;
|
|
1530
|
+
}
|
|
1531
|
+
function parseRRule(rrule) {
|
|
1532
|
+
const result = {};
|
|
1533
|
+
for (const part of rrule.split(";")) {
|
|
1534
|
+
const eqIdx = part.indexOf("=");
|
|
1535
|
+
if (eqIdx === -1) {
|
|
1536
|
+
result[part] = "";
|
|
1537
|
+
} else {
|
|
1538
|
+
result[part.slice(0, eqIdx)] = part.slice(eqIdx + 1);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return result;
|
|
1542
|
+
}
|
|
1543
|
+
function parseIntOrNull(s) {
|
|
1544
|
+
if (s === void 0) return null;
|
|
1545
|
+
if (!/^[+-]?\d+$/.test(s)) return null;
|
|
1546
|
+
const n = Number(s);
|
|
1547
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
1548
|
+
return n >= -2147483648 && n <= 2147483647 ? n : null;
|
|
1549
|
+
}
|
|
1550
|
+
function parseUntilDate(s) {
|
|
1551
|
+
const digits = s.slice(0, 8);
|
|
1552
|
+
if (!/^\d{8}$/.test(digits)) return null;
|
|
1553
|
+
const y = Number(digits.slice(0, 4));
|
|
1554
|
+
const m = Number(digits.slice(4, 6));
|
|
1555
|
+
const d = Number(digits.slice(6, 8));
|
|
1556
|
+
if (m < 1 || m > 12) return null;
|
|
1557
|
+
if (d < 1 || d > lengthOfMonth(y, m)) return null;
|
|
1558
|
+
return { y, m, d };
|
|
1559
|
+
}
|
|
1560
|
+
function withRootSuffix(message, rootId) {
|
|
1561
|
+
return rootId !== void 0 ? `${message} (root ${rootId})` : message;
|
|
1562
|
+
}
|
|
1563
|
+
var RecurrenceRuleError = class extends Error {
|
|
1564
|
+
code;
|
|
1565
|
+
rootId;
|
|
1566
|
+
constructor(message, code, rootId) {
|
|
1567
|
+
super(message);
|
|
1568
|
+
this.name = "RecurrenceRuleError";
|
|
1569
|
+
this.code = code;
|
|
1570
|
+
this.rootId = rootId;
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
function parseRRuleConfig(rrule) {
|
|
1574
|
+
if (rrule === null || rrule.trim() === "") {
|
|
1575
|
+
return { freq: "WEEKLY", interval: 1, byDay: [], until: null };
|
|
1576
|
+
}
|
|
1577
|
+
const params = parseRRule(rrule);
|
|
1578
|
+
const freq = params["FREQ"] ?? "WEEKLY";
|
|
1579
|
+
const interval = parseIntOrNull(params["INTERVAL"]) ?? 1;
|
|
1580
|
+
const byDayTokens = new Set(
|
|
1581
|
+
(params["BYDAY"] ?? "").split(",").map((t) => t.trim()).filter((t) => RRULE_DAY_ORDER.includes(t))
|
|
1582
|
+
);
|
|
1583
|
+
const byDay = RRULE_DAY_ORDER.filter((d) => byDayTokens.has(d));
|
|
1584
|
+
const untilDate = params["UNTIL"] !== void 0 ? parseUntilDate(params["UNTIL"]) : null;
|
|
1585
|
+
const until = untilDate !== null ? formatIsoDate(untilDate) : null;
|
|
1586
|
+
return { freq, interval, byDay, until };
|
|
1587
|
+
}
|
|
1588
|
+
function parseDeletedOccurrences(raw) {
|
|
1589
|
+
if (raw === null || raw.trim() === "") return [];
|
|
1590
|
+
try {
|
|
1591
|
+
let body = raw.trim();
|
|
1592
|
+
if (body.length >= 2 && body.startsWith("[") && body.endsWith("]")) {
|
|
1593
|
+
body = body.slice(1, -1);
|
|
1594
|
+
}
|
|
1595
|
+
const dates = [];
|
|
1596
|
+
for (const rawToken of body.split(",")) {
|
|
1597
|
+
let token = rawToken.trim();
|
|
1598
|
+
if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) {
|
|
1599
|
+
token = token.slice(1, -1);
|
|
1600
|
+
}
|
|
1601
|
+
if (token === "") continue;
|
|
1602
|
+
dates.push(formatIsoDate(parseIsoDate(token)));
|
|
1603
|
+
}
|
|
1604
|
+
return Array.from(new Set(dates)).sort();
|
|
1605
|
+
} catch {
|
|
1606
|
+
return [];
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
function expandDates(startDate, rrule, rangeStart, rangeEnd, deletedDates, rootId) {
|
|
1610
|
+
if (rrule === null || rrule.trim() === "") {
|
|
1611
|
+
if (isWithinRange(startDate, rangeStart, rangeEnd) && !deletedDates.has(formatIsoDate(startDate))) {
|
|
1612
|
+
return [startDate];
|
|
1613
|
+
}
|
|
1614
|
+
return [];
|
|
1615
|
+
}
|
|
1616
|
+
const params = parseRRule(rrule);
|
|
1617
|
+
const freq = params["FREQ"];
|
|
1618
|
+
if (freq === void 0) {
|
|
1619
|
+
return isWithinRange(startDate, rangeStart, rangeEnd) ? [startDate] : [];
|
|
1620
|
+
}
|
|
1621
|
+
const interval = parseIntOrNull(params["INTERVAL"]) ?? 1;
|
|
1622
|
+
if (interval < 1) {
|
|
1623
|
+
throw new RecurrenceRuleError(
|
|
1624
|
+
withRootSuffix(`Recurrence rule "${rrule}" has INTERVAL below 1 \u2014 refusing to expand (would loop forever)`, rootId),
|
|
1625
|
+
"INTERVAL_MUST_BE_POSITIVE",
|
|
1626
|
+
rootId
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
const byDay = new Set(
|
|
1630
|
+
(params["BYDAY"] ?? "").split(",").map((t) => t.trim()).filter((t) => RRULE_DAY_ORDER.includes(t))
|
|
1631
|
+
);
|
|
1632
|
+
const byMonthDay = parseIntOrNull(params["BYMONTHDAY"]) ?? void 0;
|
|
1633
|
+
const ruleUntil = params["UNTIL"] !== void 0 ? parseUntilDate(params["UNTIL"]) : null;
|
|
1634
|
+
const effectiveEnd = ruleUntil !== null && compareCalDate(ruleUntil, rangeEnd) < 0 ? ruleUntil : rangeEnd;
|
|
1635
|
+
const results = [];
|
|
1636
|
+
if (freq === "DAILY") {
|
|
1637
|
+
let current = startDate;
|
|
1638
|
+
while (compareCalDate(current, effectiveEnd) <= 0) {
|
|
1639
|
+
if (compareCalDate(current, rangeStart) >= 0 && !deletedDates.has(formatIsoDate(current))) {
|
|
1640
|
+
results.push(current);
|
|
1641
|
+
}
|
|
1642
|
+
current = plusDays(current, interval);
|
|
1643
|
+
}
|
|
1644
|
+
} else if (freq === "WEEKLY") {
|
|
1645
|
+
if (byDay.size === 0) {
|
|
1646
|
+
let current = startDate;
|
|
1647
|
+
while (compareCalDate(current, effectiveEnd) <= 0) {
|
|
1648
|
+
if (compareCalDate(current, rangeStart) >= 0 && !deletedDates.has(formatIsoDate(current))) {
|
|
1649
|
+
results.push(current);
|
|
1650
|
+
}
|
|
1651
|
+
current = plusWeeks(current, interval);
|
|
1652
|
+
}
|
|
1653
|
+
} else {
|
|
1654
|
+
let weekStart = mondayOf(startDate);
|
|
1655
|
+
while (compareCalDate(weekStart, effectiveEnd) <= 0) {
|
|
1656
|
+
for (let offset = 0; offset < RRULE_DAY_ORDER.length; offset++) {
|
|
1657
|
+
const token = RRULE_DAY_ORDER[offset];
|
|
1658
|
+
if (!byDay.has(token)) continue;
|
|
1659
|
+
const date = plusDays(weekStart, offset);
|
|
1660
|
+
if (compareCalDate(date, startDate) >= 0 && compareCalDate(date, effectiveEnd) <= 0 && compareCalDate(date, rangeStart) >= 0 && !deletedDates.has(formatIsoDate(date))) {
|
|
1661
|
+
results.push(date);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
weekStart = plusWeeks(weekStart, interval);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
} else if (freq === "MONTHLY") {
|
|
1668
|
+
if (byMonthDay !== void 0 && byMonthDay < 1) {
|
|
1669
|
+
throw new RecurrenceRuleError(
|
|
1670
|
+
withRootSuffix(`Recurrence rule "${rrule}" has BYMONTHDAY=${byMonthDay} below 1`, rootId),
|
|
1671
|
+
"BYMONTHDAY_OUT_OF_RANGE",
|
|
1672
|
+
rootId
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
const dayOfMonth = byMonthDay ?? startDate.d;
|
|
1676
|
+
let current = startDate;
|
|
1677
|
+
while (compareCalDate(current, effectiveEnd) <= 0) {
|
|
1678
|
+
const day = Math.min(dayOfMonth, lengthOfMonth(current.y, current.m));
|
|
1679
|
+
const date = withDayOfMonth(current, day);
|
|
1680
|
+
if (compareCalDate(date, startDate) >= 0 && compareCalDate(date, effectiveEnd) <= 0 && compareCalDate(date, rangeStart) >= 0 && !deletedDates.has(formatIsoDate(date))) {
|
|
1681
|
+
results.push(date);
|
|
1682
|
+
}
|
|
1683
|
+
current = plusMonths(current, interval);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
return results;
|
|
1687
|
+
}
|
|
1688
|
+
function epochMsToUtcCalDate(ms) {
|
|
1689
|
+
return fromEpochDay(Math.floor(ms / 864e5));
|
|
1690
|
+
}
|
|
1691
|
+
function renderSeriesFields(recurrenceRule) {
|
|
1692
|
+
return parseRRuleConfig(recurrenceRule);
|
|
1693
|
+
}
|
|
1694
|
+
function weekOffsetsForGroups(roots) {
|
|
1695
|
+
const offsets = /* @__PURE__ */ new Map();
|
|
1696
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1697
|
+
for (const root of roots) {
|
|
1698
|
+
if (root.recurrenceGroupId === null) {
|
|
1699
|
+
offsets.set(root.id, 0);
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const existing = groups.get(root.recurrenceGroupId);
|
|
1703
|
+
if (existing) {
|
|
1704
|
+
existing.push(root);
|
|
1705
|
+
} else {
|
|
1706
|
+
groups.set(root.recurrenceGroupId, [root]);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
for (const group of groups.values()) {
|
|
1710
|
+
const filtered = group.filter((r) => r.recurrenceRule !== null || group.length === 1);
|
|
1711
|
+
const sorted = (filtered.length > 0 ? filtered : group).slice().sort((a, b) => a.scheduledDate - b.scheduledDate);
|
|
1712
|
+
const refMonday = mondayOf(epochMsToUtcCalDate(sorted[0].scheduledDate));
|
|
1713
|
+
const refMondayEpoch = epochDay(refMonday);
|
|
1714
|
+
for (const root of group) {
|
|
1715
|
+
const rootMonday = mondayOf(epochMsToUtcCalDate(root.scheduledDate));
|
|
1716
|
+
offsets.set(root.id, (epochDay(rootMonday) - refMondayEpoch) / 7);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return offsets;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// src/tools/get_planned_workouts.ts
|
|
1723
|
+
function getPlannedWorkouts(args, snapshot) {
|
|
1724
|
+
const rangeStart = parseIsoDate(args.from);
|
|
1725
|
+
const rangeEnd = parseIsoDate(args.to);
|
|
1726
|
+
const templateNameById = /* @__PURE__ */ new Map();
|
|
1727
|
+
for (const t of snapshot.templates) {
|
|
1728
|
+
templateNameById.set(t.id, t.deletedAt == null ? t.name : null);
|
|
1729
|
+
}
|
|
1730
|
+
const weekOffsets = weekOffsetsForGroups(snapshot.plannedWorkouts);
|
|
1731
|
+
const occurrences = [];
|
|
1732
|
+
const rootIdsWithOccurrence = /* @__PURE__ */ new Set();
|
|
1733
|
+
for (const root of snapshot.plannedWorkouts) {
|
|
1734
|
+
const dtstart = epochMsToUtcCalDate(root.scheduledDate);
|
|
1735
|
+
const deletedOccurrences = parseDeletedOccurrences(root.deletedOccurrencesRaw);
|
|
1736
|
+
const dates = expandDates(
|
|
1737
|
+
dtstart,
|
|
1738
|
+
root.recurrenceRule,
|
|
1739
|
+
rangeStart,
|
|
1740
|
+
rangeEnd,
|
|
1741
|
+
new Set(deletedOccurrences),
|
|
1742
|
+
root.id
|
|
1743
|
+
);
|
|
1744
|
+
if (dates.length === 0) continue;
|
|
1745
|
+
rootIdsWithOccurrence.add(root.id);
|
|
1746
|
+
const isRecurring = root.recurrenceRule !== null && root.recurrenceRule.trim() !== "";
|
|
1747
|
+
const templateName = templateNameById.get(root.templateId) ?? null;
|
|
1748
|
+
for (const date of dates) {
|
|
1749
|
+
occurrences.push({
|
|
1750
|
+
date: formatIsoDate(date),
|
|
1751
|
+
rootId: root.id,
|
|
1752
|
+
templateId: root.templateId,
|
|
1753
|
+
templateName,
|
|
1754
|
+
scheduledTime: root.scheduledTime,
|
|
1755
|
+
note: root.note,
|
|
1756
|
+
recurrenceGroupId: root.recurrenceGroupId,
|
|
1757
|
+
isRecurring
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
occurrences.sort((a, b) => {
|
|
1762
|
+
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
|
|
1763
|
+
if (a.scheduledTime !== b.scheduledTime) {
|
|
1764
|
+
if (a.scheduledTime === null) return 1;
|
|
1765
|
+
if (b.scheduledTime === null) return -1;
|
|
1766
|
+
return a.scheduledTime < b.scheduledTime ? -1 : 1;
|
|
1767
|
+
}
|
|
1768
|
+
if (a.rootId !== b.rootId) return a.rootId < b.rootId ? -1 : 1;
|
|
1769
|
+
return 0;
|
|
1770
|
+
});
|
|
1771
|
+
const roots = snapshot.plannedWorkouts.filter((root) => rootIdsWithOccurrence.has(root.id)).map((root) => {
|
|
1772
|
+
const seriesFields = renderSeriesFields(root.recurrenceRule);
|
|
1773
|
+
return {
|
|
1774
|
+
id: root.id,
|
|
1775
|
+
templateId: root.templateId,
|
|
1776
|
+
scheduledDate: formatIsoDate(epochMsToUtcCalDate(root.scheduledDate)),
|
|
1777
|
+
scheduledTime: root.scheduledTime,
|
|
1778
|
+
note: root.note,
|
|
1779
|
+
recurrenceRule: root.recurrenceRule,
|
|
1780
|
+
recurrenceGroupId: root.recurrenceGroupId,
|
|
1781
|
+
freq: seriesFields.freq,
|
|
1782
|
+
interval: seriesFields.interval,
|
|
1783
|
+
byDay: seriesFields.byDay,
|
|
1784
|
+
until: seriesFields.until,
|
|
1785
|
+
// Every root in `snapshot.plannedWorkouts` was fed into `weekOffsets` above
|
|
1786
|
+
// (step 7), so this lookup is always defined — `?? 0` is a defensive fallback,
|
|
1787
|
+
// never an expected path.
|
|
1788
|
+
weekOffset: weekOffsets.get(root.id) ?? 0,
|
|
1789
|
+
deletedOccurrences: parseDeletedOccurrences(root.deletedOccurrencesRaw),
|
|
1790
|
+
completedSessionId: root.completedSessionId
|
|
1791
|
+
};
|
|
1792
|
+
});
|
|
1793
|
+
return { occurrences, roots };
|
|
1794
|
+
}
|
|
1795
|
+
function registerToolGetPlannedWorkouts(server2, cfg) {
|
|
1796
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
1797
|
+
server2.registerTool(
|
|
1798
|
+
"get_planned_workouts",
|
|
1799
|
+
{
|
|
1800
|
+
title: "Get Planned Workouts",
|
|
1801
|
+
description: 'Return concrete calendar occurrences and their planned-workout roots for a required date window (`from`/`to`, YYYY-MM-DD, both mandatory, span at most 366 days). Cancelled occurrences (deletedOccurrences) are already excluded. Completed occurrences are NOT excluded \u2014 a past date alone is not evidence of a missed workout. Each root also carries structured series fields (freq, interval, byDay, until, weekOffset) alongside the raw recurrenceRule, so a series can be described ("every 2 days", "every other week on Monday and Friday") without parsing the RRULE string.',
|
|
1802
|
+
inputSchema: GetPlannedWorkoutsSchema,
|
|
1803
|
+
annotations: { readOnlyHint: true }
|
|
1804
|
+
},
|
|
1805
|
+
async (args) => {
|
|
1806
|
+
try {
|
|
1807
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
1808
|
+
const result = getPlannedWorkouts(args, data.snapshot);
|
|
1809
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
return {
|
|
1812
|
+
isError: true,
|
|
1813
|
+
content: [{ type: "text", text: `Planned workouts error: ${err.message}` }]
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/training-state/time-zone.ts
|
|
1821
|
+
var TRAINING_TIMEZONE_SETTING_KEY = "training_timezone_id";
|
|
1822
|
+
var TimeZoneUnavailableError = class extends Error {
|
|
1823
|
+
constructor(message) {
|
|
1824
|
+
super(message);
|
|
1825
|
+
this.name = "TimeZoneUnavailableError";
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
function resolveTimeZoneId(snapshot) {
|
|
1829
|
+
const row = snapshot.settings.find((s) => s.key === TRAINING_TIMEZONE_SETTING_KEY);
|
|
1830
|
+
if (!row) {
|
|
1831
|
+
throw new TimeZoneUnavailableError(
|
|
1832
|
+
`Missing synchronized settings key "${TRAINING_TIMEZONE_SETTING_KEY}" \u2014 the app has not synced its time zone yet (Protocol \xA71.4 Rule 3). This tool cannot fall back to a host zone; the athlete must open the app once so it can sync.`
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
const knownZones = Intl.supportedValuesOf("timeZone");
|
|
1836
|
+
if (!knownZones.includes(row.value)) {
|
|
1837
|
+
throw new TimeZoneUnavailableError(
|
|
1838
|
+
`Synchronized settings key "${TRAINING_TIMEZONE_SETTING_KEY}" carries an unrecognized time-zone value \u2014 this is not a valid IANA zone identifier (Protocol \xA71.4 Rule 3).`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
return row.value;
|
|
1842
|
+
}
|
|
1843
|
+
function toCalendarDay(epochMs, timeZoneId) {
|
|
1844
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
1845
|
+
timeZone: timeZoneId,
|
|
1846
|
+
year: "numeric",
|
|
1847
|
+
month: "2-digit",
|
|
1848
|
+
day: "2-digit"
|
|
1849
|
+
});
|
|
1850
|
+
return formatter.format(new Date(epochMs));
|
|
1851
|
+
}
|
|
1852
|
+
function isoWeekKey(isoDate) {
|
|
1853
|
+
const [year, month, day] = isoDate.split("-").map(Number);
|
|
1854
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
1855
|
+
const isoDayIndex = (date.getUTCDay() + 6) % 7;
|
|
1856
|
+
date.setUTCDate(date.getUTCDate() - isoDayIndex + 3);
|
|
1857
|
+
const weekBasedYear = date.getUTCFullYear();
|
|
1858
|
+
const firstThursday = new Date(Date.UTC(weekBasedYear, 0, 4));
|
|
1859
|
+
const firstThursdayIsoDayIndex = (firstThursday.getUTCDay() + 6) % 7;
|
|
1860
|
+
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayIsoDayIndex + 3);
|
|
1861
|
+
const week = 1 + Math.round((date.getTime() - firstThursday.getTime()) / (7 * 864e5));
|
|
1862
|
+
return weekBasedYear * 100 + week;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/training-state/consistency.ts
|
|
1866
|
+
function areConsecutiveWeeks(week1, week2) {
|
|
1867
|
+
const year1 = Math.floor(week1 / 100);
|
|
1868
|
+
const w1 = week1 % 100;
|
|
1869
|
+
const year2 = Math.floor(week2 / 100);
|
|
1870
|
+
const w2 = week2 % 100;
|
|
1871
|
+
return year1 === year2 && w2 === w1 + 1 || year2 === year1 + 1 && w1 >= 52 && w2 === 1;
|
|
1872
|
+
}
|
|
1873
|
+
function calculateLongestStreak(sortedWeeks) {
|
|
1874
|
+
if (sortedWeeks.length === 0) return 0;
|
|
1875
|
+
let longest = 1;
|
|
1876
|
+
let current = 1;
|
|
1877
|
+
for (let i = 1; i < sortedWeeks.length; i++) {
|
|
1878
|
+
if (areConsecutiveWeeks(sortedWeeks[i - 1], sortedWeeks[i])) {
|
|
1879
|
+
current++;
|
|
1880
|
+
if (current > longest) longest = current;
|
|
1881
|
+
} else {
|
|
1882
|
+
current = 1;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
return longest;
|
|
1886
|
+
}
|
|
1887
|
+
function calculateCurrentStreak(sortedWeeks, currentWeekKey) {
|
|
1888
|
+
if (sortedWeeks.length === 0) return 0;
|
|
1889
|
+
const lastWeek = sortedWeeks[sortedWeeks.length - 1];
|
|
1890
|
+
if (lastWeek !== currentWeekKey && !areConsecutiveWeeks(lastWeek, currentWeekKey)) return 0;
|
|
1891
|
+
let streak = 1;
|
|
1892
|
+
for (let i = sortedWeeks.length - 2; i >= 0; i--) {
|
|
1893
|
+
if (areConsecutiveWeeks(sortedWeeks[i], sortedWeeks[i + 1])) {
|
|
1894
|
+
streak++;
|
|
1895
|
+
} else {
|
|
1896
|
+
break;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return streak;
|
|
1900
|
+
}
|
|
1901
|
+
function trainingWeekKeys(sessions, timeZoneId) {
|
|
1902
|
+
const weeks = /* @__PURE__ */ new Set();
|
|
1903
|
+
for (const session of sessions) {
|
|
1904
|
+
if (session.deletedAt != null) continue;
|
|
1905
|
+
const calendarDay = toCalendarDay(session.startTime, timeZoneId);
|
|
1906
|
+
weeks.add(isoWeekKey(calendarDay));
|
|
1907
|
+
}
|
|
1908
|
+
return [...weeks].sort((a, b) => a - b);
|
|
1909
|
+
}
|
|
1910
|
+
function computeStreaks(sessions, timeZoneId, nowMs) {
|
|
1911
|
+
const sortedWeeks = trainingWeekKeys(sessions, timeZoneId);
|
|
1912
|
+
const currentWeekKey = isoWeekKey(toCalendarDay(nowMs, timeZoneId));
|
|
1913
|
+
return {
|
|
1914
|
+
currentStreakWeeks: calculateCurrentStreak(sortedWeeks, currentWeekKey),
|
|
1915
|
+
longestStreakWeeks: calculateLongestStreak(sortedWeeks),
|
|
1916
|
+
trainingWeekCount: sortedWeeks.length
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
function shiftCalendarDay(isoDate, days) {
|
|
1920
|
+
const [y, mm, d] = isoDate.split("-").map(Number);
|
|
1921
|
+
const date = new Date(Date.UTC(y, mm - 1, d));
|
|
1922
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
1923
|
+
return date.toISOString().slice(0, 10);
|
|
1924
|
+
}
|
|
1925
|
+
function computeFrequency(sessions, timeZoneId, nowMs) {
|
|
1926
|
+
const completedSessions = sessions.filter((s) => s.deletedAt == null && s.endTime !== void 0);
|
|
1927
|
+
const todayIso = toCalendarDay(nowMs, timeZoneId);
|
|
1928
|
+
const todayWeekKey = isoWeekKey(todayIso);
|
|
1929
|
+
const fourWeeksAgoIso = shiftCalendarDay(todayIso, -28);
|
|
1930
|
+
const eightWeeksAgoIso = shiftCalendarDay(todayIso, -56);
|
|
1931
|
+
let sessionsThisIsoWeek = 0;
|
|
1932
|
+
let sessionsLast4Weeks = 0;
|
|
1933
|
+
let sessionsPrior4Weeks = 0;
|
|
1934
|
+
for (const session of completedSessions) {
|
|
1935
|
+
const dayIso = toCalendarDay(session.startTime, timeZoneId);
|
|
1936
|
+
if (isoWeekKey(dayIso) === todayWeekKey) sessionsThisIsoWeek++;
|
|
1937
|
+
if (dayIso >= fourWeeksAgoIso && dayIso <= todayIso) sessionsLast4Weeks++;
|
|
1938
|
+
if (dayIso >= eightWeeksAgoIso && dayIso < fourWeeksAgoIso) sessionsPrior4Weeks++;
|
|
1939
|
+
}
|
|
1940
|
+
return {
|
|
1941
|
+
sessionsThisIsoWeek,
|
|
1942
|
+
sessionsLast4Weeks,
|
|
1943
|
+
sessionsPrior4Weeks,
|
|
1944
|
+
totalCompletedSessions: completedSessions.length
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
function trackBest(map, exerciseId, value, achievedAtMs) {
|
|
1948
|
+
const existing = map.get(exerciseId);
|
|
1949
|
+
if (existing === void 0 || value > existing.value || value === existing.value && achievedAtMs < existing.achievedAtMs) {
|
|
1950
|
+
map.set(exerciseId, { value, achievedAtMs });
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function toSortedRecords(candidates, catalogById, timeZoneId) {
|
|
1954
|
+
return [...candidates.entries()].map(([exerciseId, candidate]) => ({
|
|
1955
|
+
exerciseId,
|
|
1956
|
+
exerciseName: catalogById.get(exerciseId)?.nameEn ?? null,
|
|
1957
|
+
value: candidate.value,
|
|
1958
|
+
achievedOn: toCalendarDay(candidate.achievedAtMs, timeZoneId)
|
|
1959
|
+
})).sort((a, b) => b.value - a.value || a.exerciseId.localeCompare(b.exerciseId));
|
|
1960
|
+
}
|
|
1961
|
+
function computePersonalRecords(snapshot, catalog, timeZoneId) {
|
|
1962
|
+
const catalogById = new Map(catalog.map((ex) => [ex.id, ex]));
|
|
1963
|
+
const templateExerciseById = new Map(snapshot.templateExercises.map((te) => [te.id, te]));
|
|
1964
|
+
const sessionById = new Map(snapshot.sessions.map((s) => [s.id, s]));
|
|
1965
|
+
const maxRepsByExercise = /* @__PURE__ */ new Map();
|
|
1966
|
+
const maxHoldByExercise = /* @__PURE__ */ new Map();
|
|
1967
|
+
for (const setLog of snapshot.setLogs) {
|
|
1968
|
+
if (setLog.deletedAt != null) continue;
|
|
1969
|
+
const session = sessionById.get(setLog.sessionId);
|
|
1970
|
+
if (session === void 0) continue;
|
|
1971
|
+
if (session.deletedAt != null) continue;
|
|
1972
|
+
const exerciseId = setLog.exerciseId ?? (setLog.workoutExerciseId !== void 0 ? templateExerciseById.get(setLog.workoutExerciseId)?.exerciseId : void 0);
|
|
1973
|
+
if (exerciseId === void 0) continue;
|
|
1974
|
+
if (setLog.completedReps !== null) {
|
|
1975
|
+
trackBest(maxRepsByExercise, exerciseId, setLog.completedReps, setLog.createdAt);
|
|
1976
|
+
}
|
|
1977
|
+
const exercise = catalogById.get(exerciseId);
|
|
1978
|
+
if (exercise?.mode === "TIME" && setLog.completedTimeSeconds !== null) {
|
|
1979
|
+
trackBest(maxHoldByExercise, exerciseId, setLog.completedTimeSeconds, setLog.createdAt);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return {
|
|
1983
|
+
maxReps: toSortedRecords(maxRepsByExercise, catalogById, timeZoneId),
|
|
1984
|
+
maxHoldSeconds: toSortedRecords(maxHoldByExercise, catalogById, timeZoneId)
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
function computeConsistency(snapshot, catalog, timeZoneId, nowMs) {
|
|
1988
|
+
const streaks = computeStreaks(snapshot.sessions, timeZoneId, nowMs);
|
|
1989
|
+
const frequency = computeFrequency(snapshot.sessions, timeZoneId, nowMs);
|
|
1990
|
+
return { ...streaks, ...frequency };
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// src/training-state/muscle-similarity.ts
|
|
1994
|
+
function buildMuscleVector(hits, catalogById) {
|
|
1995
|
+
const vector = /* @__PURE__ */ new Map();
|
|
1996
|
+
for (const hit of hits) {
|
|
1997
|
+
const exercise = catalogById.get(hit.exerciseId);
|
|
1998
|
+
if (exercise === void 0) continue;
|
|
1999
|
+
for (const muscleGroup of exercise.muscleGroups) {
|
|
2000
|
+
vector.set(
|
|
2001
|
+
muscleGroup.key,
|
|
2002
|
+
(vector.get(muscleGroup.key) ?? 0) + hit.setCount * weightFor(muscleGroup.involvementLevel)
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return vector;
|
|
2007
|
+
}
|
|
2008
|
+
var CAPABILITY_VECTOR_KEY_PREFIX = "capability:";
|
|
2009
|
+
function buildCombinedVector(hits, catalogById) {
|
|
2010
|
+
const vector = buildMuscleVector(hits, catalogById);
|
|
2011
|
+
for (const hit of hits) {
|
|
2012
|
+
const exercise = catalogById.get(hit.exerciseId);
|
|
2013
|
+
if (exercise === void 0) continue;
|
|
2014
|
+
for (const axis of exercise.capabilities) {
|
|
2015
|
+
const key = `${CAPABILITY_VECTOR_KEY_PREFIX}${axis.key}`;
|
|
2016
|
+
vector.set(key, (vector.get(key) ?? 0) + hit.setCount * weightFor2(axis.capabilityLevel));
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return vector;
|
|
2020
|
+
}
|
|
2021
|
+
function ruzickaSimilarity(a, b) {
|
|
2022
|
+
const keys = /* @__PURE__ */ new Set([...a.keys(), ...b.keys()]);
|
|
2023
|
+
let sumMin = 0;
|
|
2024
|
+
let sumMax = 0;
|
|
2025
|
+
for (const key of keys) {
|
|
2026
|
+
const av = a.get(key) ?? 0;
|
|
2027
|
+
const bv = b.get(key) ?? 0;
|
|
2028
|
+
sumMin += Math.min(av, bv);
|
|
2029
|
+
sumMax += Math.max(av, bv);
|
|
2030
|
+
}
|
|
2031
|
+
if (sumMax === 0) return 0;
|
|
2032
|
+
return sumMin / sumMax;
|
|
2033
|
+
}
|
|
2034
|
+
function classifySimilarity(similarity, matchThreshold, uncertainThreshold) {
|
|
2035
|
+
if (similarity >= matchThreshold) return "matched";
|
|
2036
|
+
if (similarity >= uncertainThreshold) return "uncertain";
|
|
2037
|
+
return "none";
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// src/training-state/adherence.ts
|
|
2041
|
+
var ADHERENCE_EXPLANATION = "removedOccurrenceCount counts how many planned occurrences the athlete removed from the calendar inside this window. The stored data carries no timestamp for a removal, so it cannot say whether a given occurrence was cancelled ahead of its date on purpose or cleaned up afterward because it was missed \u2014 ask the athlete rather than assuming either reading. A planned occurrence that was removed entirely (its whole recurring series, or its only single date) leaves no trace inside this window at all, and this calculation treats it exactly as if nothing had ever been planned on that day.";
|
|
2042
|
+
function occKeyOf(occ) {
|
|
2043
|
+
return `${occ.date}::${occ.rootId}`;
|
|
2044
|
+
}
|
|
2045
|
+
function daysBetween(a, b) {
|
|
2046
|
+
return Math.abs(epochDay(parseIsoDate(a)) - epochDay(parseIsoDate(b)));
|
|
2047
|
+
}
|
|
2048
|
+
function compareTieBreak(aDate, aRootId, aSessionId, bDate, bRootId, bSessionId) {
|
|
2049
|
+
if (aDate !== bDate) return aDate < bDate ? -1 : 1;
|
|
2050
|
+
if (aRootId !== bRootId) return aRootId < bRootId ? -1 : 1;
|
|
2051
|
+
if (aSessionId !== bSessionId) return aSessionId < bSessionId ? -1 : 1;
|
|
2052
|
+
return 0;
|
|
2053
|
+
}
|
|
2054
|
+
function computeAdherence(args, data) {
|
|
2055
|
+
const { snapshot, catalog, timeZoneId } = data;
|
|
2056
|
+
const { from, to, toleranceDays, matchThreshold, uncertainThreshold } = args;
|
|
2057
|
+
const denom = getPlannedWorkouts({ from, to }, snapshot);
|
|
2058
|
+
const plannedCount = denom.occurrences.length;
|
|
2059
|
+
let removedOccurrenceCount = 0;
|
|
2060
|
+
for (const root of denom.roots) {
|
|
2061
|
+
for (const deletedDate of root.deletedOccurrences) {
|
|
2062
|
+
if (deletedDate >= from && deletedDate <= to) removedOccurrenceCount++;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
const searchStart = formatIsoDate(plusDays(parseIsoDate(from), -toleranceDays));
|
|
2066
|
+
const searchEnd = formatIsoDate(plusDays(parseIsoDate(to), toleranceDays));
|
|
2067
|
+
const candidateSessions = [];
|
|
2068
|
+
for (const session of snapshot.sessions) {
|
|
2069
|
+
if (session.deletedAt !== void 0) continue;
|
|
2070
|
+
if (session.endTime === void 0) continue;
|
|
2071
|
+
const calDay = toCalendarDay(session.startTime, timeZoneId);
|
|
2072
|
+
if (calDay < searchStart || calDay > searchEnd) continue;
|
|
2073
|
+
candidateSessions.push({ session, calDay });
|
|
2074
|
+
}
|
|
2075
|
+
const usedOccKeys = /* @__PURE__ */ new Set();
|
|
2076
|
+
const usedSessionIds = /* @__PURE__ */ new Set();
|
|
2077
|
+
const matches = [];
|
|
2078
|
+
const exactCandidates = [];
|
|
2079
|
+
for (const occ of denom.occurrences) {
|
|
2080
|
+
for (const cs of candidateSessions) {
|
|
2081
|
+
if (cs.session.templateId !== occ.templateId) continue;
|
|
2082
|
+
const dateDistanceDays = daysBetween(occ.date, cs.calDay);
|
|
2083
|
+
if (dateDistanceDays > toleranceDays) continue;
|
|
2084
|
+
exactCandidates.push({ occKey: occKeyOf(occ), occ, session: cs.session, calDay: cs.calDay, dateDistanceDays });
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
exactCandidates.sort((a, b) => {
|
|
2088
|
+
if (a.dateDistanceDays !== b.dateDistanceDays) return a.dateDistanceDays - b.dateDistanceDays;
|
|
2089
|
+
return compareTieBreak(
|
|
2090
|
+
a.occ.date,
|
|
2091
|
+
a.occ.rootId,
|
|
2092
|
+
a.session.id,
|
|
2093
|
+
b.occ.date,
|
|
2094
|
+
b.occ.rootId,
|
|
2095
|
+
b.session.id
|
|
2096
|
+
);
|
|
2097
|
+
});
|
|
2098
|
+
for (const c of exactCandidates) {
|
|
2099
|
+
if (usedOccKeys.has(c.occKey) || usedSessionIds.has(c.session.id)) continue;
|
|
2100
|
+
usedOccKeys.add(c.occKey);
|
|
2101
|
+
usedSessionIds.add(c.session.id);
|
|
2102
|
+
matches.push({
|
|
2103
|
+
date: c.occ.date,
|
|
2104
|
+
rootId: c.occ.rootId,
|
|
2105
|
+
templateId: c.occ.templateId,
|
|
2106
|
+
sessionId: c.session.id,
|
|
2107
|
+
sessionDate: c.calDay,
|
|
2108
|
+
dateDistanceDays: c.dateDistanceDays,
|
|
2109
|
+
via: "templateId",
|
|
2110
|
+
similarity: null,
|
|
2111
|
+
confidence: "matched"
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
const catalogById = new Map(catalog.map((ex) => [ex.id, ex]));
|
|
2115
|
+
const templateExerciseById = new Map(snapshot.templateExercises.map((te) => [te.id, te]));
|
|
2116
|
+
const blockById = new Map(snapshot.blocks.map((b) => [b.id, b]));
|
|
2117
|
+
function roundsFor(te) {
|
|
2118
|
+
if (te.blockId === void 0) return 1;
|
|
2119
|
+
return blockById.get(te.blockId)?.rounds ?? 1;
|
|
2120
|
+
}
|
|
2121
|
+
const templateExercisesByTemplateId = /* @__PURE__ */ new Map();
|
|
2122
|
+
for (const te of snapshot.templateExercises) {
|
|
2123
|
+
const list = templateExercisesByTemplateId.get(te.templateId) ?? [];
|
|
2124
|
+
list.push({ exerciseId: te.exerciseId, sets: te.sets * roundsFor(te) });
|
|
2125
|
+
templateExercisesByTemplateId.set(te.templateId, list);
|
|
2126
|
+
}
|
|
2127
|
+
const setLogsBySessionId = /* @__PURE__ */ new Map();
|
|
2128
|
+
for (const setLog of snapshot.setLogs) {
|
|
2129
|
+
if (setLog.deletedAt !== void 0) continue;
|
|
2130
|
+
const list = setLogsBySessionId.get(setLog.sessionId) ?? [];
|
|
2131
|
+
list.push(setLog);
|
|
2132
|
+
setLogsBySessionId.set(setLog.sessionId, list);
|
|
2133
|
+
}
|
|
2134
|
+
const planVectorCache = /* @__PURE__ */ new Map();
|
|
2135
|
+
function planVectorFor(templateId) {
|
|
2136
|
+
const cached2 = planVectorCache.get(templateId);
|
|
2137
|
+
if (cached2) return cached2;
|
|
2138
|
+
const hits = (templateExercisesByTemplateId.get(templateId) ?? []).map((te) => ({
|
|
2139
|
+
exerciseId: te.exerciseId,
|
|
2140
|
+
// WR-01/MUSC-07 (138-15): te.sets is already pre-multiplied by the containing
|
|
2141
|
+
// block's rounds — see the blockById-based construction of
|
|
2142
|
+
// templateExercisesByTemplateId above (a rounds:3 block's sets:1 row means the
|
|
2143
|
+
// exercise is actually performed 3 times per template, not once).
|
|
2144
|
+
setCount: te.sets
|
|
2145
|
+
}));
|
|
2146
|
+
const vector = buildCombinedVector(hits, catalogById);
|
|
2147
|
+
planVectorCache.set(templateId, vector);
|
|
2148
|
+
return vector;
|
|
2149
|
+
}
|
|
2150
|
+
const sessionVectorCache = /* @__PURE__ */ new Map();
|
|
2151
|
+
function sessionVectorFor(session) {
|
|
2152
|
+
const cached2 = sessionVectorCache.get(session.id);
|
|
2153
|
+
if (cached2) return cached2;
|
|
2154
|
+
const hits = [];
|
|
2155
|
+
for (const setLog of setLogsBySessionId.get(session.id) ?? []) {
|
|
2156
|
+
const exerciseId = setLog.exerciseId ?? (setLog.workoutExerciseId !== void 0 ? templateExerciseById.get(setLog.workoutExerciseId)?.exerciseId : void 0);
|
|
2157
|
+
if (exerciseId === void 0) continue;
|
|
2158
|
+
hits.push({ exerciseId, setCount: 1 });
|
|
2159
|
+
}
|
|
2160
|
+
const vector = buildCombinedVector(hits, catalogById);
|
|
2161
|
+
sessionVectorCache.set(session.id, vector);
|
|
2162
|
+
return vector;
|
|
2163
|
+
}
|
|
2164
|
+
const remainingOccs = denom.occurrences.filter((occ) => !usedOccKeys.has(occKeyOf(occ)));
|
|
2165
|
+
const remainingSessions = candidateSessions.filter((cs) => !usedSessionIds.has(cs.session.id));
|
|
2166
|
+
const similarityCandidates = [];
|
|
2167
|
+
for (const occ of remainingOccs) {
|
|
2168
|
+
const planVector = planVectorFor(occ.templateId);
|
|
2169
|
+
for (const cs of remainingSessions) {
|
|
2170
|
+
const dateDistanceDays = daysBetween(occ.date, cs.calDay);
|
|
2171
|
+
if (dateDistanceDays > toleranceDays) continue;
|
|
2172
|
+
const sessionVector = sessionVectorFor(cs.session);
|
|
2173
|
+
const similarity = ruzickaSimilarity(sessionVector, planVector);
|
|
2174
|
+
const verdict = classifySimilarity(similarity, matchThreshold, uncertainThreshold);
|
|
2175
|
+
if (verdict === "none") continue;
|
|
2176
|
+
similarityCandidates.push({
|
|
2177
|
+
occKey: occKeyOf(occ),
|
|
2178
|
+
occ,
|
|
2179
|
+
session: cs.session,
|
|
2180
|
+
calDay: cs.calDay,
|
|
2181
|
+
dateDistanceDays,
|
|
2182
|
+
similarity,
|
|
2183
|
+
verdict
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
similarityCandidates.sort((a, b) => {
|
|
2188
|
+
if (a.dateDistanceDays !== b.dateDistanceDays) return a.dateDistanceDays - b.dateDistanceDays;
|
|
2189
|
+
if (a.similarity !== b.similarity) return b.similarity - a.similarity;
|
|
2190
|
+
return compareTieBreak(
|
|
2191
|
+
a.occ.date,
|
|
2192
|
+
a.occ.rootId,
|
|
2193
|
+
a.session.id,
|
|
2194
|
+
b.occ.date,
|
|
2195
|
+
b.occ.rootId,
|
|
2196
|
+
b.session.id
|
|
2197
|
+
);
|
|
2198
|
+
});
|
|
2199
|
+
for (const c of similarityCandidates) {
|
|
2200
|
+
if (usedOccKeys.has(c.occKey) || usedSessionIds.has(c.session.id)) continue;
|
|
2201
|
+
usedOccKeys.add(c.occKey);
|
|
2202
|
+
usedSessionIds.add(c.session.id);
|
|
2203
|
+
matches.push({
|
|
2204
|
+
date: c.occ.date,
|
|
2205
|
+
rootId: c.occ.rootId,
|
|
2206
|
+
templateId: c.occ.templateId,
|
|
2207
|
+
sessionId: c.session.id,
|
|
2208
|
+
sessionDate: c.calDay,
|
|
2209
|
+
dateDistanceDays: c.dateDistanceDays,
|
|
2210
|
+
via: "muscleSimilarity",
|
|
2211
|
+
similarity: c.similarity,
|
|
2212
|
+
confidence: c.verdict
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
matches.sort((a, b) => compareTieBreak(a.date, a.rootId, a.sessionId, b.date, b.rootId, b.sessionId));
|
|
2216
|
+
const missed = denom.occurrences.filter((occ) => !usedOccKeys.has(occKeyOf(occ)));
|
|
2217
|
+
const unplannedSessionIds = candidateSessions.filter((cs) => cs.calDay >= from && cs.calDay <= to && !usedSessionIds.has(cs.session.id)).map((cs) => cs.session.id);
|
|
2218
|
+
const matchedCount = matches.filter((m) => m.confidence === "matched").length;
|
|
2219
|
+
const uncertainCount = matches.filter((m) => m.confidence === "uncertain").length;
|
|
2220
|
+
const missedCount = missed.length;
|
|
2221
|
+
const adherenceRatio = plannedCount === 0 ? null : matchedCount / plannedCount;
|
|
2222
|
+
return {
|
|
2223
|
+
window: { from, to },
|
|
2224
|
+
toleranceDays,
|
|
2225
|
+
plannedCount,
|
|
2226
|
+
matchedCount,
|
|
2227
|
+
missedCount,
|
|
2228
|
+
uncertainCount,
|
|
2229
|
+
removedOccurrenceCount,
|
|
2230
|
+
adherenceRatio,
|
|
2231
|
+
matches,
|
|
2232
|
+
missed,
|
|
2233
|
+
unplannedSessionIds,
|
|
2234
|
+
explanation: ADHERENCE_EXPLANATION
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// src/training-state/exercise-progress.ts
|
|
2239
|
+
var DEAD_BAND = 0.03;
|
|
2240
|
+
var MIN_USABLE_VALUES = 4;
|
|
2241
|
+
var DIRECTION_RULE = "The series is split into a first half and a second half by position (for an odd number of points, the middle point falls into neither half). Null values are excluded when averaging each half. The relative change of the second half's mean versus the first half's mean is computed; a change within a 3% dead band counts as flat, below -3% as down, and above +3% as up. At least four usable (non-null) values are required overall \u2014 fewer, or a half with no usable values, yields insufficient-data.";
|
|
2242
|
+
function mean(values) {
|
|
2243
|
+
if (values.length === 0) return null;
|
|
2244
|
+
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
2245
|
+
}
|
|
2246
|
+
function directionOf(values) {
|
|
2247
|
+
const usableCount = values.filter((v) => v !== null).length;
|
|
2248
|
+
if (usableCount < MIN_USABLE_VALUES) {
|
|
2249
|
+
return { label: "insufficient-data", firstHalfMean: null, secondHalfMean: null, relativeChange: null };
|
|
2250
|
+
}
|
|
2251
|
+
const mid = Math.floor(values.length / 2);
|
|
2252
|
+
const isOdd = values.length % 2 === 1;
|
|
2253
|
+
const firstHalf = values.slice(0, mid);
|
|
2254
|
+
const secondHalf = isOdd ? values.slice(mid + 1) : values.slice(mid);
|
|
2255
|
+
const firstHalfMean = mean(firstHalf.filter((v) => v !== null));
|
|
2256
|
+
const secondHalfMean = mean(secondHalf.filter((v) => v !== null));
|
|
2257
|
+
if (firstHalfMean === null || secondHalfMean === null) {
|
|
2258
|
+
return { label: "insufficient-data", firstHalfMean, secondHalfMean, relativeChange: null };
|
|
2259
|
+
}
|
|
2260
|
+
const relativeChange = firstHalfMean === 0 ? secondHalfMean === 0 ? 0 : secondHalfMean > 0 ? 1 : -1 : (secondHalfMean - firstHalfMean) / Math.abs(firstHalfMean);
|
|
2261
|
+
const label = relativeChange > DEAD_BAND ? "up" : relativeChange < -DEAD_BAND ? "down" : "flat";
|
|
2262
|
+
return { label, firstHalfMean, secondHalfMean, relativeChange };
|
|
2263
|
+
}
|
|
2264
|
+
function summarizeSession(logs, metric) {
|
|
2265
|
+
if (metric === "e1rm") {
|
|
2266
|
+
const bestSet2 = bestE1rm(logs);
|
|
2267
|
+
let sessionTotal2 = null;
|
|
2268
|
+
for (const log of logs) {
|
|
2269
|
+
const w = log.weightUsed;
|
|
2270
|
+
const r = log.completedReps;
|
|
2271
|
+
if (w !== null && w > 0 && r !== null && r > 0) {
|
|
2272
|
+
sessionTotal2 = (sessionTotal2 ?? 0) + r * w;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
return { bestSet: bestSet2, sessionTotal: sessionTotal2 };
|
|
2276
|
+
}
|
|
2277
|
+
let bestSet = null;
|
|
2278
|
+
let sessionTotal = null;
|
|
2279
|
+
for (const log of logs) {
|
|
2280
|
+
const value = metric === "reps" ? log.completedReps : log.completedTimeSeconds;
|
|
2281
|
+
if (value === null) continue;
|
|
2282
|
+
if (bestSet === null || value > bestSet) bestSet = value;
|
|
2283
|
+
sessionTotal = (sessionTotal ?? 0) + value;
|
|
2284
|
+
}
|
|
2285
|
+
return { bestSet, sessionTotal };
|
|
2286
|
+
}
|
|
2287
|
+
function buildSeriesForExercise(exerciseId, logs, ctx) {
|
|
2288
|
+
const exercise = ctx.catalogById.get(exerciseId);
|
|
2289
|
+
const usesWeight = exercise?.usesWeight ?? false;
|
|
2290
|
+
const mode = exercise?.mode ?? "REPS";
|
|
2291
|
+
const metric = usesWeight ? "e1rm" : mode === "TIME" ? "timeSeconds" : "reps";
|
|
2292
|
+
const logsBySessionId = /* @__PURE__ */ new Map();
|
|
2293
|
+
for (const log of logs) {
|
|
2294
|
+
const bucket = logsBySessionId.get(log.sessionId);
|
|
2295
|
+
if (bucket) bucket.push(log);
|
|
2296
|
+
else logsBySessionId.set(log.sessionId, [log]);
|
|
2297
|
+
}
|
|
2298
|
+
const sessionsTouched = [...logsBySessionId.keys()].map((sessionId) => ctx.sessionById.get(sessionId)).filter((s) => s !== void 0).sort((a, b) => a.startTime - b.startTime);
|
|
2299
|
+
const points = sessionsTouched.map((session) => {
|
|
2300
|
+
const sessionLogs = logsBySessionId.get(session.id) ?? [];
|
|
2301
|
+
const { bestSet, sessionTotal } = summarizeSession(sessionLogs, metric);
|
|
2302
|
+
return {
|
|
2303
|
+
sessionId: session.id,
|
|
2304
|
+
date: toCalendarDay(session.startTime, ctx.timeZoneId),
|
|
2305
|
+
setCount: sessionLogs.length,
|
|
2306
|
+
bestSet,
|
|
2307
|
+
sessionTotal
|
|
2308
|
+
};
|
|
2309
|
+
});
|
|
2310
|
+
const trimmedPoints = points.length > ctx.maxPoints ? points.slice(points.length - ctx.maxPoints) : points;
|
|
2311
|
+
return {
|
|
2312
|
+
exerciseId,
|
|
2313
|
+
exerciseName: exercise?.nameEn ?? null,
|
|
2314
|
+
mode,
|
|
2315
|
+
usesWeight,
|
|
2316
|
+
metric,
|
|
2317
|
+
points: trimmedPoints,
|
|
2318
|
+
bestSetDirection: directionOf(trimmedPoints.map((p) => p.bestSet)),
|
|
2319
|
+
sessionTotalDirection: directionOf(trimmedPoints.map((p) => p.sessionTotal)),
|
|
2320
|
+
rule: DIRECTION_RULE
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
function computeExerciseProgress(args, data) {
|
|
2324
|
+
const { snapshot, catalog, timeZoneId } = data;
|
|
2325
|
+
const catalogById = new Map(catalog.map((ex) => [ex.id, ex]));
|
|
2326
|
+
const templateExerciseById = new Map(snapshot.templateExercises.map((te) => [te.id, te]));
|
|
2327
|
+
const sessionById = new Map(snapshot.sessions.map((s) => [s.id, s]));
|
|
2328
|
+
const setLogsByExerciseId = /* @__PURE__ */ new Map();
|
|
2329
|
+
for (const setLog of snapshot.setLogs) {
|
|
2330
|
+
if (setLog.deletedAt !== void 0) continue;
|
|
2331
|
+
const session = sessionById.get(setLog.sessionId);
|
|
2332
|
+
if (session === void 0) continue;
|
|
2333
|
+
if (session.deletedAt !== void 0) continue;
|
|
2334
|
+
if (session.endTime === void 0) continue;
|
|
2335
|
+
const exerciseId = setLog.exerciseId ?? (setLog.workoutExerciseId !== void 0 ? templateExerciseById.get(setLog.workoutExerciseId)?.exerciseId : void 0);
|
|
2336
|
+
if (exerciseId === void 0) continue;
|
|
2337
|
+
const bucket = setLogsByExerciseId.get(exerciseId);
|
|
2338
|
+
if (bucket) bucket.push(setLog);
|
|
2339
|
+
else setLogsByExerciseId.set(exerciseId, [setLog]);
|
|
2340
|
+
}
|
|
2341
|
+
let exerciseIds;
|
|
2342
|
+
if (args.exerciseId !== void 0) {
|
|
2343
|
+
exerciseIds = [args.exerciseId];
|
|
2344
|
+
} else {
|
|
2345
|
+
const lastOccurrenceMs = /* @__PURE__ */ new Map();
|
|
2346
|
+
for (const [exerciseId, logs] of setLogsByExerciseId) {
|
|
2347
|
+
let latest = -Infinity;
|
|
2348
|
+
for (const log of logs) {
|
|
2349
|
+
const session = sessionById.get(log.sessionId);
|
|
2350
|
+
if (session !== void 0 && session.startTime > latest) latest = session.startTime;
|
|
2351
|
+
}
|
|
2352
|
+
lastOccurrenceMs.set(exerciseId, latest);
|
|
2353
|
+
}
|
|
2354
|
+
exerciseIds = [...lastOccurrenceMs.entries()].sort((a, b) => b[1] - a[1]).slice(0, args.recentExerciseCount).map(([exerciseId]) => exerciseId);
|
|
2355
|
+
}
|
|
2356
|
+
return exerciseIds.map(
|
|
2357
|
+
(exerciseId) => buildSeriesForExercise(exerciseId, setLogsByExerciseId.get(exerciseId) ?? [], {
|
|
2358
|
+
sessionById,
|
|
2359
|
+
catalogById,
|
|
2360
|
+
timeZoneId,
|
|
2361
|
+
maxPoints: args.maxPoints
|
|
2362
|
+
})
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// src/training-state/narrative.ts
|
|
2367
|
+
function streakClause(currentStreakWeeks) {
|
|
2368
|
+
if (currentStreakWeeks === 0) {
|
|
2369
|
+
return "The current training streak is 0 weeks.";
|
|
2370
|
+
}
|
|
2371
|
+
const weekWord = currentStreakWeeks === 1 ? "week" : "weeks";
|
|
2372
|
+
return `The current training streak is ${currentStreakWeeks} ${weekWord}.`;
|
|
2373
|
+
}
|
|
2374
|
+
function frequencyClause(sessionsLast4Weeks, sessionsPrior4Weeks) {
|
|
2375
|
+
if (sessionsLast4Weeks === sessionsPrior4Weeks) {
|
|
2376
|
+
return `Training frequency is unchanged: ${sessionsLast4Weeks} sessions in each of the last two 4-week windows.`;
|
|
2377
|
+
}
|
|
2378
|
+
if (sessionsPrior4Weeks === 0) {
|
|
2379
|
+
return `${sessionsLast4Weeks} session(s) were logged in the last 4 weeks, versus none in the previous 4-week window.`;
|
|
2380
|
+
}
|
|
2381
|
+
if (sessionsLast4Weeks === 0) {
|
|
2382
|
+
return `No sessions were logged in the last 4 weeks, versus ${sessionsPrior4Weeks} in the previous 4-week window.`;
|
|
2383
|
+
}
|
|
2384
|
+
const relativePercent = Math.round((sessionsLast4Weeks - sessionsPrior4Weeks) / sessionsPrior4Weeks * 100);
|
|
2385
|
+
const signedPercent = relativePercent >= 0 ? `+${relativePercent}` : `${relativePercent}`;
|
|
2386
|
+
return `${sessionsLast4Weeks} sessions were logged in the last 4 weeks versus ${sessionsPrior4Weeks} in the previous 4-week window (${signedPercent}%).`;
|
|
2387
|
+
}
|
|
2388
|
+
function adherenceClause(adherence) {
|
|
2389
|
+
if (adherence.adherenceRatio === null) {
|
|
2390
|
+
return "Nothing was scheduled in the adherence window, so no adherence percentage applies.";
|
|
2391
|
+
}
|
|
2392
|
+
const percent = Math.round(adherence.adherenceRatio * 100);
|
|
2393
|
+
return `Adherence over the window is ${percent}% (${adherence.matchedCount} of ${adherence.plannedCount} planned sessions matched).`;
|
|
2394
|
+
}
|
|
2395
|
+
function progressClause(recentExercises) {
|
|
2396
|
+
const trendingUp = recentExercises.filter((e) => e.direction === "up");
|
|
2397
|
+
if (trendingUp.length === 0) return null;
|
|
2398
|
+
trendingUp.sort((a, b) => {
|
|
2399
|
+
if (a.lastTrainedOn !== b.lastTrainedOn) return a.lastTrainedOn < b.lastTrainedOn ? 1 : -1;
|
|
2400
|
+
return a.exerciseId.localeCompare(b.exerciseId);
|
|
2401
|
+
});
|
|
2402
|
+
const chosen = trendingUp[0];
|
|
2403
|
+
const name = chosen.exerciseName ?? chosen.exerciseId;
|
|
2404
|
+
return `${name} is trending upward across recent sessions.`;
|
|
2405
|
+
}
|
|
2406
|
+
function buildNarrative(input) {
|
|
2407
|
+
const clauses = [
|
|
2408
|
+
streakClause(input.currentStreakWeeks),
|
|
2409
|
+
frequencyClause(input.sessionsLast4Weeks, input.sessionsPrior4Weeks),
|
|
2410
|
+
adherenceClause(input.adherence)
|
|
2411
|
+
];
|
|
2412
|
+
const progress = progressClause(input.recentExercises);
|
|
2413
|
+
if (progress !== null) clauses.push(progress);
|
|
2414
|
+
return clauses.join(" ");
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// src/tools/get_training_state.ts
|
|
2418
|
+
function getTrainingState(args, data, nowMs) {
|
|
2419
|
+
const { snapshot, catalog, coachParameters, parametersSource } = data;
|
|
2420
|
+
const timeZoneId = resolveTimeZoneId(snapshot);
|
|
2421
|
+
const asOfDate = toCalendarDay(nowMs, timeZoneId);
|
|
2422
|
+
const consistency = computeConsistency(snapshot, catalog, timeZoneId, nowMs);
|
|
2423
|
+
const muscleBalance = computeMuscleBalance(snapshot, catalog, nowMs);
|
|
2424
|
+
const adherenceTo = asOfDate;
|
|
2425
|
+
const adherenceFrom = formatIsoDate(
|
|
2426
|
+
plusDays(parseIsoDate(asOfDate), -coachParameters.adherenceWindowWeeks * 7)
|
|
2427
|
+
);
|
|
2428
|
+
const fullAdherence = computeAdherence(
|
|
2429
|
+
{
|
|
2430
|
+
from: adherenceFrom,
|
|
2431
|
+
to: adherenceTo,
|
|
2432
|
+
toleranceDays: coachParameters.toleranceDays,
|
|
2433
|
+
matchThreshold: coachParameters.matchThreshold,
|
|
2434
|
+
uncertainThreshold: coachParameters.uncertainThreshold
|
|
2435
|
+
},
|
|
2436
|
+
{ snapshot, catalog, timeZoneId }
|
|
2437
|
+
);
|
|
2438
|
+
const adherence = {
|
|
2439
|
+
window: fullAdherence.window,
|
|
2440
|
+
toleranceDays: fullAdherence.toleranceDays,
|
|
2441
|
+
plannedCount: fullAdherence.plannedCount,
|
|
2442
|
+
matchedCount: fullAdherence.matchedCount,
|
|
2443
|
+
uncertainCount: fullAdherence.uncertainCount,
|
|
2444
|
+
missedCount: fullAdherence.missedCount,
|
|
2445
|
+
removedOccurrenceCount: fullAdherence.removedOccurrenceCount,
|
|
2446
|
+
adherenceRatio: fullAdherence.adherenceRatio,
|
|
2447
|
+
explanation: fullAdherence.explanation
|
|
2448
|
+
};
|
|
2449
|
+
const progressSeries = computeExerciseProgress(
|
|
2450
|
+
{ maxPoints: coachParameters.exerciseTrendPoints, recentExerciseCount: coachParameters.recentExerciseCount },
|
|
2451
|
+
{ snapshot, catalog, timeZoneId }
|
|
2452
|
+
);
|
|
2453
|
+
const recentExercises = progressSeries.map((series) => {
|
|
2454
|
+
const lastPoint = series.points[series.points.length - 1];
|
|
2455
|
+
return {
|
|
2456
|
+
exerciseId: series.exerciseId,
|
|
2457
|
+
exerciseName: series.exerciseName,
|
|
2458
|
+
metric: series.metric,
|
|
2459
|
+
lastTrainedOn: lastPoint?.date ?? asOfDate,
|
|
2460
|
+
bestSetDirection: series.bestSetDirection,
|
|
2461
|
+
sessionTotalDirection: series.sessionTotalDirection,
|
|
2462
|
+
rule: series.rule
|
|
2463
|
+
};
|
|
2464
|
+
});
|
|
2465
|
+
const personalRecords = computePersonalRecords(snapshot, catalog, timeZoneId);
|
|
2466
|
+
const narrative = buildNarrative({
|
|
2467
|
+
currentStreakWeeks: consistency.currentStreakWeeks,
|
|
2468
|
+
sessionsLast4Weeks: consistency.sessionsLast4Weeks,
|
|
2469
|
+
sessionsPrior4Weeks: consistency.sessionsPrior4Weeks,
|
|
2470
|
+
adherence: {
|
|
2471
|
+
plannedCount: adherence.plannedCount,
|
|
2472
|
+
matchedCount: adherence.matchedCount,
|
|
2473
|
+
adherenceRatio: adherence.adherenceRatio
|
|
2474
|
+
},
|
|
2475
|
+
recentExercises: recentExercises.map((e) => ({
|
|
2476
|
+
exerciseId: e.exerciseId,
|
|
2477
|
+
exerciseName: e.exerciseName,
|
|
2478
|
+
lastTrainedOn: e.lastTrainedOn,
|
|
2479
|
+
direction: e.bestSetDirection.label
|
|
2480
|
+
}))
|
|
2481
|
+
});
|
|
2482
|
+
return {
|
|
2483
|
+
timeZoneId,
|
|
2484
|
+
asOfDate,
|
|
2485
|
+
parametersSource,
|
|
2486
|
+
parameters: coachParameters,
|
|
2487
|
+
consistency,
|
|
2488
|
+
muscleBalance,
|
|
2489
|
+
adherence,
|
|
2490
|
+
recentExercises,
|
|
2491
|
+
personalRecords,
|
|
2492
|
+
narrative
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
function registerToolGetTrainingState(server2, cfg) {
|
|
2496
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
2497
|
+
server2.registerTool(
|
|
2498
|
+
"get_training_state",
|
|
2499
|
+
{
|
|
2500
|
+
title: "Get Training State",
|
|
2501
|
+
description: `The standard entry point for "how is the athlete doing" \u2014 a query-time-computed overview, rather than raw session-by-session history. In one call: the current and longest training streak in weeks plus session-frequency counts (consistency); the same radar-chart muscle balance data as the app's Analytics dashboard in all three app windows (muscleBalance); a plan-adherence short form \u2014 counts, ratio, and an honesty explanation about what removedOccurrenceCount does and does not mean (adherence); the most recently trained exercises with their direction labels (recentExercises); all-time personal records (personalRecords); and a short, deterministic narrative sentence built from these already-computed numbers, never a second model call. This tool stays narrow on purpose: it carries no point series and no pair lists. For per-exercise or per-format progress series, call get_progress. For the full adherence pair/missed lists, call get_adherence. For raw session history, call get_history. All calendar-day mapping binds to the athlete's own synchronized time zone, never the coach's own clock.`,
|
|
2502
|
+
inputSchema: GetTrainingStateSchema,
|
|
2503
|
+
annotations: { readOnlyHint: true }
|
|
2504
|
+
},
|
|
2505
|
+
async (args) => {
|
|
2506
|
+
try {
|
|
2507
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
2508
|
+
const { params, source } = await loadCoachParameters({ pat: pat2, serverUrl: serverUrl2 });
|
|
2509
|
+
const result = getTrainingState(
|
|
2510
|
+
args,
|
|
2511
|
+
{ snapshot: data.snapshot, catalog: data.catalog, coachParameters: params, parametersSource: source },
|
|
2512
|
+
Date.now()
|
|
2513
|
+
);
|
|
2514
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
2515
|
+
} catch (err) {
|
|
2516
|
+
return {
|
|
2517
|
+
isError: true,
|
|
2518
|
+
content: [{ type: "text", text: `Training state error: ${err.message}` }]
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/dedupe.ts
|
|
2526
|
+
function normalizeExerciseName(name) {
|
|
2527
|
+
const trimmed = name.trim();
|
|
2528
|
+
const lowered = trimmed.toLocaleLowerCase("en-US");
|
|
2529
|
+
const collapsed = lowered.replace(/\s+/g, " ");
|
|
2530
|
+
const nfd = collapsed.normalize("NFD");
|
|
2531
|
+
return nfd.replace(new RegExp("\\p{Mn}", "gu"), "");
|
|
2532
|
+
}
|
|
2533
|
+
function findCatalogMatch(proposedName, catalog) {
|
|
2534
|
+
const key = normalizeExerciseName(proposedName);
|
|
2535
|
+
if (!key) return void 0;
|
|
2536
|
+
return catalog.find(
|
|
2537
|
+
(ex) => normalizeExerciseName(ex.nameEn) === key || ex.translations.some((t) => normalizeExerciseName(t.name) === key)
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// src/hash.ts
|
|
2542
|
+
import { createHash } from "crypto";
|
|
2543
|
+
function canonicalStringify(value, path = "<root>") {
|
|
2544
|
+
if (value === void 0) {
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
`canonicalStringify: refusing to serialize undefined at "${path}". A field this hash depends on is missing. JSON.stringify(undefined) produces no string at all, so a prior version of this function silently wrote the bare word \`undefined\` into the hash input here \u2014 invalid JSON, and a hash that could never match the Kotlin side (G-134-37: exactly this bug on rounds/sets/exerciseSource, planhash-wire-defaults-parity.md). Fix the caller to supply a real value or an explicit null, not this function.`
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
if (value === null || typeof value !== "object") {
|
|
2550
|
+
return JSON.stringify(value);
|
|
2551
|
+
}
|
|
2552
|
+
if (Array.isArray(value)) {
|
|
2553
|
+
return `[${value.map((v, i) => canonicalStringify(v, `${path}[${i}]`)).join(",")}]`;
|
|
2554
|
+
}
|
|
2555
|
+
const record = value;
|
|
2556
|
+
const keys = Object.keys(record).sort();
|
|
2557
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalStringify(record[k], path === "<root>" ? k : `${path}.${k}`)}`).join(",")}}`;
|
|
2558
|
+
}
|
|
2559
|
+
function canonWeight(w) {
|
|
2560
|
+
return w == null ? null : decimalHalfUpScale2(String(w));
|
|
2561
|
+
}
|
|
2562
|
+
function decimalHalfUpScale2(repr) {
|
|
2563
|
+
let s = repr;
|
|
2564
|
+
let sign = "";
|
|
2565
|
+
if (s.startsWith("-")) {
|
|
2566
|
+
sign = "-";
|
|
2567
|
+
s = s.slice(1);
|
|
2568
|
+
}
|
|
2569
|
+
let mantissa = s;
|
|
2570
|
+
let exp = 0;
|
|
2571
|
+
const eIdx = s.search(/[eE]/);
|
|
2572
|
+
if (eIdx !== -1) {
|
|
2573
|
+
mantissa = s.slice(0, eIdx);
|
|
2574
|
+
exp = Number.parseInt(s.slice(eIdx + 1), 10);
|
|
2575
|
+
}
|
|
2576
|
+
const dot = mantissa.indexOf(".");
|
|
2577
|
+
const digits = dot === -1 ? mantissa : mantissa.slice(0, dot) + mantissa.slice(dot + 1);
|
|
2578
|
+
let pointPos = (dot === -1 ? mantissa.length : dot) + exp;
|
|
2579
|
+
let padded = digits;
|
|
2580
|
+
if (pointPos < 0) {
|
|
2581
|
+
padded = "0".repeat(-pointPos) + padded;
|
|
2582
|
+
pointPos = 0;
|
|
2583
|
+
}
|
|
2584
|
+
if (pointPos + 2 > padded.length) {
|
|
2585
|
+
padded = padded + "0".repeat(pointPos + 2 - padded.length);
|
|
2586
|
+
}
|
|
2587
|
+
let scaled = BigInt(padded.slice(0, pointPos + 2));
|
|
2588
|
+
const rest = padded.slice(pointPos + 2);
|
|
2589
|
+
if (rest !== "" && rest.charCodeAt(0) >= 53) {
|
|
2590
|
+
scaled += 1n;
|
|
2591
|
+
}
|
|
2592
|
+
const abs = scaled.toString().padStart(3, "0");
|
|
2593
|
+
const out = `${abs.slice(0, -2)}.${abs.slice(-2)}`;
|
|
2594
|
+
return sign !== "" && out !== "0.00" ? `${sign}${out}` : out;
|
|
2595
|
+
}
|
|
2596
|
+
function canonicalizeRoundTarget(rt) {
|
|
2597
|
+
return {
|
|
2598
|
+
round: rt.round,
|
|
2599
|
+
targetReps: rt.targetReps ?? null,
|
|
2600
|
+
targetTimeSeconds: rt.targetTimeSeconds ?? null,
|
|
2601
|
+
targetWeight: canonWeight(rt.targetWeight)
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
function compareByOrderIndexThenId(a, b) {
|
|
2605
|
+
if (a.orderIndex !== b.orderIndex) return a.orderIndex - b.orderIndex;
|
|
2606
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
2607
|
+
}
|
|
2608
|
+
function computePlanHash(templateId, snapshot) {
|
|
2609
|
+
const blocks = snapshot.blocks.filter((b) => b.templateId === templateId && b.deletedAt == null).sort(compareByOrderIndexThenId);
|
|
2610
|
+
const templateExercises = snapshot.templateExercises.filter(
|
|
2611
|
+
(te) => te.templateId === templateId && te.deletedAt == null
|
|
2612
|
+
);
|
|
2613
|
+
const canonical = {
|
|
2614
|
+
templateId,
|
|
2615
|
+
blocks: blocks.map((block) => {
|
|
2616
|
+
const exercisesForBlock = templateExercises.filter((te) => te.blockId === block.id).sort(compareByOrderIndexThenId).map((te) => canonicalizeTemplateExercise(te));
|
|
2617
|
+
return {
|
|
2618
|
+
id: block.id,
|
|
2619
|
+
rounds: block.rounds,
|
|
2620
|
+
orderIndex: block.orderIndex,
|
|
2621
|
+
templateExercises: exercisesForBlock
|
|
2622
|
+
};
|
|
2623
|
+
}),
|
|
2624
|
+
standalone: templateExercises.filter((te) => te.blockId == null).sort(compareByOrderIndexThenId).map((te) => canonicalizeTemplateExercise(te))
|
|
2625
|
+
};
|
|
2626
|
+
return createHash("sha256").update(canonicalStringify(canonical), "utf8").digest("hex");
|
|
2627
|
+
}
|
|
2628
|
+
function canonicalizeTemplateExercise(te) {
|
|
2629
|
+
const roundTargets = (te.roundTargets ?? []).slice().sort((a, b) => a.round - b.round).map(canonicalizeRoundTarget);
|
|
2630
|
+
return {
|
|
2631
|
+
id: te.id,
|
|
2632
|
+
blockId: te.blockId ?? null,
|
|
2633
|
+
exerciseId: te.exerciseId,
|
|
2634
|
+
exerciseSource: te.exerciseSource,
|
|
2635
|
+
mode: te.mode,
|
|
2636
|
+
targetReps: te.targetReps ?? null,
|
|
2637
|
+
targetTimeSeconds: te.targetTimeSeconds ?? null,
|
|
2638
|
+
restTimeSeconds: te.restTimeSeconds,
|
|
2639
|
+
sets: te.sets,
|
|
2640
|
+
targetWeight: canonWeight(te.targetWeight),
|
|
2641
|
+
orderIndex: te.orderIndex,
|
|
2642
|
+
roundTargets
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
function computeChangeHash(type, payload) {
|
|
2646
|
+
return createHash("sha256").update(`${type}:${canonicalStringify(payload)}`, "utf8").digest("hex");
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/source_llm.ts
|
|
2650
|
+
var SOURCE_LLM_MAX_LEN = 64;
|
|
2651
|
+
function getSourceLlm(server2) {
|
|
2652
|
+
const info = server2.server.getClientVersion();
|
|
2653
|
+
if (!info || !info.name) return "unknown";
|
|
2654
|
+
return `${info.name}/${info.version ?? "0"}`.slice(0, SOURCE_LLM_MAX_LEN);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// src/format_proposal.ts
|
|
2658
|
+
function validateFormatProposal(templateWorkoutType, payload) {
|
|
2659
|
+
if (templateWorkoutType == null) {
|
|
2660
|
+
return { kind: "classicOps" };
|
|
2661
|
+
}
|
|
2662
|
+
const hasOps = (payload.ops ?? []).length > 0;
|
|
2663
|
+
const hasFormatParams = payload.formatParams !== void 0;
|
|
2664
|
+
if (hasOps && hasFormatParams) {
|
|
2665
|
+
return { kind: "refused", reason: "OPS_AND_FORMAT_PARAMS_EXCLUSIVE" };
|
|
2666
|
+
}
|
|
2667
|
+
if (templateWorkoutType === "CLASSIC") {
|
|
2668
|
+
if (hasFormatParams) {
|
|
2669
|
+
return { kind: "refused", reason: "FORMAT_PARAMS_NOT_ALLOWED" };
|
|
2670
|
+
}
|
|
2671
|
+
return { kind: "classicOps" };
|
|
2672
|
+
}
|
|
2673
|
+
if (!hasFormatParams) {
|
|
2674
|
+
return { kind: "refused", reason: "FORMAT_PARAMS_REQUIRED" };
|
|
2675
|
+
}
|
|
2676
|
+
if (payload.formatParams.workoutType !== templateWorkoutType) {
|
|
2677
|
+
return { kind: "refused", reason: "FORMAT_TYPE_MISMATCH" };
|
|
2678
|
+
}
|
|
2679
|
+
return { kind: "format" };
|
|
2680
|
+
}
|
|
2681
|
+
var FORMAT_REFUSAL_MESSAGES = {
|
|
2682
|
+
FORMAT_PARAMS_REQUIRED: "This template is a format template \u2014 propose formatParams, not an ops[] diff.",
|
|
2683
|
+
FORMAT_PARAMS_NOT_ALLOWED: "This template is CLASSIC \u2014 propose ops[], not formatParams.",
|
|
2684
|
+
FORMAT_TYPE_MISMATCH: "formatParams.workoutType does not match the target template's own workoutType.",
|
|
2685
|
+
OPS_AND_FORMAT_PARAMS_EXCLUSIVE: "A proposal may carry ops[] or formatParams, never both.",
|
|
2686
|
+
UNKNOWN_FORMAT_TYPE: "formatParams.workoutType is not one of the eight recognized formats.",
|
|
2687
|
+
FORMAT_PARAMS_INVALID: "formatParams violates a constraint the format editor itself enforces."
|
|
2688
|
+
};
|
|
2689
|
+
|
|
2690
|
+
// src/tools/propose_plan_update.ts
|
|
2691
|
+
function rewriteFormatParamsRefs(formatParams, rewriteRef) {
|
|
2692
|
+
const rewriteEntry = (entry) => ({
|
|
2693
|
+
...entry,
|
|
2694
|
+
exercise: rewriteRef(entry.exercise)
|
|
2695
|
+
});
|
|
2696
|
+
switch (formatParams.workoutType) {
|
|
2697
|
+
case "CIRCUIT":
|
|
2698
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2699
|
+
case "EMOM":
|
|
2700
|
+
return { ...formatParams, stations: formatParams.stations.map(rewriteEntry) };
|
|
2701
|
+
case "AMRAP":
|
|
2702
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2703
|
+
case "TABATA":
|
|
2704
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2705
|
+
case "LADDER":
|
|
2706
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2707
|
+
case "FOR_TIME":
|
|
2708
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2709
|
+
case "CHIPPER":
|
|
2710
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2711
|
+
case "DEATH_BY":
|
|
2712
|
+
return { ...formatParams, exercises: formatParams.exercises.map(rewriteEntry) };
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
function buildPlanUpdatePayload(args, catalog) {
|
|
2716
|
+
const tempIdToUuid = /* @__PURE__ */ new Map();
|
|
2717
|
+
const remainingNewExercises = [];
|
|
2718
|
+
for (const entry of args.newExercises ?? []) {
|
|
2719
|
+
const match = findCatalogMatch(entry.name, catalog);
|
|
2720
|
+
if (match) {
|
|
2721
|
+
tempIdToUuid.set(entry.tempId, match.id);
|
|
2722
|
+
} else {
|
|
2723
|
+
remainingNewExercises.push(entry);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
const rewriteRef = (ref) => {
|
|
2727
|
+
if (ref.source === "new" && tempIdToUuid.has(ref.tempId)) {
|
|
2728
|
+
return { source: "catalog", exerciseId: tempIdToUuid.get(ref.tempId) };
|
|
2729
|
+
}
|
|
2730
|
+
return ref;
|
|
2731
|
+
};
|
|
2732
|
+
const ops = (args.ops ?? []).map((op) => {
|
|
2733
|
+
if (op.op === "addExercise") {
|
|
2734
|
+
return { ...op, exercise: rewriteRef(op.exercise) };
|
|
2735
|
+
}
|
|
2736
|
+
return op;
|
|
2737
|
+
});
|
|
2738
|
+
return {
|
|
2739
|
+
templateId: args.templateId,
|
|
2740
|
+
ops,
|
|
2741
|
+
newExercises: remainingNewExercises,
|
|
2742
|
+
...args.formatParams !== void 0 ? { formatParams: rewriteFormatParamsRefs(args.formatParams, rewriteRef) } : {}
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
var FORMAT_PARAMS_ON_UNTYPED_TEMPLATE_REASON = "FORMAT_PARAMS_ON_UNTYPED_TEMPLATE";
|
|
2746
|
+
var FORMAT_PARAMS_ON_UNTYPED_TEMPLATE_MESSAGE = "This template's own format type is not known to the coach yet (its workoutType is absent or has not been re-synced) \u2014 a whole-parameter formatParams proposal cannot be applied to it. Propose an ops[] diff instead.";
|
|
2747
|
+
var EMPTY_PLAN_UPDATE_REASON = "EMPTY_PLAN_UPDATE";
|
|
2748
|
+
var EMPTY_PLAN_UPDATE_MESSAGE = "This proposal carries no ops[] and no formatParams \u2014 nothing to propose.";
|
|
2749
|
+
function registerToolProposePlanUpdate(server2, cfg) {
|
|
2750
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
2751
|
+
server2.registerTool(
|
|
2752
|
+
"propose_plan_update",
|
|
2753
|
+
{
|
|
2754
|
+
title: "Propose Plan Update",
|
|
2755
|
+
description: "Propose a structural change to an existing workout template for human review. Against a CLASSIC template, pass an `ops[]` diff (add/remove/update-sets-reps/reorder). Against a format template (CIRCUIT/EMOM/AMRAP/TABATA/LADDER/FOR_TIME/CHIPPER/DEATH_BY \u2014 check `get_template`'s `workoutType`), pass `formatParams` instead: the complete new authoring parameters for that format, never an ops[] diff \u2014 the compiled rows are recomputed from formatParams on every save and an ops[] diff against them would be silently discarded. Exactly one of `ops`/`formatParams` may be set. Never mutates directly \u2014 the proposal is transported to the coach inbox for accept/reject. Fast-fails with an error (no network call) if the templateId is not found in the local snapshot, or if the proposal shape does not match the target template's own format. Inline `newExercises[]` entries that match an existing catalog exercise are automatically resolved to that catalog UUID instead of being proposed as duplicates.",
|
|
2756
|
+
inputSchema: ProposePlanUpdateSchema,
|
|
2757
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
2758
|
+
},
|
|
2759
|
+
async (args) => {
|
|
2760
|
+
try {
|
|
2761
|
+
const typedArgs = args;
|
|
2762
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
2763
|
+
const tpl = getTemplate({ templateId: typedArgs.templateId }, data.snapshot);
|
|
2764
|
+
if (!tpl || tpl.deletedAt != null) {
|
|
2765
|
+
return {
|
|
2766
|
+
isError: true,
|
|
2767
|
+
content: [{ type: "text", text: `Template not found: ${typedArgs.templateId}` }]
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
const verdict = validateFormatProposal(tpl.workoutType, typedArgs);
|
|
2771
|
+
if (verdict.kind === "refused") {
|
|
2772
|
+
return {
|
|
2773
|
+
isError: true,
|
|
2774
|
+
content: [
|
|
2775
|
+
{ type: "text", text: `${verdict.reason}: ${FORMAT_REFUSAL_MESSAGES[verdict.reason]}` }
|
|
2776
|
+
]
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
if (verdict.kind === "classicOps" && typedArgs.formatParams !== void 0) {
|
|
2780
|
+
return {
|
|
2781
|
+
isError: true,
|
|
2782
|
+
content: [
|
|
2783
|
+
{
|
|
2784
|
+
type: "text",
|
|
2785
|
+
text: `${FORMAT_PARAMS_ON_UNTYPED_TEMPLATE_REASON}: ${FORMAT_PARAMS_ON_UNTYPED_TEMPLATE_MESSAGE}`
|
|
2786
|
+
}
|
|
2787
|
+
]
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
const argsForPayload = verdict.kind === "format" ? typedArgs : { ...typedArgs, formatParams: void 0 };
|
|
2791
|
+
const payload = buildPlanUpdatePayload(argsForPayload, data.catalog);
|
|
2792
|
+
if (payload.formatParams === void 0 && payload.ops.length === 0) {
|
|
2793
|
+
return {
|
|
2794
|
+
isError: true,
|
|
2795
|
+
content: [
|
|
2796
|
+
{ type: "text", text: `${EMPTY_PLAN_UPDATE_REASON}: ${EMPTY_PLAN_UPDATE_MESSAGE}` }
|
|
2797
|
+
]
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
const planHash = computePlanHash(typedArgs.templateId, data.snapshot);
|
|
2801
|
+
const changeHash = computeChangeHash("plan_update", payload);
|
|
2802
|
+
const sourceLlm = getSourceLlm(server2);
|
|
2803
|
+
const result = await postSuggest(
|
|
2804
|
+
{
|
|
2805
|
+
type: "plan_update",
|
|
2806
|
+
payload: JSON.stringify(payload),
|
|
2807
|
+
rationale: typedArgs.rationale,
|
|
2808
|
+
sourceLlm,
|
|
2809
|
+
changeHash,
|
|
2810
|
+
planHash
|
|
2811
|
+
},
|
|
2812
|
+
pat2,
|
|
2813
|
+
serverUrl2
|
|
2814
|
+
);
|
|
2815
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
if (err instanceof HttpError) {
|
|
2818
|
+
console.error(`propose_plan_update: server ${err.status}`);
|
|
2819
|
+
return {
|
|
2820
|
+
isError: true,
|
|
2821
|
+
content: [{ type: "text", text: `Server rejected proposal: HTTP ${err.status}` }]
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
return {
|
|
2825
|
+
isError: true,
|
|
2826
|
+
content: [{ type: "text", text: `Propose plan update error: ${err.message}` }]
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// src/tools/propose_new_plan.ts
|
|
2834
|
+
function buildNewPlanPayload(args, catalog) {
|
|
2835
|
+
const tempIdToUuid = /* @__PURE__ */ new Map();
|
|
2836
|
+
const remainingNewExercises = [];
|
|
2837
|
+
for (const entry of args.newExercises ?? []) {
|
|
2838
|
+
const match = findCatalogMatch(entry.name, catalog);
|
|
2839
|
+
if (match) {
|
|
2840
|
+
tempIdToUuid.set(entry.tempId, match.id);
|
|
2841
|
+
} else {
|
|
2842
|
+
remainingNewExercises.push(entry);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
const rewriteRef = (ref) => {
|
|
2846
|
+
if (ref.source === "new" && tempIdToUuid.has(ref.tempId)) {
|
|
2847
|
+
return { source: "catalog", exerciseId: tempIdToUuid.get(ref.tempId) };
|
|
2848
|
+
}
|
|
2849
|
+
return ref;
|
|
2850
|
+
};
|
|
2851
|
+
const blocks = args.blocks.map((block) => ({
|
|
2852
|
+
tempBlockId: block.tempBlockId,
|
|
2853
|
+
rounds: block.rounds,
|
|
2854
|
+
orderIndex: block.orderIndex,
|
|
2855
|
+
exercises: block.exercises.map((ex) => ({
|
|
2856
|
+
exercise: rewriteRef(ex.exercise),
|
|
2857
|
+
mode: ex.mode,
|
|
2858
|
+
...ex.targetReps !== void 0 ? { targetReps: ex.targetReps } : {},
|
|
2859
|
+
...ex.targetTimeSeconds !== void 0 ? { targetTimeSeconds: ex.targetTimeSeconds } : {},
|
|
2860
|
+
restTimeSeconds: ex.restTimeSeconds,
|
|
2861
|
+
sets: ex.sets,
|
|
2862
|
+
...ex.targetWeight !== void 0 ? { targetWeight: ex.targetWeight } : {},
|
|
2863
|
+
orderIndex: ex.orderIndex,
|
|
2864
|
+
...ex.roundTargets !== void 0 ? { roundTargets: ex.roundTargets } : {}
|
|
2865
|
+
}))
|
|
2866
|
+
}));
|
|
2867
|
+
return { name: args.name, blocks, newExercises: remainingNewExercises };
|
|
2868
|
+
}
|
|
2869
|
+
function registerToolProposeNewPlan(server2, cfg) {
|
|
2870
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
2871
|
+
server2.registerTool(
|
|
2872
|
+
"propose_new_plan",
|
|
2873
|
+
{
|
|
2874
|
+
title: "Propose New Plan",
|
|
2875
|
+
description: "Propose a brand-new workout plan (blocks + exercises) for human review. Never creates it directly \u2014 the proposal is transported to the coach inbox for accept/reject. Inline `newExercises[]` entries that match an existing catalog exercise are automatically resolved to that catalog UUID instead of being proposed as duplicates.",
|
|
2876
|
+
inputSchema: ProposeNewPlanSchema,
|
|
2877
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
2878
|
+
},
|
|
2879
|
+
async (args) => {
|
|
2880
|
+
try {
|
|
2881
|
+
const typedArgs = args;
|
|
2882
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
2883
|
+
const payload = buildNewPlanPayload(typedArgs, data.catalog);
|
|
2884
|
+
const changeHash = computeChangeHash("new_plan", payload);
|
|
2885
|
+
const sourceLlm = getSourceLlm(server2);
|
|
2886
|
+
const result = await postSuggest(
|
|
2887
|
+
{
|
|
2888
|
+
type: "new_plan",
|
|
2889
|
+
payload: JSON.stringify(payload),
|
|
2890
|
+
rationale: typedArgs.rationale,
|
|
2891
|
+
sourceLlm,
|
|
2892
|
+
changeHash
|
|
2893
|
+
},
|
|
2894
|
+
pat2,
|
|
2895
|
+
serverUrl2
|
|
2896
|
+
);
|
|
2897
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
2898
|
+
} catch (err) {
|
|
2899
|
+
if (err instanceof HttpError) {
|
|
2900
|
+
console.error(`propose_new_plan: server ${err.status}`);
|
|
2901
|
+
return {
|
|
2902
|
+
isError: true,
|
|
2903
|
+
content: [{ type: "text", text: `Server rejected proposal: HTTP ${err.status}` }]
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
return {
|
|
2907
|
+
isError: true,
|
|
2908
|
+
content: [{ type: "text", text: `Propose new plan error: ${err.message}` }]
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
);
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// src/tools/propose_new_exercise.ts
|
|
2916
|
+
function registerToolProposeNewExercise(server2, cfg) {
|
|
2917
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
2918
|
+
server2.registerTool(
|
|
2919
|
+
"propose_new_exercise",
|
|
2920
|
+
{
|
|
2921
|
+
title: "Propose New Exercise",
|
|
2922
|
+
description: "Propose a brand-new custom exercise for human review. Never creates it directly \u2014 the proposal is transported to the coach inbox for accept/reject. If the name matches an existing catalog exercise, returns the existing UUID instead of proposing a duplicate.",
|
|
2923
|
+
inputSchema: ProposeNewExerciseSchema,
|
|
2924
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
2925
|
+
},
|
|
2926
|
+
async (args) => {
|
|
2927
|
+
try {
|
|
2928
|
+
const typedArgs = args;
|
|
2929
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
2930
|
+
const match = findCatalogMatch(typedArgs.name, data.catalog);
|
|
2931
|
+
if (match) {
|
|
2932
|
+
return {
|
|
2933
|
+
content: [{ type: "text", text: JSON.stringify({ dedupedTo: match.id }) }]
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
const payload = {
|
|
2937
|
+
name: typedArgs.name,
|
|
2938
|
+
mode: typedArgs.mode,
|
|
2939
|
+
usesWeight: typedArgs.usesWeight,
|
|
2940
|
+
...typedArgs.description ? { description: typedArgs.description } : {}
|
|
2941
|
+
};
|
|
2942
|
+
const changeHash = computeChangeHash("new_exercise", payload);
|
|
2943
|
+
const sourceLlm = getSourceLlm(server2);
|
|
2944
|
+
const result = await postSuggest(
|
|
2945
|
+
{
|
|
2946
|
+
type: "new_exercise",
|
|
2947
|
+
payload: JSON.stringify(payload),
|
|
2948
|
+
rationale: typedArgs.rationale,
|
|
2949
|
+
sourceLlm,
|
|
2950
|
+
changeHash
|
|
2951
|
+
},
|
|
2952
|
+
pat2,
|
|
2953
|
+
serverUrl2
|
|
2954
|
+
);
|
|
2955
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
if (err instanceof HttpError) {
|
|
2958
|
+
console.error(`propose_new_exercise: server ${err.status}`);
|
|
2959
|
+
return {
|
|
2960
|
+
isError: true,
|
|
2961
|
+
content: [{ type: "text", text: `Server rejected proposal: HTTP ${err.status}` }]
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
return {
|
|
2965
|
+
isError: true,
|
|
2966
|
+
content: [{ type: "text", text: `Propose new exercise error: ${err.message}` }]
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// src/tools/propose_planned_update.ts
|
|
2974
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2975
|
+
|
|
2976
|
+
// src/rrule-allowlist.ts
|
|
2977
|
+
var ALLOWED_PARAMS = /* @__PURE__ */ new Set(["FREQ", "INTERVAL", "BYDAY", "BYMONTHDAY", "UNTIL"]);
|
|
2978
|
+
var ALLOWED_FREQ = /* @__PURE__ */ new Set(["DAILY", "WEEKLY", "MONTHLY"]);
|
|
2979
|
+
var ALLOWED_BYDAY = /* @__PURE__ */ new Set(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]);
|
|
2980
|
+
var REPLACEMENTS = {
|
|
2981
|
+
COUNT_NOT_SUPPORTED: "UNTIL=YYYYMMDD",
|
|
2982
|
+
YEARLY_NOT_SUPPORTED: "FREQ=MONTHLY;INTERVAL=12",
|
|
2983
|
+
BYSETPOS_NOT_SUPPORTED: null,
|
|
2984
|
+
ORDINAL_BYDAY_NOT_SUPPORTED: "BYDAY=MO combined with INTERVAL",
|
|
2985
|
+
MULTI_VALUE_BYMONTHDAY_NOT_SUPPORTED: "a single BYMONTHDAY value, or two roots sharing one recurrenceGroupId",
|
|
2986
|
+
INTERVAL_MUST_BE_POSITIVE: "INTERVAL must be 1 or greater",
|
|
2987
|
+
BYMONTHDAY_OUT_OF_RANGE: "BYMONTHDAY must be between 1 and 31",
|
|
2988
|
+
UNKNOWN_TOKEN: "Omit the token \u2014 the grammar above is the full closed allowlist"
|
|
2989
|
+
};
|
|
2990
|
+
function reject(code) {
|
|
2991
|
+
return { valid: false, reasonCode: code, replacement: REPLACEMENTS[code] };
|
|
2992
|
+
}
|
|
2993
|
+
function parseRRuleParams(rrule) {
|
|
2994
|
+
const params = {};
|
|
2995
|
+
for (const part of rrule.split(";")) {
|
|
2996
|
+
const idx = part.indexOf("=");
|
|
2997
|
+
if (idx < 0) {
|
|
2998
|
+
params[part] = "";
|
|
2999
|
+
} else {
|
|
3000
|
+
params[part.slice(0, idx)] = part.slice(idx + 1);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
return params;
|
|
3004
|
+
}
|
|
3005
|
+
function parseIntOrNull2(s) {
|
|
3006
|
+
if (!/^[+-]?\d+$/.test(s)) return null;
|
|
3007
|
+
const n = Number(s);
|
|
3008
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
3009
|
+
return n >= -2147483648 && n <= 2147483647 ? n : null;
|
|
3010
|
+
}
|
|
3011
|
+
function isValidUntilDate(value) {
|
|
3012
|
+
if (!/^\d{8}$/.test(value)) return false;
|
|
3013
|
+
const y = value.slice(0, 4);
|
|
3014
|
+
const m = value.slice(4, 6);
|
|
3015
|
+
const d = value.slice(6, 8);
|
|
3016
|
+
try {
|
|
3017
|
+
parseIsoDate(`${y}-${m}-${d}`);
|
|
3018
|
+
return true;
|
|
3019
|
+
} catch {
|
|
3020
|
+
return false;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
function validateRRule(rrule) {
|
|
3024
|
+
if (rrule == null || rrule.trim() === "") return { valid: true };
|
|
3025
|
+
const params = parseRRuleParams(rrule);
|
|
3026
|
+
if ("COUNT" in params) return reject("COUNT_NOT_SUPPORTED");
|
|
3027
|
+
if ("BYSETPOS" in params) return reject("BYSETPOS_NOT_SUPPORTED");
|
|
3028
|
+
for (const key of Object.keys(params)) {
|
|
3029
|
+
if (!ALLOWED_PARAMS.has(key)) return reject("UNKNOWN_TOKEN");
|
|
3030
|
+
}
|
|
3031
|
+
const freq = params.FREQ;
|
|
3032
|
+
if (freq === "YEARLY") return reject("YEARLY_NOT_SUPPORTED");
|
|
3033
|
+
if (freq !== void 0 && !ALLOWED_FREQ.has(freq)) return reject("UNKNOWN_TOKEN");
|
|
3034
|
+
if (params.BYDAY !== void 0) {
|
|
3035
|
+
const codes = params.BYDAY.split(",").map((c) => c.trim());
|
|
3036
|
+
if (codes.some((c) => !ALLOWED_BYDAY.has(c))) return reject("ORDINAL_BYDAY_NOT_SUPPORTED");
|
|
3037
|
+
}
|
|
3038
|
+
if (params.BYMONTHDAY !== void 0) {
|
|
3039
|
+
if (params.BYMONTHDAY.includes(",")) return reject("MULTI_VALUE_BYMONTHDAY_NOT_SUPPORTED");
|
|
3040
|
+
const value = parseIntOrNull2(params.BYMONTHDAY);
|
|
3041
|
+
if (value === null || value < 1 || value > 31) return reject("BYMONTHDAY_OUT_OF_RANGE");
|
|
3042
|
+
}
|
|
3043
|
+
if (params.INTERVAL !== void 0) {
|
|
3044
|
+
const value = parseIntOrNull2(params.INTERVAL);
|
|
3045
|
+
if (value === null || value < 1) return reject("INTERVAL_MUST_BE_POSITIVE");
|
|
3046
|
+
}
|
|
3047
|
+
if (params.UNTIL !== void 0) {
|
|
3048
|
+
if (!isValidUntilDate(params.UNTIL)) return reject("UNKNOWN_TOKEN");
|
|
3049
|
+
}
|
|
3050
|
+
return { valid: true };
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// src/series-hash.ts
|
|
3054
|
+
import { createHash as createHash2 } from "crypto";
|
|
3055
|
+
function parseDeduplicatedSorted(raw) {
|
|
3056
|
+
if (raw === null || raw.trim() === "") return [];
|
|
3057
|
+
let body = raw.trim();
|
|
3058
|
+
if (body.length >= 2 && body.startsWith("[") && body.endsWith("]")) {
|
|
3059
|
+
body = body.slice(1, -1);
|
|
3060
|
+
}
|
|
3061
|
+
const dates = [];
|
|
3062
|
+
for (const rawToken of body.split(",")) {
|
|
3063
|
+
let token = rawToken.trim();
|
|
3064
|
+
if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) {
|
|
3065
|
+
token = token.slice(1, -1);
|
|
3066
|
+
}
|
|
3067
|
+
if (token === "") continue;
|
|
3068
|
+
dates.push(token);
|
|
3069
|
+
}
|
|
3070
|
+
return Array.from(new Set(dates)).sort();
|
|
3071
|
+
}
|
|
3072
|
+
function canonicalSeriesString(roots) {
|
|
3073
|
+
const sorted = [...roots].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
3074
|
+
const objects = sorted.map((r) => ({
|
|
3075
|
+
id: r.id,
|
|
3076
|
+
templateId: r.templateId,
|
|
3077
|
+
scheduledDate: r.scheduledDate,
|
|
3078
|
+
scheduledTime: r.scheduledTime ?? null,
|
|
3079
|
+
recurrenceRule: r.recurrenceRule ?? null,
|
|
3080
|
+
recurrenceGroupId: r.recurrenceGroupId ?? null,
|
|
3081
|
+
deletedOccurrences: parseDeduplicatedSorted(r.deletedOccurrencesRaw)
|
|
3082
|
+
}));
|
|
3083
|
+
return canonicalStringify(objects);
|
|
3084
|
+
}
|
|
3085
|
+
function computeSeriesHash(roots) {
|
|
3086
|
+
return createHash2("sha256").update(canonicalSeriesString(roots), "utf8").digest("hex");
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// src/tools/propose_planned_update.ts
|
|
3090
|
+
var PlannedUpdateRejection = class extends Error {
|
|
3091
|
+
constructor(cause, message) {
|
|
3092
|
+
super(message);
|
|
3093
|
+
this.cause = cause;
|
|
3094
|
+
this.name = "PlannedUpdateRejection";
|
|
3095
|
+
}
|
|
3096
|
+
cause;
|
|
3097
|
+
};
|
|
3098
|
+
function buildMoveOccurrenceEnvelope(args, root) {
|
|
3099
|
+
const isRecurring = root.recurrenceRule !== null && root.recurrenceRule.trim() !== "";
|
|
3100
|
+
const existingDeleted = parseDeletedOccurrences(root.deletedOccurrencesRaw);
|
|
3101
|
+
const newScheduledTime = args.newTime ?? root.scheduledTime;
|
|
3102
|
+
if (!isRecurring) {
|
|
3103
|
+
const replaced = {
|
|
3104
|
+
id: root.id,
|
|
3105
|
+
templateId: root.templateId,
|
|
3106
|
+
scheduledDate: args.newDate,
|
|
3107
|
+
scheduledTime: newScheduledTime,
|
|
3108
|
+
note: root.note,
|
|
3109
|
+
recurrenceRule: null,
|
|
3110
|
+
recurrenceGroupId: null,
|
|
3111
|
+
deletedOccurrences: existingDeleted
|
|
3112
|
+
};
|
|
3113
|
+
return {
|
|
3114
|
+
kind: "occurrence",
|
|
3115
|
+
scope: "this_occurrence",
|
|
3116
|
+
roots: [{ operation: "replace", root: replaced }]
|
|
3117
|
+
};
|
|
3118
|
+
}
|
|
3119
|
+
const newDeleted = Array.from(/* @__PURE__ */ new Set([...existingDeleted, args.occurrenceDate])).sort();
|
|
3120
|
+
const originalScheduledDate = formatIsoDate(epochMsToUtcCalDate(root.scheduledDate));
|
|
3121
|
+
const originalRoot = {
|
|
3122
|
+
id: root.id,
|
|
3123
|
+
templateId: root.templateId,
|
|
3124
|
+
scheduledDate: originalScheduledDate,
|
|
3125
|
+
scheduledTime: root.scheduledTime,
|
|
3126
|
+
note: root.note,
|
|
3127
|
+
recurrenceRule: root.recurrenceRule,
|
|
3128
|
+
recurrenceGroupId: root.recurrenceGroupId,
|
|
3129
|
+
deletedOccurrences: newDeleted
|
|
3130
|
+
};
|
|
3131
|
+
const newRoot = {
|
|
3132
|
+
id: randomUUID2(),
|
|
3133
|
+
templateId: root.templateId,
|
|
3134
|
+
scheduledDate: args.newDate,
|
|
3135
|
+
scheduledTime: newScheduledTime,
|
|
3136
|
+
note: null,
|
|
3137
|
+
recurrenceRule: null,
|
|
3138
|
+
recurrenceGroupId: null,
|
|
3139
|
+
deletedOccurrences: []
|
|
3140
|
+
};
|
|
3141
|
+
return {
|
|
3142
|
+
kind: "occurrence",
|
|
3143
|
+
scope: "this_occurrence",
|
|
3144
|
+
roots: [
|
|
3145
|
+
{ operation: "replace", root: originalRoot },
|
|
3146
|
+
{ operation: "create", root: newRoot }
|
|
3147
|
+
]
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function buildCancelOccurrenceEnvelope(args, root) {
|
|
3151
|
+
const isRecurring = root.recurrenceRule !== null && root.recurrenceRule.trim() !== "";
|
|
3152
|
+
const scheduledDate = formatIsoDate(epochMsToUtcCalDate(root.scheduledDate));
|
|
3153
|
+
if (!isRecurring) {
|
|
3154
|
+
const deleted = {
|
|
3155
|
+
id: root.id,
|
|
3156
|
+
templateId: root.templateId,
|
|
3157
|
+
scheduledDate,
|
|
3158
|
+
scheduledTime: root.scheduledTime,
|
|
3159
|
+
note: root.note,
|
|
3160
|
+
recurrenceRule: null,
|
|
3161
|
+
recurrenceGroupId: null,
|
|
3162
|
+
deletedOccurrences: []
|
|
3163
|
+
};
|
|
3164
|
+
return {
|
|
3165
|
+
kind: "occurrence",
|
|
3166
|
+
scope: "this_occurrence",
|
|
3167
|
+
roots: [{ operation: "delete", root: deleted }]
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
const existingDeleted = parseDeletedOccurrences(root.deletedOccurrencesRaw);
|
|
3171
|
+
const newDeleted = Array.from(/* @__PURE__ */ new Set([...existingDeleted, args.occurrenceDate])).sort();
|
|
3172
|
+
const replaced = {
|
|
3173
|
+
id: root.id,
|
|
3174
|
+
templateId: root.templateId,
|
|
3175
|
+
scheduledDate,
|
|
3176
|
+
scheduledTime: root.scheduledTime,
|
|
3177
|
+
note: root.note,
|
|
3178
|
+
recurrenceRule: root.recurrenceRule,
|
|
3179
|
+
recurrenceGroupId: root.recurrenceGroupId,
|
|
3180
|
+
deletedOccurrences: newDeleted
|
|
3181
|
+
};
|
|
3182
|
+
return {
|
|
3183
|
+
kind: "occurrence",
|
|
3184
|
+
scope: "this_occurrence",
|
|
3185
|
+
roots: [{ operation: "replace", root: replaced }]
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
function buildScheduleWorkoutEnvelope(args) {
|
|
3189
|
+
const created = {
|
|
3190
|
+
id: randomUUID2(),
|
|
3191
|
+
...args.templateId !== void 0 ? { templateId: args.templateId } : {},
|
|
3192
|
+
...args.chainSuggestionId !== void 0 ? { chainSuggestionId: args.chainSuggestionId } : {},
|
|
3193
|
+
scheduledDate: args.date,
|
|
3194
|
+
scheduledTime: args.time ?? null,
|
|
3195
|
+
note: null,
|
|
3196
|
+
recurrenceRule: args.recurrenceRule ?? null,
|
|
3197
|
+
recurrenceGroupId: null,
|
|
3198
|
+
deletedOccurrences: []
|
|
3199
|
+
};
|
|
3200
|
+
return {
|
|
3201
|
+
kind: "occurrence",
|
|
3202
|
+
scope: "this_occurrence",
|
|
3203
|
+
roots: [{ operation: "create", root: created }]
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
function replaceOrAppendUntil(rrule, untilWire) {
|
|
3207
|
+
const parts = rrule.split(";");
|
|
3208
|
+
let found = false;
|
|
3209
|
+
const updated = parts.map((part) => {
|
|
3210
|
+
const eqIdx = part.indexOf("=");
|
|
3211
|
+
const key = eqIdx === -1 ? part : part.slice(0, eqIdx);
|
|
3212
|
+
if (key === "UNTIL") {
|
|
3213
|
+
found = true;
|
|
3214
|
+
return `UNTIL=${untilWire}`;
|
|
3215
|
+
}
|
|
3216
|
+
return part;
|
|
3217
|
+
});
|
|
3218
|
+
if (!found) updated.push(`UNTIL=${untilWire}`);
|
|
3219
|
+
return updated.join(";");
|
|
3220
|
+
}
|
|
3221
|
+
function splitMemberAtCutoff(member, cutoffDate, newRecurrenceRule) {
|
|
3222
|
+
const cutoffCal = parseIsoDate(cutoffDate);
|
|
3223
|
+
const memberScheduledCal = epochMsToUtcCalDate(member.scheduledDate);
|
|
3224
|
+
const memberScheduledIso = formatIsoDate(memberScheduledCal);
|
|
3225
|
+
const existingDeleted = parseDeletedOccurrences(member.deletedOccurrencesRaw);
|
|
3226
|
+
const before = existingDeleted.filter((d) => compareCalDate(parseIsoDate(d), cutoffCal) < 0);
|
|
3227
|
+
const onOrAfter = existingDeleted.filter((d) => compareCalDate(parseIsoDate(d), cutoffCal) >= 0);
|
|
3228
|
+
if (compareCalDate(cutoffCal, memberScheduledCal) <= 0) {
|
|
3229
|
+
assertRuleEmittableOnRoot(newRecurrenceRule, memberScheduledIso, member.id);
|
|
3230
|
+
const replaced = {
|
|
3231
|
+
id: member.id,
|
|
3232
|
+
templateId: member.templateId,
|
|
3233
|
+
scheduledDate: memberScheduledIso,
|
|
3234
|
+
scheduledTime: member.scheduledTime,
|
|
3235
|
+
note: member.note,
|
|
3236
|
+
recurrenceRule: newRecurrenceRule,
|
|
3237
|
+
recurrenceGroupId: member.recurrenceGroupId,
|
|
3238
|
+
deletedOccurrences: onOrAfter
|
|
3239
|
+
};
|
|
3240
|
+
return [{ operation: "replace", root: replaced }];
|
|
3241
|
+
}
|
|
3242
|
+
const untilCal = plusDays(cutoffCal, -1);
|
|
3243
|
+
const untilWire = formatIsoDate(untilCal).replace(/-/g, "");
|
|
3244
|
+
const clampedRule = replaceOrAppendUntil(member.recurrenceRule, untilWire);
|
|
3245
|
+
assertRuleEmittableOnRoot(clampedRule, memberScheduledIso, member.id);
|
|
3246
|
+
const oldRoot = {
|
|
3247
|
+
id: member.id,
|
|
3248
|
+
templateId: member.templateId,
|
|
3249
|
+
scheduledDate: memberScheduledIso,
|
|
3250
|
+
scheduledTime: member.scheduledTime,
|
|
3251
|
+
note: member.note,
|
|
3252
|
+
recurrenceRule: clampedRule,
|
|
3253
|
+
recurrenceGroupId: member.recurrenceGroupId,
|
|
3254
|
+
deletedOccurrences: before
|
|
3255
|
+
};
|
|
3256
|
+
const newRootId = randomUUID2();
|
|
3257
|
+
assertRuleEmittableOnRoot(newRecurrenceRule, cutoffDate, newRootId);
|
|
3258
|
+
const newRoot = {
|
|
3259
|
+
id: newRootId,
|
|
3260
|
+
templateId: member.templateId,
|
|
3261
|
+
scheduledDate: cutoffDate,
|
|
3262
|
+
scheduledTime: member.scheduledTime,
|
|
3263
|
+
note: null,
|
|
3264
|
+
recurrenceRule: newRecurrenceRule,
|
|
3265
|
+
recurrenceGroupId: member.recurrenceGroupId,
|
|
3266
|
+
deletedOccurrences: onOrAfter
|
|
3267
|
+
};
|
|
3268
|
+
return [
|
|
3269
|
+
{ operation: "replace", root: oldRoot },
|
|
3270
|
+
{ operation: "create", root: newRoot }
|
|
3271
|
+
];
|
|
3272
|
+
}
|
|
3273
|
+
function buildChangeSeriesRuleEnvelope(args, members) {
|
|
3274
|
+
if (args.scope === "whole_series") {
|
|
3275
|
+
const roots2 = members.map((member) => {
|
|
3276
|
+
const memberScheduledIso = formatIsoDate(epochMsToUtcCalDate(member.scheduledDate));
|
|
3277
|
+
assertRuleEmittableOnRoot(args.newRecurrenceRule, memberScheduledIso, member.id);
|
|
3278
|
+
return {
|
|
3279
|
+
operation: "replace",
|
|
3280
|
+
root: {
|
|
3281
|
+
id: member.id,
|
|
3282
|
+
templateId: member.templateId,
|
|
3283
|
+
scheduledDate: memberScheduledIso,
|
|
3284
|
+
scheduledTime: member.scheduledTime,
|
|
3285
|
+
note: member.note,
|
|
3286
|
+
recurrenceRule: args.newRecurrenceRule,
|
|
3287
|
+
recurrenceGroupId: member.recurrenceGroupId,
|
|
3288
|
+
deletedOccurrences: parseDeletedOccurrences(member.deletedOccurrencesRaw)
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3291
|
+
});
|
|
3292
|
+
return { kind: "rule", scope: "whole_series", roots: roots2 };
|
|
3293
|
+
}
|
|
3294
|
+
const cutoffDate = args.cutoffDate;
|
|
3295
|
+
const roots = [];
|
|
3296
|
+
for (const member of members) {
|
|
3297
|
+
roots.push(...splitMemberAtCutoff(member, cutoffDate, args.newRecurrenceRule));
|
|
3298
|
+
}
|
|
3299
|
+
return { kind: "rule", scope: "this_and_following", roots };
|
|
3300
|
+
}
|
|
3301
|
+
function findRoot(data, rootId) {
|
|
3302
|
+
const root = data.snapshot.plannedWorkouts.find((r) => r.id === rootId);
|
|
3303
|
+
if (!root) {
|
|
3304
|
+
throw new PlannedUpdateRejection("ROOT_NOT_FOUND", `Unknown rootId: ${rootId}`);
|
|
3305
|
+
}
|
|
3306
|
+
return root;
|
|
3307
|
+
}
|
|
3308
|
+
function validateStoredRule(root) {
|
|
3309
|
+
const rruleCheck = validateRRule(root.recurrenceRule);
|
|
3310
|
+
if (!rruleCheck.valid) {
|
|
3311
|
+
throw new PlannedUpdateRejection(
|
|
3312
|
+
rruleCheck.reasonCode,
|
|
3313
|
+
`Stored recurrenceRule for root ${root.id} is rejected by the allowlist: ${rruleCheck.reasonCode}`
|
|
3314
|
+
);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
function assertRuleEmittableOnRoot(rule, dtstartIso, rootId) {
|
|
3318
|
+
const rruleCheck = validateRRule(rule);
|
|
3319
|
+
if (!rruleCheck.valid) {
|
|
3320
|
+
throw new PlannedUpdateRejection(
|
|
3321
|
+
rruleCheck.reasonCode,
|
|
3322
|
+
`recurrenceRule "${rule}" for root ${rootId} is rejected by the allowlist: ${rruleCheck.reasonCode}`
|
|
3323
|
+
);
|
|
3324
|
+
}
|
|
3325
|
+
const until = parseRRuleConfig(rule).until;
|
|
3326
|
+
if (until !== null && compareCalDate(parseIsoDate(until), parseIsoDate(dtstartIso)) < 0) {
|
|
3327
|
+
throw new PlannedUpdateRejection(
|
|
3328
|
+
"RULE_NOT_EMITTABLE",
|
|
3329
|
+
`recurrenceRule "${rule}" for root ${rootId} has UNTIL=${until}, which precedes its own scheduledDate ${dtstartIso} \u2014 this rule can never emit an occurrence on this root`
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
function confirmOccurrence(root, occurrenceDate, options) {
|
|
3334
|
+
const dtstart = epochMsToUtcCalDate(root.scheduledDate);
|
|
3335
|
+
const deletedSet = options?.ignoreDeleted ? /* @__PURE__ */ new Set() : new Set(parseDeletedOccurrences(root.deletedOccurrencesRaw));
|
|
3336
|
+
const occurrenceDateCal = parseIsoDate(occurrenceDate);
|
|
3337
|
+
const expansion = expandDates(dtstart, root.recurrenceRule, occurrenceDateCal, occurrenceDateCal, deletedSet, root.id);
|
|
3338
|
+
if (expansion.length === 0) {
|
|
3339
|
+
throw new PlannedUpdateRejection(
|
|
3340
|
+
"OCCURRENCE_NOT_IN_EXPANSION",
|
|
3341
|
+
`occurrenceDate ${occurrenceDate} is not an occurrence of root ${root.id}`
|
|
3342
|
+
);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
function resolveGroupMembers(data, root) {
|
|
3346
|
+
if (root.recurrenceGroupId === null) return [root];
|
|
3347
|
+
const members = data.snapshot.plannedWorkouts.filter((r) => r.recurrenceGroupId === root.recurrenceGroupId).slice().sort((a, b) => a.scheduledDate - b.scheduledDate);
|
|
3348
|
+
const incomplete = members.find((m) => m.recurrenceRule === null || m.recurrenceRule.trim() === "");
|
|
3349
|
+
if (incomplete) {
|
|
3350
|
+
throw new PlannedUpdateRejection(
|
|
3351
|
+
"PARTIAL_GROUP_REJECTED",
|
|
3352
|
+
`recurrenceGroupId ${root.recurrenceGroupId} has member ${incomplete.id} with no recurrenceRule \u2014 multi-root groups are indivisible (protocol \xA72), refusing to split a partial group`
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3355
|
+
return members;
|
|
3356
|
+
}
|
|
3357
|
+
function resolveBeforeStateRoots(data, envelope) {
|
|
3358
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3359
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
3360
|
+
for (const op of envelope.roots) {
|
|
3361
|
+
ids.add(op.root.id);
|
|
3362
|
+
if (op.root.recurrenceGroupId !== null) {
|
|
3363
|
+
groupIds.add(op.root.recurrenceGroupId);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
for (const row of data.snapshot.plannedWorkouts) {
|
|
3367
|
+
if (row.recurrenceGroupId !== null && groupIds.has(row.recurrenceGroupId)) {
|
|
3368
|
+
ids.add(row.id);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
const rows = [];
|
|
3372
|
+
for (const id of ids) {
|
|
3373
|
+
const row = data.snapshot.plannedWorkouts.find((r) => r.id === id);
|
|
3374
|
+
if (row !== void 0) {
|
|
3375
|
+
rows.push(row);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
return rows;
|
|
3379
|
+
}
|
|
3380
|
+
function storedRootToSeriesHashRoot(row) {
|
|
3381
|
+
return {
|
|
3382
|
+
id: row.id,
|
|
3383
|
+
templateId: row.templateId,
|
|
3384
|
+
scheduledDate: row.scheduledDate,
|
|
3385
|
+
scheduledTime: row.scheduledTime,
|
|
3386
|
+
recurrenceRule: row.recurrenceRule,
|
|
3387
|
+
recurrenceGroupId: row.recurrenceGroupId,
|
|
3388
|
+
deletedOccurrencesRaw: row.deletedOccurrencesRaw
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
async function proposePlannedUpdate(args, cfg) {
|
|
3392
|
+
const data = await getSnapshot(cfg.pat, cfg.keyB64, cfg.serverUrl);
|
|
3393
|
+
let envelope;
|
|
3394
|
+
switch (args.intent) {
|
|
3395
|
+
case "move_occurrence": {
|
|
3396
|
+
const root = findRoot(data, args.rootId);
|
|
3397
|
+
validateStoredRule(root);
|
|
3398
|
+
confirmOccurrence(root, args.occurrenceDate);
|
|
3399
|
+
envelope = buildMoveOccurrenceEnvelope(args, root);
|
|
3400
|
+
break;
|
|
3401
|
+
}
|
|
3402
|
+
case "cancel_occurrence": {
|
|
3403
|
+
const root = findRoot(data, args.rootId);
|
|
3404
|
+
validateStoredRule(root);
|
|
3405
|
+
confirmOccurrence(root, args.occurrenceDate, { ignoreDeleted: true });
|
|
3406
|
+
envelope = buildCancelOccurrenceEnvelope(args, root);
|
|
3407
|
+
break;
|
|
3408
|
+
}
|
|
3409
|
+
case "schedule_workout": {
|
|
3410
|
+
if (args.templateId === void 0 === (args.chainSuggestionId === void 0)) {
|
|
3411
|
+
throw new PlannedUpdateRejection(
|
|
3412
|
+
"CHAIN_EXCLUSIVITY",
|
|
3413
|
+
"schedule_workout requires exactly one of templateId or chainSuggestionId, never both, never neither"
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
if (args.chainSuggestionId !== void 0) {
|
|
3417
|
+
const referenced = await fetchSuggestion(cfg, args.chainSuggestionId);
|
|
3418
|
+
if (!referenced) {
|
|
3419
|
+
throw new PlannedUpdateRejection(
|
|
3420
|
+
"CHAIN_SUGGESTION_NOT_FOUND",
|
|
3421
|
+
`Unknown or foreign chainSuggestionId: ${args.chainSuggestionId}`
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
3424
|
+
if (referenced.type !== "new_plan") {
|
|
3425
|
+
throw new PlannedUpdateRejection(
|
|
3426
|
+
"CHAIN_SUGGESTION_WRONG_TYPE",
|
|
3427
|
+
`chainSuggestionId ${args.chainSuggestionId} does not reference a new_plan proposal (type: ${referenced.type})`
|
|
3428
|
+
);
|
|
3429
|
+
}
|
|
3430
|
+
if (referenced.status !== "pending") {
|
|
3431
|
+
throw new PlannedUpdateRejection(
|
|
3432
|
+
"CHAIN_SUGGESTION_NOT_PENDING",
|
|
3433
|
+
`chainSuggestionId ${args.chainSuggestionId} is no longer pending (status: ${referenced.status})`
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
} else {
|
|
3437
|
+
const template = data.snapshot.templates.find((t) => t.id === args.templateId);
|
|
3438
|
+
if (!template) {
|
|
3439
|
+
throw new PlannedUpdateRejection("TEMPLATE_NOT_FOUND", `Unknown templateId: ${args.templateId}`);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
const rruleCheck = validateRRule(args.recurrenceRule);
|
|
3443
|
+
if (!rruleCheck.valid) {
|
|
3444
|
+
throw new PlannedUpdateRejection(
|
|
3445
|
+
rruleCheck.reasonCode,
|
|
3446
|
+
`recurrenceRule is rejected by the allowlist: ${rruleCheck.reasonCode}` + (rruleCheck.replacement !== null ? ` (permitted substitute: ${rruleCheck.replacement})` : "")
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
envelope = buildScheduleWorkoutEnvelope(args);
|
|
3450
|
+
break;
|
|
3451
|
+
}
|
|
3452
|
+
case "change_series_rule": {
|
|
3453
|
+
const root = findRoot(data, args.rootId);
|
|
3454
|
+
if (root.recurrenceRule === null || root.recurrenceRule.trim() === "") {
|
|
3455
|
+
throw new PlannedUpdateRejection(
|
|
3456
|
+
"NO_RECURRENCE_RULE",
|
|
3457
|
+
`root ${args.rootId} has no recurrenceRule \u2014 there is no series to change`
|
|
3458
|
+
);
|
|
3459
|
+
}
|
|
3460
|
+
const members = resolveGroupMembers(data, root);
|
|
3461
|
+
const rruleCheck = validateRRule(args.newRecurrenceRule);
|
|
3462
|
+
if (!rruleCheck.valid) {
|
|
3463
|
+
throw new PlannedUpdateRejection(
|
|
3464
|
+
rruleCheck.reasonCode,
|
|
3465
|
+
`newRecurrenceRule is rejected by the allowlist: ${rruleCheck.reasonCode}` + (rruleCheck.replacement !== null ? ` (permitted substitute: ${rruleCheck.replacement})` : "")
|
|
3466
|
+
);
|
|
3467
|
+
}
|
|
3468
|
+
if (args.scope === "this_and_following" && args.cutoffDate === void 0) {
|
|
3469
|
+
throw new PlannedUpdateRejection(
|
|
3470
|
+
"MISSING_CUTOFF_DATE",
|
|
3471
|
+
"cutoffDate is required when scope is this_and_following"
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
envelope = buildChangeSeriesRuleEnvelope(args, members);
|
|
3475
|
+
break;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
const seriesHash = computeSeriesHash(resolveBeforeStateRoots(data, envelope).map(storedRootToSeriesHashRoot));
|
|
3479
|
+
const changeHash = computeChangeHash("planned_update", envelope);
|
|
3480
|
+
const result = await postSuggest(
|
|
3481
|
+
{
|
|
3482
|
+
type: "planned_update",
|
|
3483
|
+
payload: JSON.stringify(envelope),
|
|
3484
|
+
rationale: args.rationale,
|
|
3485
|
+
sourceLlm: cfg.sourceLlm,
|
|
3486
|
+
changeHash,
|
|
3487
|
+
seriesHash
|
|
3488
|
+
},
|
|
3489
|
+
cfg.pat,
|
|
3490
|
+
cfg.serverUrl
|
|
3491
|
+
);
|
|
3492
|
+
return { suggestionId: result.id, status: result.status };
|
|
3493
|
+
}
|
|
3494
|
+
function registerToolProposePlannedUpdate(server2, cfg) {
|
|
3495
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
3496
|
+
server2.registerTool(
|
|
3497
|
+
"propose_planned_update",
|
|
3498
|
+
{
|
|
3499
|
+
title: "Propose Planned Update",
|
|
3500
|
+
description: "Propose a change to the calendar for human review \u2014 name an intent, never a raw envelope. Four intents: move_occurrence (reschedule one occurrence, identified by rootId + occurrenceDate, to newDate), cancel_occurrence (cancel one occurrence, identified by rootId + occurrenceDate), schedule_workout (create a standalone or recurring root for templateId on date, optionally recurrenceRule), and change_series_rule (replace rootId's recurrence rule at scope whole_series or this_and_following \u2014 the latter requires cutoffDate and splits the series there). All root/template ids and dates come from get_planned_workouts. Rejects with a named cause \u2014 no network call \u2014 for an unknown root or template, an occurrenceDate that is not actually an occurrence of that root, a recurrence rule that fails allowlist validation, a root with no recurrence rule to change, or a recurrenceGroupId group that is not fully intact. Never mutates directly \u2014 the proposal is transported to the coach inbox for accept/reject.",
|
|
3501
|
+
inputSchema: ProposePlannedUpdateSchema,
|
|
3502
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
3503
|
+
},
|
|
3504
|
+
async (args) => {
|
|
3505
|
+
try {
|
|
3506
|
+
const typedArgs = args;
|
|
3507
|
+
const sourceLlm = getSourceLlm(server2);
|
|
3508
|
+
const result = await proposePlannedUpdate(typedArgs, { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2, sourceLlm });
|
|
3509
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3510
|
+
} catch (err) {
|
|
3511
|
+
if (err instanceof PlannedUpdateRejection) {
|
|
3512
|
+
return {
|
|
3513
|
+
isError: true,
|
|
3514
|
+
content: [{ type: "text", text: `${err.cause}: ${err.message}` }]
|
|
3515
|
+
};
|
|
3516
|
+
}
|
|
3517
|
+
if (err instanceof HttpError) {
|
|
3518
|
+
console.error(`propose_planned_update: server ${err.status}`);
|
|
3519
|
+
return {
|
|
3520
|
+
isError: true,
|
|
3521
|
+
content: [{ type: "text", text: `Server rejected proposal: HTTP ${err.status}` }]
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
return {
|
|
3525
|
+
isError: true,
|
|
3526
|
+
content: [{ type: "text", text: `Propose planned update error: ${err.message}` }]
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
// src/tools/get_suggestions.ts
|
|
3534
|
+
var DEFAULT_LIMIT = 200;
|
|
3535
|
+
var MIN_LIMIT = 1;
|
|
3536
|
+
var MAX_LIMIT = 500;
|
|
3537
|
+
function clampSuggestionsLimit(limit) {
|
|
3538
|
+
if (limit === void 0) return DEFAULT_LIMIT;
|
|
3539
|
+
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, limit));
|
|
3540
|
+
}
|
|
3541
|
+
async function getSuggestions(args, cfg) {
|
|
3542
|
+
return fetchSuggestions(cfg, args.status, clampSuggestionsLimit(args.limit));
|
|
3543
|
+
}
|
|
3544
|
+
function registerToolGetSuggestions(server2, cfg) {
|
|
3545
|
+
server2.registerTool(
|
|
3546
|
+
"get_suggestions",
|
|
3547
|
+
{
|
|
3548
|
+
title: "Get Coach Suggestions",
|
|
3549
|
+
description: "List the coach's own proposals as cheap summary rows, newest first: id, type, status, created date, a human-readable target label, and a short rationale. No payload included. Call this first; call `get_suggestion` with one id only when you need the full proposal, including its outcome (`appliedPayload` for a modified accept). Optional `status` filter (pending, accepted, accepted_modified, rejected, expired, withdrawn, obsolete); optional `limit` (default 200, clamped to 1..500). Empty array, never an error, when you have no proposals.",
|
|
3550
|
+
inputSchema: GetSuggestionsSchema,
|
|
3551
|
+
annotations: { readOnlyHint: true }
|
|
3552
|
+
},
|
|
3553
|
+
async (args) => {
|
|
3554
|
+
try {
|
|
3555
|
+
const result = await getSuggestions(args, cfg);
|
|
3556
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3557
|
+
} catch (err) {
|
|
3558
|
+
return {
|
|
3559
|
+
isError: true,
|
|
3560
|
+
content: [{ type: "text", text: `Get suggestions error: ${err.message}` }]
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
// src/tools/get_suggestion.ts
|
|
3568
|
+
async function getSuggestion(args, cfg) {
|
|
3569
|
+
return fetchSuggestion(cfg, args.id);
|
|
3570
|
+
}
|
|
3571
|
+
function registerToolGetSuggestion(server2, cfg) {
|
|
3572
|
+
server2.registerTool(
|
|
3573
|
+
"get_suggestion",
|
|
3574
|
+
{
|
|
3575
|
+
title: "Get Coach Suggestion",
|
|
3576
|
+
description: "Return one of the coach's own proposals in full, including `payload` and \u2014 for a modified accept \u2014 `appliedPayload`. Pass an id from `get_suggestions`. Returns an error if the proposal is not found (unknown id, or an id that is not one of yours).",
|
|
3577
|
+
inputSchema: GetSuggestionSchema,
|
|
3578
|
+
annotations: { readOnlyHint: true }
|
|
3579
|
+
},
|
|
3580
|
+
async (args) => {
|
|
3581
|
+
try {
|
|
3582
|
+
const typedArgs = args;
|
|
3583
|
+
const result = await getSuggestion(typedArgs, cfg);
|
|
3584
|
+
if (!result) {
|
|
3585
|
+
return {
|
|
3586
|
+
isError: true,
|
|
3587
|
+
content: [{ type: "text", text: `Suggestion not found: ${typedArgs.id}` }]
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3591
|
+
} catch (err) {
|
|
3592
|
+
return {
|
|
3593
|
+
isError: true,
|
|
3594
|
+
content: [{ type: "text", text: `Get suggestion error: ${err.message}` }]
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// src/tools/withdraw_suggestion.ts
|
|
3602
|
+
var SuggestionNotWithdrawableError = class extends Error {
|
|
3603
|
+
constructor() {
|
|
3604
|
+
super("Suggestion not withdrawable: unknown, not yours, or no longer pending.");
|
|
3605
|
+
this.name = "SuggestionNotWithdrawableError";
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
async function withdrawSuggestion(args, cfg) {
|
|
3609
|
+
const outcome = await postWithdraw(cfg, args.id);
|
|
3610
|
+
if (outcome === "not-withdrawable") {
|
|
3611
|
+
throw new SuggestionNotWithdrawableError();
|
|
3612
|
+
}
|
|
3613
|
+
return { id: args.id, status: "withdrawn" };
|
|
3614
|
+
}
|
|
3615
|
+
function registerToolWithdrawSuggestion(server2, cfg) {
|
|
3616
|
+
server2.registerTool(
|
|
3617
|
+
"withdraw_suggestion",
|
|
3618
|
+
{
|
|
3619
|
+
title: "Withdraw Coach Suggestion",
|
|
3620
|
+
description: "Retract one of your own proposals that is still pending \u2014 the single named exception to the propose-only boundary. Sets its status to withdrawn; the proposal is NOT deleted, its audit row survives and stays fully readable through `get_suggestion`. Withdrawing an already-withdrawn proposal is a harmless no-op. Fails with an error, worded identically regardless of cause, for an id that is unknown, not yours, or no longer pending (already accepted, rejected, or expired).",
|
|
3621
|
+
inputSchema: WithdrawSuggestionSchema,
|
|
3622
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
3623
|
+
},
|
|
3624
|
+
async (args) => {
|
|
3625
|
+
try {
|
|
3626
|
+
const typedArgs = args;
|
|
3627
|
+
const result = await withdrawSuggestion(typedArgs, cfg);
|
|
3628
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3629
|
+
} catch (err) {
|
|
3630
|
+
if (err instanceof SuggestionNotWithdrawableError) {
|
|
3631
|
+
return {
|
|
3632
|
+
isError: true,
|
|
3633
|
+
content: [{ type: "text", text: err.message }]
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
if (err instanceof HttpError) {
|
|
3637
|
+
console.error(`withdraw_suggestion: server ${err.status}`);
|
|
3638
|
+
return {
|
|
3639
|
+
isError: true,
|
|
3640
|
+
content: [{ type: "text", text: `Server rejected withdraw: HTTP ${err.status}` }]
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
return {
|
|
3644
|
+
isError: true,
|
|
3645
|
+
content: [{ type: "text", text: `Withdraw suggestion error: ${err.message}` }]
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
// src/tools/get_coach_parameters.ts
|
|
3653
|
+
async function getCoachParameters(cfg) {
|
|
3654
|
+
const { params, source } = await loadCoachParameters(cfg);
|
|
3655
|
+
return describeCoachParameters(params, source);
|
|
3656
|
+
}
|
|
3657
|
+
function registerToolGetCoachParameters(server2, cfg) {
|
|
3658
|
+
server2.registerTool(
|
|
3659
|
+
"get_coach_parameters",
|
|
3660
|
+
{
|
|
3661
|
+
title: "Get Coach Parameters",
|
|
3662
|
+
description: "Report the seven training-state tuning parameters: their default values, allowed ranges, and current values in one call. Call this BEFORE calling set_coach_parameters \u2014 it is the right first step to learn what can be set and what it currently is, rather than discovering the ranges by trial and error.",
|
|
3663
|
+
inputSchema: GetCoachParametersSchema,
|
|
3664
|
+
annotations: { readOnlyHint: true }
|
|
3665
|
+
},
|
|
3666
|
+
async () => {
|
|
3667
|
+
try {
|
|
3668
|
+
const result = await getCoachParameters(cfg);
|
|
3669
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3670
|
+
} catch (err) {
|
|
3671
|
+
return {
|
|
3672
|
+
isError: true,
|
|
3673
|
+
content: [{ type: "text", text: `Coach parameters error: ${err.message}` }]
|
|
3674
|
+
};
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
// src/tools/set_coach_parameters.ts
|
|
3681
|
+
var CoachParametersValidationError = class extends Error {
|
|
3682
|
+
constructor(message) {
|
|
3683
|
+
super(message);
|
|
3684
|
+
this.name = "CoachParametersValidationError";
|
|
3685
|
+
}
|
|
3686
|
+
};
|
|
3687
|
+
var CoachParametersRejectedError = class extends Error {
|
|
3688
|
+
constructor() {
|
|
3689
|
+
super("Server rejected the coach-parameter update.");
|
|
3690
|
+
this.name = "CoachParametersRejectedError";
|
|
3691
|
+
}
|
|
3692
|
+
};
|
|
3693
|
+
async function setCoachParameters(args, cfg) {
|
|
3694
|
+
const { params: current } = await loadCoachParameters(cfg);
|
|
3695
|
+
const validation = coachParametersSchemaFor(current).safeParse(args);
|
|
3696
|
+
if (!validation.success) {
|
|
3697
|
+
const message = validation.error.issues.map((issue) => issue.message).join("; ");
|
|
3698
|
+
throw new CoachParametersValidationError(message);
|
|
3699
|
+
}
|
|
3700
|
+
const outcome = await putCoachParameters(cfg, validation.data);
|
|
3701
|
+
if (outcome === "rejected") {
|
|
3702
|
+
throw new CoachParametersRejectedError();
|
|
3703
|
+
}
|
|
3704
|
+
return describeCoachParameters(mergeCoachParameters(outcome.params), "server");
|
|
3705
|
+
}
|
|
3706
|
+
function registerToolSetCoachParameters(server2, cfg) {
|
|
3707
|
+
server2.registerTool(
|
|
3708
|
+
"set_coach_parameters",
|
|
3709
|
+
{
|
|
3710
|
+
title: "Set Coach Parameters",
|
|
3711
|
+
description: "Set one or more of the seven training-state tuning parameters. Call get_coach_parameters first to learn the defaults, allowed ranges, and current values. Rejects an out-of-range value or an invalid uncertainThreshold/matchThreshold combination before any change reaches the server. Returns the resulting overall parameter state.",
|
|
3712
|
+
inputSchema: SetCoachParametersSchema,
|
|
3713
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
3714
|
+
},
|
|
3715
|
+
async (args) => {
|
|
3716
|
+
try {
|
|
3717
|
+
const result = await setCoachParameters(args, cfg);
|
|
3718
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
3719
|
+
} catch (err) {
|
|
3720
|
+
if (err instanceof CoachParametersValidationError) {
|
|
3721
|
+
return {
|
|
3722
|
+
isError: true,
|
|
3723
|
+
content: [{ type: "text", text: `Invalid coach parameters: ${err.message}` }]
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
if (err instanceof CoachParametersRejectedError) {
|
|
3727
|
+
return {
|
|
3728
|
+
isError: true,
|
|
3729
|
+
content: [{ type: "text", text: err.message }]
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
if (err instanceof HttpError) {
|
|
3733
|
+
console.error(`set_coach_parameters: server ${err.status}`);
|
|
3734
|
+
return {
|
|
3735
|
+
isError: true,
|
|
3736
|
+
content: [{ type: "text", text: `Server rejected coach parameters: HTTP ${err.status}` }]
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
return {
|
|
3740
|
+
isError: true,
|
|
3741
|
+
content: [{ type: "text", text: `Set coach parameters error: ${err.message}` }]
|
|
3742
|
+
};
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
);
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
// src/tools/get_adherence.ts
|
|
3749
|
+
function getAdherence(args, data, nowMs) {
|
|
3750
|
+
const timeZoneId = resolveTimeZoneId(data.snapshot);
|
|
3751
|
+
let from;
|
|
3752
|
+
let to;
|
|
3753
|
+
if (args.from !== void 0 && args.to !== void 0) {
|
|
3754
|
+
from = args.from;
|
|
3755
|
+
to = args.to;
|
|
3756
|
+
} else {
|
|
3757
|
+
to = toCalendarDay(nowMs, timeZoneId);
|
|
3758
|
+
const weeks = args.windowWeeks ?? data.coachParameters.adherenceWindowWeeks;
|
|
3759
|
+
from = formatIsoDate(plusDays(parseIsoDate(to), -weeks * 7));
|
|
3760
|
+
}
|
|
3761
|
+
const toleranceDays = args.toleranceDays ?? data.coachParameters.toleranceDays;
|
|
3762
|
+
return computeAdherence(
|
|
3763
|
+
{
|
|
3764
|
+
from,
|
|
3765
|
+
to,
|
|
3766
|
+
toleranceDays,
|
|
3767
|
+
matchThreshold: data.coachParameters.matchThreshold,
|
|
3768
|
+
uncertainThreshold: data.coachParameters.uncertainThreshold
|
|
3769
|
+
},
|
|
3770
|
+
{ snapshot: data.snapshot, catalog: data.catalog, timeZoneId }
|
|
3771
|
+
);
|
|
3772
|
+
}
|
|
3773
|
+
function registerToolGetAdherence(server2, cfg) {
|
|
3774
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
3775
|
+
server2.registerTool(
|
|
3776
|
+
"get_adherence",
|
|
3777
|
+
{
|
|
3778
|
+
title: "Get Plan Adherence",
|
|
3779
|
+
description: "Answer \"how well does the athlete keep to the plan\" over a window and tolerance you choose. Without `from`/`to`, the window ends today (the athlete's own calendar day) and spans back `windowWeeks` (default: the coach's own adherenceWindowWeeks parameter). `toleranceDays` and `windowWeeks` override the coach's persisted parameters for this one call only, without saving them. The result reports plannedCount, matchedCount, uncertainCount, missedCount, removedOccurrenceCount, adherenceRatio, the matched pairs (with their confidence and, for similarity-derived matches, the similarity value), the missed occurrences, and an explanation sentence describing what removedOccurrenceCount does and does NOT mean (verbatim: \"" + ADHERENCE_EXPLANATION + '"). Set `outputFile: true` to write the full pair/missed lists to a local file instead of returning them inline.',
|
|
3780
|
+
inputSchema: GetAdherenceSchema,
|
|
3781
|
+
annotations: { readOnlyHint: true }
|
|
3782
|
+
},
|
|
3783
|
+
async (args) => {
|
|
3784
|
+
try {
|
|
3785
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
3786
|
+
const { params, source } = await loadCoachParameters({ pat: pat2, serverUrl: serverUrl2 });
|
|
3787
|
+
const result = getAdherence(
|
|
3788
|
+
args,
|
|
3789
|
+
{ snapshot: data.snapshot, catalog: data.catalog, coachParameters: params },
|
|
3790
|
+
Date.now()
|
|
3791
|
+
);
|
|
3792
|
+
const full = { ...result, parametersSource: source };
|
|
3793
|
+
if (args.outputFile) {
|
|
3794
|
+
const itemCount = result.matches.length + result.missed.length;
|
|
3795
|
+
const handle = await writeExportFile(full, itemCount);
|
|
3796
|
+
const summary = {
|
|
3797
|
+
outputFile: { path: handle.path, bytes: handle.bytes, itemCount: handle.itemCount },
|
|
3798
|
+
window: result.window,
|
|
3799
|
+
toleranceDays: result.toleranceDays,
|
|
3800
|
+
plannedCount: result.plannedCount,
|
|
3801
|
+
matchedCount: result.matchedCount,
|
|
3802
|
+
uncertainCount: result.uncertainCount,
|
|
3803
|
+
missedCount: result.missedCount,
|
|
3804
|
+
removedOccurrenceCount: result.removedOccurrenceCount,
|
|
3805
|
+
adherenceRatio: result.adherenceRatio,
|
|
3806
|
+
explanation: result.explanation,
|
|
3807
|
+
parametersSource: source
|
|
3808
|
+
};
|
|
3809
|
+
return { content: [{ type: "text", text: JSON.stringify(summary) }] };
|
|
3810
|
+
}
|
|
3811
|
+
return { content: [{ type: "text", text: JSON.stringify(full) }] };
|
|
3812
|
+
} catch (err) {
|
|
3813
|
+
return {
|
|
3814
|
+
isError: true,
|
|
3815
|
+
content: [{ type: "text", text: `Adherence error: ${err.message}` }]
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
// src/training-state/format-progress.ts
|
|
3823
|
+
function amrapSummary(rows, exercisesPerRound) {
|
|
3824
|
+
const n = exercisesPerRound > 0 ? Math.floor(rows.length / exercisesPerRound) : 0;
|
|
3825
|
+
const partial = rows.slice(n * exercisesPerRound);
|
|
3826
|
+
const reps = partial.reduce((sum, r) => sum + (r.completedReps ?? 0), 0);
|
|
3827
|
+
return { rounds: n, reps };
|
|
3828
|
+
}
|
|
3829
|
+
function deathBySummary(rows, roundCap) {
|
|
3830
|
+
return { highestFullRound: rows.length, complete: rows.length >= roundCap };
|
|
3831
|
+
}
|
|
3832
|
+
function emomIntervals(rows) {
|
|
3833
|
+
return rows.length;
|
|
3834
|
+
}
|
|
3835
|
+
var FORMAT_SUMMARY_TYPES = ["AMRAP", "DEATH_BY", "EMOM"];
|
|
3836
|
+
function parseAmrapExercisesPerRound(formatParams) {
|
|
3837
|
+
if (formatParams == null) return 0;
|
|
3838
|
+
try {
|
|
3839
|
+
const parsed = JSON.parse(formatParams);
|
|
3840
|
+
return Array.isArray(parsed.exercises) ? parsed.exercises.length : 0;
|
|
3841
|
+
} catch {
|
|
3842
|
+
return 0;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
function parseDeathByRoundCap(formatParams) {
|
|
3846
|
+
if (formatParams == null) return 30;
|
|
3847
|
+
try {
|
|
3848
|
+
const parsed = JSON.parse(formatParams);
|
|
3849
|
+
return typeof parsed.roundCap === "number" ? parsed.roundCap : 30;
|
|
3850
|
+
} catch {
|
|
3851
|
+
return 30;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
function computeFormatProgress(args, data) {
|
|
3855
|
+
const { snapshot, timeZoneId } = data;
|
|
3856
|
+
const templatesById = new Map(snapshot.templates.map((t) => [t.id, t]));
|
|
3857
|
+
const setLogsBySessionId = /* @__PURE__ */ new Map();
|
|
3858
|
+
for (const log of snapshot.setLogs) {
|
|
3859
|
+
if (log.deletedAt !== void 0) continue;
|
|
3860
|
+
const bucket = setLogsBySessionId.get(log.sessionId);
|
|
3861
|
+
if (bucket) bucket.push(log);
|
|
3862
|
+
else setLogsBySessionId.set(log.sessionId, [log]);
|
|
3863
|
+
}
|
|
3864
|
+
for (const rows of setLogsBySessionId.values()) {
|
|
3865
|
+
rows.sort((a, b) => (a.startedAt ?? a.createdAt) - (b.startedAt ?? b.createdAt));
|
|
3866
|
+
}
|
|
3867
|
+
let skippedSessionCount = 0;
|
|
3868
|
+
const sessionsByTemplateId = /* @__PURE__ */ new Map();
|
|
3869
|
+
for (const session of snapshot.sessions) {
|
|
3870
|
+
if (session.deletedAt !== void 0) continue;
|
|
3871
|
+
if (session.endTime === void 0) continue;
|
|
3872
|
+
if (session.templateId === void 0) continue;
|
|
3873
|
+
const template = templatesById.get(session.templateId);
|
|
3874
|
+
const workoutType = template?.workoutType;
|
|
3875
|
+
const isKnownFormat = template !== void 0 && workoutType !== null && workoutType !== void 0 && FORMAT_SUMMARY_TYPES.includes(workoutType);
|
|
3876
|
+
if (!isKnownFormat) {
|
|
3877
|
+
skippedSessionCount += 1;
|
|
3878
|
+
continue;
|
|
3879
|
+
}
|
|
3880
|
+
const bucket = sessionsByTemplateId.get(template.id);
|
|
3881
|
+
if (bucket) bucket.push(session);
|
|
3882
|
+
else sessionsByTemplateId.set(template.id, [session]);
|
|
3883
|
+
}
|
|
3884
|
+
const series = [];
|
|
3885
|
+
for (const [templateId, sessions] of sessionsByTemplateId) {
|
|
3886
|
+
if (args.templateId !== void 0 && templateId !== args.templateId) continue;
|
|
3887
|
+
const template = templatesById.get(templateId);
|
|
3888
|
+
if (template === void 0) continue;
|
|
3889
|
+
const workoutType = template.workoutType;
|
|
3890
|
+
const sortedSessions = [...sessions].sort((a, b) => a.startTime - b.startTime);
|
|
3891
|
+
const points = sortedSessions.map((session) => {
|
|
3892
|
+
const rows = setLogsBySessionId.get(session.id) ?? [];
|
|
3893
|
+
const point = {
|
|
3894
|
+
sessionId: session.id,
|
|
3895
|
+
date: toCalendarDay(session.startTime, timeZoneId),
|
|
3896
|
+
workoutType,
|
|
3897
|
+
rounds: null,
|
|
3898
|
+
reps: null,
|
|
3899
|
+
intervals: null,
|
|
3900
|
+
complete: null
|
|
3901
|
+
};
|
|
3902
|
+
if (workoutType === "AMRAP") {
|
|
3903
|
+
const score = amrapSummary(rows, parseAmrapExercisesPerRound(template.formatParams));
|
|
3904
|
+
point.rounds = score.rounds;
|
|
3905
|
+
point.reps = score.reps;
|
|
3906
|
+
} else if (workoutType === "DEATH_BY") {
|
|
3907
|
+
const score = deathBySummary(rows, parseDeathByRoundCap(template.formatParams));
|
|
3908
|
+
point.rounds = score.highestFullRound;
|
|
3909
|
+
point.complete = score.complete;
|
|
3910
|
+
} else if (workoutType === "EMOM") {
|
|
3911
|
+
point.intervals = emomIntervals(rows);
|
|
3912
|
+
}
|
|
3913
|
+
return point;
|
|
3914
|
+
});
|
|
3915
|
+
const trimmedPoints = points.length > args.maxPoints ? points.slice(points.length - args.maxPoints) : points;
|
|
3916
|
+
series.push({
|
|
3917
|
+
templateId,
|
|
3918
|
+
templateName: template.name,
|
|
3919
|
+
workoutType,
|
|
3920
|
+
points: trimmedPoints,
|
|
3921
|
+
skippedSessionCount
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
return series;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// src/tools/get_progress.ts
|
|
3928
|
+
function getProgress(args, data) {
|
|
3929
|
+
const timeZoneId = resolveTimeZoneId(data.snapshot);
|
|
3930
|
+
const maxPoints = args.points ?? data.coachParameters.exerciseTrendPoints;
|
|
3931
|
+
if (args.kind === "exercise") {
|
|
3932
|
+
const series2 = computeExerciseProgress(
|
|
3933
|
+
{
|
|
3934
|
+
exerciseId: args.exerciseId,
|
|
3935
|
+
maxPoints,
|
|
3936
|
+
recentExerciseCount: data.coachParameters.recentExerciseCount
|
|
3937
|
+
},
|
|
3938
|
+
{ snapshot: data.snapshot, catalog: data.catalog, timeZoneId }
|
|
3939
|
+
);
|
|
3940
|
+
return { kind: "exercise", series: series2 };
|
|
3941
|
+
}
|
|
3942
|
+
const series = computeFormatProgress(
|
|
3943
|
+
{ templateId: args.templateId, maxPoints },
|
|
3944
|
+
{ snapshot: data.snapshot, timeZoneId }
|
|
3945
|
+
);
|
|
3946
|
+
return { kind: "format", series };
|
|
3947
|
+
}
|
|
3948
|
+
function registerToolGetProgress(server2, cfg) {
|
|
3949
|
+
const { pat: pat2, keyB64: keyB642, serverUrl: serverUrl2 } = cfg;
|
|
3950
|
+
server2.registerTool(
|
|
3951
|
+
"get_progress",
|
|
3952
|
+
{
|
|
3953
|
+
title: "Get Training Progress",
|
|
3954
|
+
description: 'Two progress views in one tool, selected by `kind`. `kind: "exercise"` returns the per-exercise progress series across ALL sessions the exercise appears in (even sessions without a template) \u2014 each point carries the best single set AND the session total, in the metric appropriate to that exercise (e1RM for weighted exercises, reps or hold time for bodyweight exercises), plus a direction label with its own rule and the raw values it was computed from. Omit `exerciseId` to get the most recently trained exercises instead of one specific exercise. `kind: "format"` returns the format-specific progression (AMRAP rounds+reps, Death-By highest round, EMOM intervals) for one template, or all format templates if `templateId` is omitted. `points` overrides how many recent points each series returns (3-30) for this one call. A call with no matching data returns an empty series, never an error. Set `outputFile: true` to write the full point series to a local file instead of returning it inline.',
|
|
3955
|
+
inputSchema: GetProgressSchema,
|
|
3956
|
+
annotations: { readOnlyHint: true }
|
|
3957
|
+
},
|
|
3958
|
+
async (args) => {
|
|
3959
|
+
try {
|
|
3960
|
+
const data = await getSnapshot(pat2, keyB642, serverUrl2);
|
|
3961
|
+
const { params, source } = await loadCoachParameters({ pat: pat2, serverUrl: serverUrl2 });
|
|
3962
|
+
const result = getProgress(args, {
|
|
3963
|
+
snapshot: data.snapshot,
|
|
3964
|
+
catalog: data.catalog,
|
|
3965
|
+
coachParameters: params
|
|
3966
|
+
});
|
|
3967
|
+
const full = { ...result, parametersSource: source };
|
|
3968
|
+
if (args.outputFile) {
|
|
3969
|
+
const itemCount = full.series.reduce((sum, s) => sum + s.points.length, 0);
|
|
3970
|
+
const handle = await writeExportFile(full, itemCount);
|
|
3971
|
+
const summary = full.kind === "exercise" ? {
|
|
3972
|
+
kind: full.kind,
|
|
3973
|
+
outputFile: { path: handle.path, bytes: handle.bytes, itemCount: handle.itemCount },
|
|
3974
|
+
series: full.series.map((s) => ({
|
|
3975
|
+
exerciseId: s.exerciseId,
|
|
3976
|
+
exerciseName: s.exerciseName,
|
|
3977
|
+
mode: s.mode,
|
|
3978
|
+
usesWeight: s.usesWeight,
|
|
3979
|
+
metric: s.metric,
|
|
3980
|
+
bestSetDirection: s.bestSetDirection,
|
|
3981
|
+
sessionTotalDirection: s.sessionTotalDirection,
|
|
3982
|
+
rule: s.rule
|
|
3983
|
+
})),
|
|
3984
|
+
parametersSource: source
|
|
3985
|
+
} : {
|
|
3986
|
+
kind: full.kind,
|
|
3987
|
+
outputFile: { path: handle.path, bytes: handle.bytes, itemCount: handle.itemCount },
|
|
3988
|
+
series: full.series.map((s) => ({
|
|
3989
|
+
templateId: s.templateId,
|
|
3990
|
+
templateName: s.templateName,
|
|
3991
|
+
workoutType: s.workoutType,
|
|
3992
|
+
skippedSessionCount: s.skippedSessionCount
|
|
3993
|
+
})),
|
|
3994
|
+
parametersSource: source
|
|
3995
|
+
};
|
|
3996
|
+
return { content: [{ type: "text", text: JSON.stringify(summary) }] };
|
|
3997
|
+
}
|
|
3998
|
+
return { content: [{ type: "text", text: JSON.stringify(full) }] };
|
|
3999
|
+
} catch (err) {
|
|
4000
|
+
return {
|
|
4001
|
+
isError: true,
|
|
4002
|
+
content: [{ type: "text", text: `Progress error: ${err.message}` }]
|
|
4003
|
+
};
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// src/index.ts
|
|
4010
|
+
var pat = process.env.CALICOMP_PAT;
|
|
4011
|
+
var keyB64 = process.env.CALICOMP_KEY;
|
|
4012
|
+
var serverUrl = process.env.CALICOMP_SERVER_URL ?? "https://api.calicompanion.de";
|
|
4013
|
+
if (!pat || !keyB64) {
|
|
4014
|
+
console.error("[calicomp-mcp] FATAL: CALICOMP_PAT and CALICOMP_KEY must be set.");
|
|
4015
|
+
process.exit(1);
|
|
4016
|
+
}
|
|
4017
|
+
var server = new McpServer({ name: "calicomp", version: "0.1.0" });
|
|
4018
|
+
var config = { pat, keyB64, serverUrl };
|
|
4019
|
+
registerToolGetHistory(server, config);
|
|
4020
|
+
registerToolGetStats(server, config);
|
|
4021
|
+
registerToolGetProfile(server, config);
|
|
4022
|
+
registerToolListTemplates(server, config);
|
|
4023
|
+
registerToolGetTemplate(server, config);
|
|
4024
|
+
registerToolGetExerciseCatalog(server, config);
|
|
4025
|
+
registerToolGetPlannedWorkouts(server, config);
|
|
4026
|
+
registerToolGetTrainingState(server, config);
|
|
4027
|
+
registerToolProposePlanUpdate(server, config);
|
|
4028
|
+
registerToolProposeNewPlan(server, config);
|
|
4029
|
+
registerToolProposeNewExercise(server, config);
|
|
4030
|
+
registerToolProposePlannedUpdate(server, config);
|
|
4031
|
+
registerToolGetSuggestions(server, config);
|
|
4032
|
+
registerToolGetSuggestion(server, config);
|
|
4033
|
+
registerToolWithdrawSuggestion(server, config);
|
|
4034
|
+
registerToolGetCoachParameters(server, config);
|
|
4035
|
+
registerToolSetCoachParameters(server, config);
|
|
4036
|
+
registerToolGetAdherence(server, config);
|
|
4037
|
+
registerToolGetProgress(server, config);
|
|
4038
|
+
await pruneExports();
|
|
4039
|
+
var transport = new StdioServerTransport();
|
|
4040
|
+
await server.connect(transport);
|