1/**
2 * Fitness Core
3 *
4 * Multi-modality workout tracking. Three languages:
5 * gym: weight x reps x sets (progressive overload = weight up)
6 * running: distance x time x pace (progressive overload = mileage up, pace down)
7 * home: reps x sets or duration (progressive overload = reps up, harder variation)
8 *
9 * One LLM call detects modality and parses. Routing sends to the right branch.
10 * The tree structure defines what exercises exist. The code is generic.
11 */
12
13import log from "../../seed/log.js";
14import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
15
16let _Node = null;
17let _Note = null;
18let _runChat = null;
19let _metadata = null;
20
21export function configure({ Node, Note, runChat, metadata }) {
22 _Node = Node;
23 _Note = Note;
24 _runChat = runChat;
25 _metadata = metadata;
26}
27
28// ── Constants ──
29
30const MAX_HISTORY = 50;
31const MAX_SESSION_HISTORY = 90; // days of session records on History node
32
33// ── Modalities ──
34
35const MODALITIES = {
36 GYM: "gym",
37 RUNNING: "running",
38 HOME: "home",
39};
40
41// ── Adopt an existing node as a tracked exercise ──
42
43export async function adoptExercise(nodeId, { exerciseType, unit, goals }) {
44 if (!_metadata || !_Node) throw new Error("Services not configured");
45 const node = await _Node.findById(nodeId);
46 if (!node) throw new Error("Node not found");
47 await _metadata.setExtMeta(node, "fitness", {
48 role: "exercise",
49 valueSchema: { type: exerciseType || "weight-reps", unit: unit || "lb" },
50 });
51 if (goals && Object.keys(goals).length > 0) {
52 await _metadata.setExtMeta(node, "goals", goals);
53 }
54 // Initialize empty values
55 await _metadata.batchSetExtMeta(nodeId, "values", { today: 0 });
56 log.info("Fitness", `Adopted "${node.name}" as exercise (${exerciseType || "weight-reps"})`);
57}
58
59// ── Initialization check ──
60
61export async function isInitialized(rootId) {
62 if (!_Node) return false;
63 const root = await _Node.findById(rootId).select("metadata").lean();
64 if (!root) return false;
65 const meta = root.metadata instanceof Map
66 ? root.metadata.get("fitness")
67 : root.metadata?.fitness;
68 return !!meta?.initialized;
69}
70
71export async function getSetupPhase(rootId) {
72 if (!_Node) return null;
73 const root = await _Node.findById(rootId).select("metadata").lean();
74 if (!root) return null;
75 const meta = root.metadata instanceof Map
76 ? root.metadata.get("fitness")
77 : root.metadata?.fitness;
78 return meta?.setupPhase || (meta?.initialized ? "complete" : null);
79}
80
81export async function getProfile(rootId) {
82 if (!_Node) return {};
83 const root = await _Node.findById(rootId).select("metadata").lean();
84 if (!root) return {};
85 const meta = root.metadata instanceof Map
86 ? root.metadata.get("fitness")
87 : root.metadata?.fitness;
88 return meta?.profile || {};
89}
90
91// ── Find fitness nodes by role ──
92
93export async function findFitnessNodes(rootId) {
94 if (!_Node) return null;
95 const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
96 const result = { groups: [], exercises: {}, modalities: [], _rootId: String(rootId) };
97
98 for (const child of children) {
99 const meta = child.metadata instanceof Map
100 ? child.metadata.get("fitness")
101 : child.metadata?.fitness;
102 if (!meta?.role) {
103 // Unadopted node: child of fitness root with no fitness role
104 if (!result._unadopted) result._unadopted = [];
105 result._unadopted.push({ id: String(child._id), name: child.name });
106 continue;
107 }
108
109 if (meta.role === "log") result.log = { id: String(child._id), name: child.name };
110 else if (meta.role === "program") result.program = { id: String(child._id), name: child.name };
111 else if (meta.role === "history") result.history = { id: String(child._id), name: child.name };
112 else if (meta.role === "modality") {
113 result.modalities.push({ id: String(child._id), name: child.name, modality: meta.modality });
114 // Load groups under modality (gym has muscle groups, running/home have categories)
115 const subChildren = await _Node.find({ parent: child._id }).select("_id name metadata").lean();
116 for (const sub of subChildren) {
117 const subMeta = sub.metadata instanceof Map ? sub.metadata.get("fitness") : sub.metadata?.fitness;
118 if (subMeta?.role === "group" || subMeta?.role === "muscle-group") {
119 result.groups.push({ id: String(sub._id), name: sub.name, modality: meta.modality, parentModality: child.name });
120 const exercises = await _Node.find({ parent: sub._id }).select("_id name metadata").lean();
121 result.exercises[sub.name] = exercises.map(e => ({
122 id: String(e._id),
123 name: e.name,
124 modality: meta.modality,
125 meta: e.metadata instanceof Map ? Object.fromEntries(e.metadata) : (e.metadata || {}),
126 }));
127 } else if (subMeta?.role === "exercise") {
128 // Direct exercises under modality (running/Runs, running/PRs)
129 if (!result.exercises[child.name]) result.exercises[child.name] = [];
130 result.exercises[child.name].push({
131 id: String(sub._id),
132 name: sub.name,
133 modality: meta.modality,
134 meta: sub.metadata instanceof Map ? Object.fromEntries(sub.metadata) : (sub.metadata || {}),
135 });
136 }
137 }
138 }
139 // Backward compat: old trees have muscle-group directly under root
140 else if (meta.role === "muscle-group" || meta.role === "group") {
141 result.groups.push({ id: String(child._id), name: child.name, modality: "gym" });
142 const exercises = await _Node.find({ parent: child._id }).select("_id name metadata").lean();
143 result.exercises[child.name] = exercises.map(e => ({
144 id: String(e._id),
145 name: e.name,
146 modality: "gym",
147 meta: e.metadata instanceof Map ? Object.fromEntries(e.metadata) : (e.metadata || {}),
148 }));
149 }
150 }
151
152 return result;
153}
154
155// ── Build exercise list for AI prompt ──
156
157export function buildExerciseListForPrompt(fitnessNodes) {
158 if (!fitnessNodes) return "";
159 const lines = [];
160
161 for (const [groupName, exercises] of Object.entries(fitnessNodes.exercises)) {
162 if (!exercises.length) continue;
163 const modality = exercises[0]?.modality || "gym";
164 const exList = exercises.map(e => {
165 const schema = e.meta?.fitness?.valueSchema;
166 const type = schema?.type || (modality === "running" ? "distance-time" : modality === "home" ? "reps" : "weight-reps");
167 const unit = schema?.unit || "lb";
168 return ` ${e.name} (${type}, ${unit})`;
169 }).join("\n");
170 lines.push(` ${groupName} [${modality}]:\n${exList}`);
171 }
172
173 if (fitnessNodes._unadopted?.length > 0) {
174 lines.push(`\n UNADOPTED (found but not yet configured):\n${fitnessNodes._unadopted.map(u => ` ${u.name} (id: ${u.id})`).join("\n")}`);
175 }
176
177 return lines.join("\n");
178}
179
180// ── Workout parsing (one LLM call, all modalities) ──
181
182export async function parseWorkout(message, userId, username, rootId) {
183 if (!_runChat) return null;
184
185 const { answer } = await _runChat({
186 userId,
187 username,
188 message,
189 mode: "tree:fitness-log",
190 rootId,
191 slot: "fitness",
192 });
193
194 if (!answer) return null;
195 const parsed = parseJsonSafe(answer);
196 if (!parsed) return null;
197
198 // Normalize: ensure we have either exercises array or running data
199 if (!parsed.exercises?.length && !parsed.distance && !parsed.modality) return null;
200
201 // Default date to today
202 if (!parsed.date) parsed.date = new Date().toISOString().slice(0, 10);
203
204 // If running data returned as top-level (not in exercises array), wrap it
205 if (parsed.modality === "running" && !parsed.exercises) {
206 parsed.exercises = [{
207 modality: "running",
208 name: "Run",
209 distance: parsed.distance,
210 distanceUnit: parsed.distanceUnit || "miles",
211 duration: parsed.duration,
212 pace: parsed.pace,
213 type: parsed.type || "easy",
214 }];
215 }
216
217 return parsed;
218}
219
220// ── Route parsed data to exercise nodes ──
221
222export async function deliverToExerciseNodes(fitnessNodes, parsed) {
223 if (!_Node || !fitnessNodes) return [];
224
225 const delivered = [];
226
227 for (const exercise of (parsed.exercises || [])) {
228 const modality = exercise.modality || detectModality(exercise);
229
230 if (modality === "running") {
231 // Find running exercise nodes by schema type, not hardcoded name
232 let runsNode = null;
233 let prsNode = null;
234 for (const [, exs] of Object.entries(fitnessNodes.exercises)) {
235 for (const e of exs) {
236 if (e.modality !== "running") continue;
237 const schema = e.meta?.fitness?.valueSchema;
238 if (schema?.type === "distance-time") {
239 if (e.name.toLowerCase().includes("pr") || e.name.toLowerCase().includes("record")) prsNode = e;
240 else if (!runsNode) runsNode = e;
241 }
242 }
243 }
244 if (runsNode) {
245 await deliverRunData(runsNode, exercise, parsed.date);
246 if (prsNode) await updateRunPRs(prsNode, exercise);
247 delivered.push({ exercise, nodeId: runsNode.id, modality });
248 }
249 continue;
250 }
251
252 // Gym and home: match exercise name to node
253 let match = null;
254 const groupName = exercise.group;
255 if (groupName && fitnessNodes.exercises[groupName]) {
256 match = fuzzyMatchExercise(fitnessNodes.exercises[groupName], exercise.name);
257 }
258 // Search all groups if no direct match
259 if (!match) {
260 for (const [gn, exs] of Object.entries(fitnessNodes.exercises)) {
261 match = fuzzyMatchExercise(exs, exercise.name);
262 if (match) break;
263 }
264 }
265
266 if (!match) {
267 // Auto-create the exercise node. Find or create the group, then create the exercise.
268 try {
269 const { addGroupNode, addExerciseNode } = await import("./setup.js");
270 const group = exercise.group || "General";
271
272 // Find the modality node (gym or home)
273 let modalityNodeId = null;
274 for (const [, info] of Object.entries(fitnessNodes.modalities || {})) {
275 if (info.modality === modality || info.name?.toLowerCase() === modality) {
276 modalityNodeId = info.id;
277 break;
278 }
279 }
280 if (!modalityNodeId) {
281 log.verbose("Fitness", `No ${modality} modality node for "${exercise.name}". Skipping.`);
282 continue;
283 }
284
285 // Find or create the group
286 let groupNode = null;
287 const groupChildren = await _Node.find({ parent: modalityNodeId }).select("_id name").lean();
288 groupNode = groupChildren.find(c => c.name.toLowerCase() === group.toLowerCase());
289 if (!groupNode) {
290 const created = await addGroupNode({ parentId: modalityNodeId, name: group, userId: parsed._userId || "SYSTEM" });
291 groupNode = { _id: created.id, name: created.name };
292 log.info("Fitness", `Auto-created group "${group}" under ${modality}`);
293 }
294
295 // Create the exercise
296 const schemaType = modality === "home" ? "reps" : "weight-reps";
297 const sets = exercise.sets?.length || 3;
298 const created = await addExerciseNode({
299 groupId: String(groupNode._id),
300 name: exercise.name,
301 exerciseType: schemaType,
302 unit: modality === "home" ? "bodyweight" : (parsed._weightUnit || "lb"),
303 sets,
304 rootId: fitnessNodes._rootId,
305 userId: parsed._userId || "SYSTEM",
306 });
307 log.info("Fitness", `Auto-created exercise "${exercise.name}" under ${group}`);
308
309 // Now deliver data to the new node
310 match = { id: created.id, name: created.name, meta: { fitness: { valueSchema: { type: schemaType, sets } } } };
311 } catch (err) {
312 log.warn("Fitness", `Auto-create failed for "${exercise.name}": ${err.message}`);
313 continue;
314 }
315 }
316
317 const schema = match.meta?.fitness?.valueSchema;
318 const fields = buildValueFields(exercise, schema);
319 fields.lastWorked = parsed.date;
320
321 await _metadata.batchSetExtMeta(match.id, "values", fields);
322
323 // Record to exercise history
324 const node = await _Node.findById(match.id);
325 if (node) {
326 const existing = _metadata.getExtMeta(node, "fitness") || {};
327 const history = Array.isArray(existing.history) ? existing.history : [];
328 history.push({ date: parsed.date, ...fields, sets: exercise.sets });
329 while (history.length > MAX_HISTORY) history.shift();
330 await _metadata.setExtMeta(node, "fitness", { ...existing, history });
331 }
332
333 delivered.push({ exercise, nodeId: match.id, modality });
334 log.verbose("Fitness", `Updated ${exercise.name} (${modality})`);
335 }
336
337 return delivered;
338}
339
340function fuzzyMatchExercise(exercises, name) {
341 if (!exercises || !name) return null;
342 const lower = name.toLowerCase();
343 return exercises.find(e =>
344 e.name.toLowerCase() === lower ||
345 e.name.toLowerCase().includes(lower) ||
346 lower.includes(e.name.toLowerCase())
347 ) || null;
348}
349
350function detectModality(exercise) {
351 if (exercise.distance || exercise.pace || exercise.distanceUnit) return "running";
352 if (exercise.sets?.[0]?.weight > 0) return "gym";
353 if (exercise.duration && !exercise.sets?.length) return "home";
354 return exercise.modality || "gym";
355}
356
357export function buildValueFields(exercise, schema) {
358 const fields = {};
359 const type = schema?.type || detectModality(exercise);
360
361 if (type === "weight-reps" || type === "gym") {
362 const weight = exercise.sets?.[0]?.weight || 0;
363 fields.weight = weight;
364 for (let i = 0; i < (exercise.sets?.length || 0); i++) {
365 fields[`set${i + 1}`] = exercise.sets[i].reps || 0;
366 }
367 fields.totalVolume = (exercise.sets || []).reduce((sum, s) => sum + (s.weight || 0) * (s.reps || 0), 0);
368 } else if (type === "duration" || type === "home") {
369 if (exercise.duration != null) {
370 fields.duration = exercise.duration;
371 }
372 if (exercise.sets?.length) {
373 for (let i = 0; i < exercise.sets.length; i++) {
374 fields[`set${i + 1}`] = exercise.sets[i].reps || exercise.sets[i].duration || 0;
375 }
376 fields.totalReps = exercise.sets.reduce((sum, s) => sum + (s.reps || 0), 0);
377 }
378 if (exercise.variation) fields.variation = exercise.variation;
379 } else if (type === "distance-time" || type === "running") {
380 if (exercise.distance) fields.distance = exercise.distance;
381 if (exercise.duration) fields.time = exercise.duration;
382 if (exercise.pace) fields.pace = exercise.pace;
383 }
384
385 return fields;
386}
387
388async function deliverRunData(runsNode, exercise, date) {
389 const fields = {
390 lastRun: date,
391 };
392 if (exercise.distance) fields.lastDistance = exercise.distance;
393 if (exercise.duration) fields.lastDuration = exercise.duration;
394 if (exercise.pace) fields.lastPace = exercise.pace;
395
396 // Increment weekly stats
397 const node = await _Node.findById(runsNode.id);
398 if (node) {
399 const vals = _metadata.getExtMeta(node, "values") || {};
400 fields.weeklyMiles = (vals.weeklyMiles || 0) + (exercise.distance || 0);
401 fields.runsThisWeek = (vals.runsThisWeek || 0) + 1;
402 await _metadata.batchSetExtMeta(runsNode.id, "values", fields);
403
404 // Record to history
405 const existing = _metadata.getExtMeta(node, "fitness") || {};
406 const history = Array.isArray(existing.history) ? existing.history : [];
407 history.push({
408 date,
409 distance: exercise.distance,
410 distanceUnit: exercise.distanceUnit || "miles",
411 duration: exercise.duration,
412 pace: exercise.pace,
413 type: exercise.type || "easy",
414 });
415 while (history.length > MAX_HISTORY) history.shift();
416 await _metadata.setExtMeta(node, "fitness", { ...existing, history });
417 }
418}
419
420async function updateRunPRs(prsNode, exercise) {
421 if (!exercise.distance || !exercise.duration) return;
422 const node = await _Node.findById(prsNode.id);
423 if (!node) return;
424 const prs = _metadata.getExtMeta(node, "values") || {};
425 const pace = exercise.duration / exercise.distance; // seconds per unit
426
427 // Check common race distances
428 const dist = exercise.distance;
429 const dur = exercise.duration;
430 if (dist >= 1 && (!prs.mile || dur / dist < prs.mile)) prs.mile = Math.round(dur / dist);
431 if (dist >= 3.1 && (!prs.fiveK || dur < prs.fiveK)) prs.fiveK = Math.round(dur);
432 if (dist >= 6.2 && (!prs.tenK || dur < prs.tenK)) prs.tenK = Math.round(dur);
433 if (dist >= 13.1 && (!prs.half || dur < prs.half)) prs.half = Math.round(dur);
434 if (dist >= 26.2 && (!prs.marathon || dur < prs.marathon)) prs.marathon = Math.round(dur);
435
436 await _metadata.setExtMeta(node, "values", prs);
437}
438
439// ── Write session to History node ──
440
441export async function recordSessionHistory(historyNodeId, parsed, delivered, userId, ctx = {}) {
442 if (!historyNodeId) return null;
443
444 const modalities = [...new Set(delivered.map(d => d.modality))];
445
446 const record = {
447 date: parsed.date,
448 modalities,
449 };
450
451 // Gym data
452 const gymExercises = delivered.filter(d => d.modality === "gym");
453 if (gymExercises.length > 0) {
454 record.gym = {
455 muscleGroups: [...new Set(gymExercises.map(d => d.exercise.group))],
456 exercises: gymExercises.map(d => ({
457 name: d.exercise.name,
458 group: d.exercise.group,
459 sets: d.exercise.sets,
460 totalVolume: (d.exercise.sets || []).reduce((s, set) => s + (set.weight || 0) * (set.reps || 0), 0),
461 })),
462 totalVolume: gymExercises.reduce((sum, d) =>
463 sum + (d.exercise.sets || []).reduce((s, set) => s + (set.weight || 0) * (set.reps || 0), 0), 0),
464 };
465 }
466
467 // Running data
468 const runs = delivered.filter(d => d.modality === "running");
469 if (runs.length > 0) {
470 const run = runs[0].exercise;
471 record.running = {
472 distance: run.distance,
473 distanceUnit: run.distanceUnit || "miles",
474 duration: run.duration,
475 pace: run.pace,
476 type: run.type || "easy",
477 };
478 }
479
480 // Home/bodyweight data
481 const homeExercises = delivered.filter(d => d.modality === "home");
482 if (homeExercises.length > 0) {
483 record.home = {
484 exercises: homeExercises.map(d => ({
485 name: d.exercise.name,
486 sets: d.exercise.sets,
487 totalReps: (d.exercise.sets || []).reduce((s, set) => s + (set.reps || 0), 0),
488 })),
489 };
490 }
491
492 try {
493 const { createNote } = await import("../../seed/tree/notes.js");
494 await createNote({
495 nodeId: historyNodeId,
496 content: JSON.stringify(record),
497 contentType: "text",
498 userId,
499 wasAi: ctx.chatId != null || ctx.wasAi === true,
500 chatId: ctx.chatId ?? null,
501 sessionId: ctx.sessionId ?? null,
502 });
503 } catch (err) {
504 log.warn("Fitness", `History note failed: ${err.message}`);
505 }
506
507 return record;
508}
509
510// ── Progressive overload check (generic, all modalities) ──
511
512export function checkProgression(exerciseNode) {
513 const values = exerciseNode.metadata instanceof Map
514 ? exerciseNode.metadata.get("values")
515 : exerciseNode.metadata?.values;
516 const goals = exerciseNode.metadata instanceof Map
517 ? exerciseNode.metadata.get("goals")
518 : exerciseNode.metadata?.goals;
519 const fitMeta = exerciseNode.metadata instanceof Map
520 ? exerciseNode.metadata.get("fitness")
521 : exerciseNode.metadata?.fitness;
522
523 if (!values || !goals) return null;
524
525 const schema = fitMeta?.valueSchema;
526 const increment = fitMeta?.progressionIncrement;
527
528 // Check if all goal keys are met
529 let allMet = true;
530 let goalCount = 0;
531 for (const [key, goalVal] of Object.entries(goals)) {
532 if (goalVal == null) continue;
533 goalCount++;
534 const currentVal = values[key];
535 if (currentVal == null || currentVal < goalVal) {
536 allMet = false;
537 break;
538 }
539 }
540
541 if (goalCount === 0) return null;
542
543 const result = { allGoalsMet: allMet, modality: schema?.type || "gym" };
544
545 if (allMet && increment) {
546 result.suggestedIncrements = increment;
547 // Build human-readable suggestion
548 const parts = [];
549 for (const [key, val] of Object.entries(increment)) {
550 parts.push(`${key}: +${val}`);
551 }
552 result.suggestion = parts.join(", ");
553 }
554
555 // Bodyweight variation progression
556 if (allMet && fitMeta?.progressionPath && values.variation) {
557 const path = fitMeta.progressionPath;
558 const idx = path.indexOf(values.variation);
559 if (idx >= 0 && idx < path.length - 1) {
560 result.nextVariation = path[idx + 1];
561 result.suggestion = (result.suggestion ? result.suggestion + ". " : "") +
562 `Ready for ${path[idx + 1]} variation`;
563 }
564 }
565
566 return result;
567}
568
569// ── Build workout summary for response ──
570
571export function buildWorkoutSummary(parsed, delivered) {
572 const lines = [];
573
574 for (const d of delivered) {
575 const ex = d.exercise;
576 if (d.modality === "running") {
577 const pace = ex.pace ? formatPace(ex.pace) : null;
578 lines.push(`Run: ${ex.distance}${ex.distanceUnit || "mi"} in ${formatDuration(ex.duration)}${pace ? ` (${pace}/mi)` : ""}`);
579 } else if (d.modality === "home" && ex.duration && !ex.sets?.length) {
580 lines.push(`${ex.name}: ${ex.duration}s`);
581 } else {
582 const setsStr = (ex.sets || []).map(s =>
583 s.weight > 0 ? `${s.weight}x${s.reps}` : `${s.reps}`
584 ).join("/");
585 const volume = (ex.sets || []).reduce((sum, s) => sum + (s.weight || 0) * (s.reps || 0), 0);
586 lines.push(`${ex.name}: ${setsStr}${volume > 0 ? ` (vol: ${volume.toLocaleString()})` : ""}`);
587 }
588 }
589
590 const modalities = [...new Set(delivered.map(d => d.modality))];
591 return {
592 lines,
593 modalities,
594 summary: lines.join("\n"),
595 };
596}
597
598function formatPace(secondsPerUnit) {
599 const min = Math.floor(secondsPerUnit / 60);
600 const sec = Math.round(secondsPerUnit % 60);
601 return `${min}:${String(sec).padStart(2, "0")}`;
602}
603
604function formatDuration(totalSeconds) {
605 if (!totalSeconds) return "?";
606 const min = Math.floor(totalSeconds / 60);
607 const sec = Math.round(totalSeconds % 60);
608 return sec > 0 ? `${min}:${String(sec).padStart(2, "0")}` : `${min}min`;
609}
610
611// ── Read exercise state for enrichContext / AI prompt ──
612
613export async function getExerciseState(rootId) {
614 if (!_Node) return null;
615
616 const nodes = await findFitnessNodes(rootId);
617 if (!nodes) return null;
618
619 const state = { modalities: [], groups: {} };
620
621 for (const mod of nodes.modalities) {
622 state.modalities.push(mod.modality);
623 }
624 // Backward compat: if no modalities but has groups, it's gym-only
625 if (state.modalities.length === 0 && nodes.groups.length > 0) {
626 state.modalities.push("gym");
627 }
628
629 for (const group of nodes.groups) {
630 const exercises = nodes.exercises[group.name] || [];
631 state.groups[group.name] = {
632 modality: group.modality || "gym",
633 exercises: exercises.map(ex => {
634 const values = ex.meta.values || {};
635 const goals = ex.meta.goals || {};
636 const fitMeta = ex.meta.fitness || {};
637 return {
638 name: ex.name,
639 id: ex.id,
640 modality: ex.modality || group.modality || "gym",
641 schema: fitMeta.valueSchema || null,
642 values,
643 goals,
644 lastWorked: values.lastWorked || null,
645 historyCount: fitMeta.history?.length || 0,
646 recentHistory: (fitMeta.history || []).slice(-5),
647 };
648 }),
649 };
650 }
651
652 // Include direct modality exercises (Running/Runs, Running/PRs)
653 for (const mod of nodes.modalities) {
654 const directExercises = nodes.exercises[mod.name] || [];
655 if (directExercises.length > 0 && !state.groups[mod.name]) {
656 state.groups[mod.name] = {
657 modality: mod.modality,
658 exercises: directExercises.map(ex => ({
659 name: ex.name,
660 id: ex.id,
661 modality: mod.modality,
662 schema: ex.meta?.fitness?.valueSchema || null,
663 values: ex.meta.values || {},
664 goals: ex.meta.goals || {},
665 lastWorked: (ex.meta.values || {}).lastWorked || null,
666 historyCount: (ex.meta.fitness || {}).history?.length || 0,
667 })),
668 };
669 }
670 }
671
672 if (nodes._unadopted?.length > 0) {
673 state._unadopted = nodes._unadopted;
674 }
675
676 return state;
677}
678
679// ── Weekly stats ──
680
681export async function getWeeklyStats(rootId) {
682 if (!_Node) return null;
683 const nodes = await findFitnessNodes(rootId);
684 if (!nodes?.history) return null;
685
686 const now = new Date();
687 const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
688
689 try {
690 const { getNotes } = await import("../../seed/tree/notes.js");
691 const notes = await _Note.find({
692 nodeId: nodes.history.id,
693 createdAt: { $gte: weekAgo },
694 }).select("content").lean();
695
696 const stats = { sessions: 0, gymSessions: 0, runs: 0, runMiles: 0, homeSessions: 0, totalVolume: 0 };
697 for (const note of notes) {
698 try {
699 const record = JSON.parse(note.content);
700 stats.sessions++;
701 if (record.gym) { stats.gymSessions++; stats.totalVolume += record.gym.totalVolume || 0; }
702 if (record.running) { stats.runs++; stats.runMiles += record.running.distance || 0; }
703 if (record.home) stats.homeSessions++;
704 } catch {}
705 }
706 return stats;
707 } catch {
708 return null;
709 }
710}
711
1/**
2 * Fitness handleMessage: lock to plan mode during setup.
3 *
4 * The orchestrator calls this BEFORE suffix routing. Returning { mode } forces
5 * the orchestrator to use that mode and skip suffix routing for this message.
6 *
7 * Why: setup is a multi-step conversation. The user might say "i want to track
8 * push-ups" which would normally route to fitness-log via suffix routing — but
9 * if setup isn't complete, log mode has nothing useful to do. fitness-plan
10 * owns the entire setup conversation. We force the user into plan mode for
11 * every message until plan mode itself sets setupPhase = "complete" via
12 * fitness-complete-setup.
13 *
14 * Once setup is complete, return null and let the orchestrator's suffix
15 * routing pick the right mode based on what the user said.
16 */
17import Node from "../../seed/models/node.js";
18
19export async function handleMessage(message, { rootId, targetNodeId }) {
20 // Find the fitness root for the current position. The orchestrator gives us
21 // either a targetNodeId (the position-hold node) or rootId. The fitness root
22 // is whichever ancestor has metadata.fitness.initialized = true.
23 const startId = targetNodeId || rootId;
24 if (!startId) return null;
25
26 try {
27 // Walk up from the current position to find the fitness root.
28 let current = await Node.findById(startId).select("_id parent metadata").lean();
29 let depth = 0;
30 let fitnessRoot = null;
31 while (current && depth < 20) {
32 const meta = current.metadata instanceof Map
33 ? current.metadata.get("fitness")
34 : current.metadata?.fitness;
35 if (meta?.initialized) {
36 fitnessRoot = { node: current, meta };
37 break;
38 }
39 if (!current.parent) break;
40 current = await Node.findById(current.parent).select("_id parent metadata").lean();
41 depth++;
42 }
43
44 if (!fitnessRoot) return null;
45
46 // Setup complete = let the orchestrator do its normal suffix routing
47 if (fitnessRoot.meta.setupPhase === "complete") return null;
48
49 // Setup in progress = force plan mode regardless of what the user said
50 return { mode: "tree:fitness-plan" };
51 } catch {
52 return null;
53 }
54}
55
1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { getExerciseState, getWeeklyStats, getProfile } from "./core.js";
7import { renderFitnessDashboard } from "./pages/dashboard.js";
8
9const router = express.Router();
10
11router.get("/root/:rootId/fitness", urlAuth, htmlOnly, async (req, res) => {
12 try {
13 const { rootId } = req.params;
14 const root = await Node.findById(rootId).select("name metadata").lean();
15 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Fitness tree not found");
16
17 const meta = root.metadata instanceof Map ? root.metadata.get("fitness") : root.metadata?.fitness;
18 if (!meta?.initialized) {
19 return sendError(res, 404, ERR.TREE_NOT_FOUND, "Fitness tree not initialized");
20 }
21
22 const [state, weekly, profile] = await Promise.all([
23 getExerciseState(rootId),
24 getWeeklyStats(rootId),
25 getProfile(rootId),
26 ]);
27
28 res.send(renderFitnessDashboard({
29 rootId,
30 rootName: root.name,
31 state,
32 weekly,
33 profile,
34 token: req.query.token || null,
35 }));
36 } catch (err) {
37 sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
38 }
39});
40
41export default router;
42
1/**
2 * Fitness
3 *
4 * Multi-modality workout tracking. Gym, running, bodyweight.
5 * The tree is the workout. Modalities are branches. Groups are children.
6 * Exercises are leaves. Values track sets/reps/weight/distance/time.
7 * Channels route logged data. Progressive overload tracked through goals.
8 */
9
10import log from "../../seed/log.js";
11import logMode from "./modes/log.js";
12import coachMode from "./modes/coach.js";
13import reviewMode from "./modes/review.js";
14import planMode from "./modes/plan.js";
15import getTools from "./tools.js";
16import {
17 configure,
18 isInitialized,
19 getSetupPhase,
20 findFitnessNodes,
21 getExerciseState,
22 getWeeklyStats,
23 checkProgression,
24 buildValueFields,
25} from "./core.js";
26import { setDeps as setSetupDeps } from "./setup.js";
27import { handleMessage } from "./handler.js";
28
29export async function init(core) {
30 core.llm.registerRootLlmSlot?.("fitness");
31
32 const runChat = core.llm?.runChat || null;
33 configure({
34 Node: core.models.Node,
35 Note: core.models.Note,
36 runChat: runChat
37 ? async (opts) => {
38 if (opts.userId && opts.userId !== "SYSTEM") {
39 const hasLlm = await core.llm.userHasLlm(opts.userId);
40 if (!hasLlm) return { answer: null };
41 }
42 return core.llm.runChat({
43 ...opts,
44 llmPriority: core.llm.LLM_PRIORITY.INTERACTIVE,
45 });
46 }
47 : null,
48 metadata: core.metadata,
49 });
50 setSetupDeps({ metadata: core.metadata, Node: core.models.Node });
51
52 // ── Register modes ──
53 core.modes.registerMode("tree:fitness-log", logMode, "fitness");
54 core.modes.registerMode("tree:fitness-coach", coachMode, "fitness");
55 core.modes.registerMode("tree:fitness-review", reviewMode, "fitness");
56 core.modes.registerMode("tree:fitness-plan", planMode, "fitness");
57
58 if (core.llm?.registerModeAssignment) {
59 core.llm.registerModeAssignment("tree:fitness-log", "fitnessLog");
60 core.llm.registerModeAssignment("tree:fitness-coach", "fitnessCoach");
61 core.llm.registerModeAssignment("tree:fitness-review", "fitnessReview");
62 core.llm.registerModeAssignment("tree:fitness-plan", "fitnessPlan");
63 }
64
65 // ── Boot self-heal: ensure fitness roots have mode override ──
66 core.hooks.register("afterBoot", async () => {
67 try {
68 const fitnessRoots = await core.models.Node.find({
69 "metadata.fitness.initialized": true,
70 }).select("_id metadata").lean();
71 for (const root of fitnessRoots) {
72 const modes = root.metadata instanceof Map
73 ? root.metadata.get("modes")
74 : root.metadata?.modes;
75 if (!modes?.respond) {
76 const fitMeta = root.metadata instanceof Map
77 ? root.metadata.get("fitness")
78 : root.metadata?.fitness;
79 const mode = fitMeta?.setupPhase === "complete" ? "tree:fitness-coach" : "tree:fitness-plan";
80 await core.modes.setNodeMode(root._id, "respond", mode);
81 log.verbose("Fitness", `Self-healed mode override on ${String(root._id).slice(0, 8)} -> ${mode}`);
82 }
83 }
84 } catch {}
85 }, "fitness");
86
87 // ── Data integrity: cap History notes, validate exercise history arrays ──
88 const _lastIntegrity = new Map();
89 core.hooks.register("breath:exhale", async ({ rootId }) => {
90 if (!rootId) return;
91 try {
92 // Find fitness root in this tree (may be under Life)
93 const fitnessRoots = await core.models.Node.find({
94 $or: [{ _id: rootId }, { rootOwner: { $exists: true } }],
95 "metadata.fitness.initialized": true,
96 }).select("_id").lean();
97 // Also search children up to depth 3
98 let candidates = [rootId];
99 for (let d = 0; d < 3; d++) {
100 const children = await core.models.Node.find({ parent: { $in: candidates } })
101 .select("_id metadata").lean();
102 for (const c of children) {
103 const fm = c.metadata instanceof Map ? c.metadata.get("fitness") : c.metadata?.fitness;
104 if (fm?.initialized) fitnessRoots.push(c);
105 }
106 candidates = children.map(c => String(c._id));
107 if (candidates.length === 0) break;
108 }
109
110 for (const fr of fitnessRoots) {
111 const fid = String(fr._id);
112 const last = _lastIntegrity.get(fid) || 0;
113 if (Date.now() - last < 600000) continue; // 10 min cooldown
114 _lastIntegrity.set(fid, Date.now());
115
116 const nodes = await findFitnessNodes(fid);
117 if (!nodes?.history?.id) continue;
118
119 // Cap History notes at 365
120 const Note = core.models.Node.db.model("Note");
121 const count = await Note.countDocuments({ nodeId: nodes.history.id });
122 if (count > 365) {
123 const old = await Note.find({ nodeId: nodes.history.id })
124 .sort({ createdAt: 1 }).limit(count - 365).select("_id").lean();
125 if (old.length > 0) {
126 await Note.deleteMany({ _id: { $in: old.map(n => n._id) } });
127 log.verbose("Fitness", `Capped history: deleted ${old.length} old entries`);
128 }
129 }
130 }
131 } catch {}
132 }, "fitness-integrity");
133
134 // ── onCascade: exercise data accumulation ──
135 core.hooks.register("onCascade", async (hookData) => {
136 const { node } = hookData;
137 if (!node) return;
138
139 const meta = node.metadata instanceof Map
140 ? node.metadata.get("fitness")
141 : node.metadata?.fitness;
142 if (meta?.role !== "exercise") return;
143
144 const payload = hookData.writeContext || hookData.payload || {};
145 if (!payload.sets?.length && !payload.distance && !payload.duration) return;
146
147 // Generic: build value fields from whatever the payload contains
148 const schema = meta.valueSchema;
149 const fields = buildValueFields(payload, schema);
150 fields.lastWorked = payload.date || new Date().toISOString().slice(0, 10);
151
152 await core.metadata.batchSetExtMeta(node._id, "values", fields);
153
154 hookData._resultStatus = "SUCCEEDED";
155 hookData._resultExtName = "fitness";
156 }, "fitness");
157
158 // ── enrichContext: fitness state for the AI ──
159 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
160 if (!node?._id) return;
161
162 const fitMeta = meta?.fitness;
163 if (!fitMeta?.role) return;
164
165 const role = fitMeta.role;
166 const values = meta?.values || {};
167 const goals = meta?.goals || {};
168
169 if (role === "exercise") {
170 // Build dynamic values/goals (not hardcoded to set1/set2/set3)
171 context.fitnessExercise = {
172 modality: fitMeta.valueSchema?.type || "gym",
173 values,
174 goals,
175 lastWorked: values.lastWorked || null,
176 recentHistory: (fitMeta.history || []).slice(-5),
177 };
178
179 const prog = checkProgression(node);
180 if (prog?.allGoalsMet) {
181 context.fitnessProgression = prog.suggestion || "All goals met. Ready for progression.";
182 if (prog.nextVariation) context.fitnessNextVariation = prog.nextVariation;
183 }
184
185 } else if (role === "log" || role === "program" || role === "modality") {
186 // Show full exercise state across all groups
187 const parentId = role === "modality" ? String(node._id) : (node.parent ? String(node.parent) : null);
188 if (parentId) {
189 const rootId = role === "log" || role === "program" ? parentId : String(node.parent);
190 const state = await getExerciseState(rootId);
191 if (state) context.fitnessState = state;
192 }
193
194 } else if (role === "group" || role === "muscle-group") {
195 // Show exercises in this group
196 const exercises = await core.models.Node.find({ parent: node._id })
197 .select("name metadata").lean();
198 context.fitnessExercises = exercises.map(e => {
199 const v = e.metadata instanceof Map ? e.metadata.get("values") : e.metadata?.values;
200 const g = e.metadata instanceof Map ? e.metadata.get("goals") : e.metadata?.goals;
201 const fm = e.metadata instanceof Map ? e.metadata.get("fitness") : e.metadata?.fitness;
202 return {
203 name: e.name,
204 modality: fm?.valueSchema?.type || "gym",
205 values: v || {},
206 goals: g || {},
207 lastWorked: v?.lastWorked || null,
208 };
209 });
210 }
211
212 // Cross-domain: food and recovery state (coach-level nodes only)
213 if (role === "log" || role === "program") {
214 try {
215 const { getExtension } = await import("../loader.js");
216 const life = getExtension("life");
217 if (life?.exports?.getDomainNodes) {
218 const treeRoot = node.rootOwner || String(node._id);
219 const domains = await life.exports.getDomainNodes(treeRoot);
220
221 if (domains.food?.id) {
222 const food = getExtension("food");
223 if (food?.exports?.getDailyPicture) {
224 const picture = await food.exports.getDailyPicture(domains.food.id);
225 if (picture?.calories) {
226 context.foodToday = { calories: picture.calories.today, goal: picture.calories.goal };
227 }
228 }
229 }
230
231 if (domains.recovery?.id) {
232 const recovery = getExtension("recovery");
233 if (recovery?.exports?.getStatus) {
234 const status = await recovery.exports.getStatus(domains.recovery.id);
235 if (status) {
236 context.recoveryToday = {
237 substances: status.substances,
238 mood: status.feelings?.mood,
239 energy: status.feelings?.energy,
240 };
241 }
242 }
243 }
244 }
245 } catch {}
246 }
247 }, "fitness");
248
249 // ── Live dashboard updates: push to client when data changes ──
250 // Walk up to find the fitness root (node with metadata.fitness.initialized),
251 // not just rootOwner (which is the Life root in organized trees).
252 async function findFitnessRootFromNode(nodeId) {
253 let current = await core.models.Node.findById(nodeId).select("metadata parent rootOwner").lean();
254 let depth = 0;
255 while (current && depth < 10) {
256 const fm = current.metadata instanceof Map ? current.metadata.get("fitness") : current.metadata?.fitness;
257 if (fm?.initialized) return { fitnessRootId: String(current._id), ownerId: current.rootOwner ? String(current.rootOwner) : null };
258 if (!current.parent || current.rootOwner) break;
259 current = await core.models.Node.findById(current.parent).select("metadata parent rootOwner").lean();
260 depth++;
261 }
262 return current?.rootOwner ? { fitnessRootId: null, ownerId: String(current.rootOwner) } : null;
263 }
264
265 core.hooks.register("afterNote", async ({ node }) => {
266 if (!node) return;
267 const fm = node.metadata instanceof Map ? node.metadata.get("fitness") : node.metadata?.fitness;
268 if (!fm?.role) return;
269 const info = await findFitnessRootFromNode(node._id);
270 if (!info?.ownerId) return;
271 // Emit with both the fitness root ID and the tree root ID so both dashboard URLs match
272 if (info.fitnessRootId) core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.fitnessRootId });
273 core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.ownerId });
274 }, "fitness");
275
276 core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
277 if (extName !== "values" && extName !== "fitness" && extName !== "goals") return;
278 const info = await findFitnessRootFromNode(nodeId);
279 if (!info?.ownerId) return;
280 if (info.fitnessRootId) core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.fitnessRootId });
281 core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.ownerId });
282 }, "fitness");
283
284 // HTML dashboard is now inline in routes.js (GET with ?html check)
285
286 // ── Register tool navigation (if treeos-base installed) ──
287 try {
288 const { getExtension } = await import("../loader.js");
289 const base = getExtension("treeos-base");
290 if (base?.exports?.registerToolNavigations) {
291 const nodeNav = ({ args, withToken: t }) => t(`/api/v1/node/${args.nodeId || args.rootId}?html`);
292 base.exports.registerToolNavigations({
293 "fitness-add-modality": nodeNav,
294 "fitness-add-group": nodeNav,
295 "fitness-add-exercise": nodeNav,
296 "fitness-remove-exercise": nodeNav,
297 "fitness-complete-setup": nodeNav,
298 "fitness-save-profile": nodeNav,
299 });
300 }
301 } catch {}
302
303 // ── Register apps-grid slot ──
304 try {
305 const { getExtension } = await import("../loader.js");
306 const base = getExtension("treeos-base");
307 base?.exports?.registerSlot?.("apps-grid", "fitness", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
308 const entries = rootMap.get("Fitness") || [];
309 const existing = entries.map(entry =>
310 entry.ready
311 ? `<a class="app-active" href="/api/v1/root/${entry.id}/fitness?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
312 : `<a class="app-active" style="background:rgba(236,201,75,0.12);border-color:rgba(236,201,75,0.3);color:#ecc94b;margin-right:8px;margin-bottom:6px;" href="/api/v1/root/${entry.id}/fitness?html${tokenParam}">${e(entry.name)} (setup)</a>`
313 ).join("");
314 return `<div class="app-card">
315 <div class="app-header"><span class="app-emoji">💪</span><span class="app-name">Fitness</span></div>
316 <div class="app-desc">Three languages: gym (weight x reps x sets), running (distance x time x pace), bodyweight (reps x sets or duration). Progressive overload tracked per modality.</div>
317 ${entries.length > 0
318 ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
319 : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
320 ${tokenField}<input type="hidden" name="app" value="fitness" />
321 <input class="app-input" name="message" placeholder="What do you train? (e.g. hypertrophy 4 days, running, bodyweight)" required />
322 <button class="app-start" type="submit">Start Fitness</button>
323 </form>`}
324 </div>`;
325 }, { priority: 10 });
326 } catch {}
327
328 // ── Import router ──
329 const { default: router, setServices } = await import("./routes.js");
330 setServices({ Node: core.models.Node });
331
332 const tools = getTools();
333
334 log.info("Fitness", "Loaded. Gym, running, bodyweight. The tree is the workout.");
335
336 return {
337 router,
338 tools,
339 modeTools: [
340 { modeKey: "tree:fitness-log", toolNames: ["fitness-log-workout"] },
341 { modeKey: "tree:fitness-plan", toolNames: [
342 "fitness-add-modality", "fitness-add-group", "fitness-add-exercise",
343 "fitness-remove-exercise", "fitness-adopt-exercise", "fitness-complete-setup", "fitness-save-profile",
344 ]},
345 { modeKey: "tree:fitness-coach", toolNames: [
346 "fitness-log-workout", "fitness-add-exercise", "fitness-add-group", "fitness-adopt-exercise",
347 ]},
348 ],
349 exports: {
350 isInitialized,
351 getSetupPhase,
352 findFitnessNodes,
353 getExerciseState,
354 getWeeklyStats,
355 handleMessage,
356 scaffold: (await import("./setup.js")).scaffoldFitnessBase,
357 },
358 };
359}
360
1export default {
2 name: "fitness",
3 version: "3.0.3",
4 builtFor: "TreeOS",
5 description:
6 "Multi-modality workout tracking. Three languages: gym (weight x reps x sets), " +
7 "running (distance x time x pace), bodyweight (reps x sets or duration). One extension, " +
8 "one LLM call detects modality and parses. The tree structure defines what exercises " +
9 "exist. Gym bro, marathon runner, and someone doing pushups in their apartment all use " +
10 "the same command. Progressive overload tracked per modality: weight goes up for gym, " +
11 "mileage increases for running, harder variations for bodyweight. Four modes: log " +
12 "(universal parser), coach (guided sessions), review (cross-modality analysis), plan " +
13 "(program creation). Channels route logged data to exercise nodes. Food channel " +
14 "integrates nutrition awareness. Type 'be' at the Fitness tree to start a guided " +
15 "workout: the coach walks you through today's program set by set.",
16
17 territory: "physical movement, training, exercise, how your body performs",
18 classifierHints: [
19 /\b\d+\s*x\s*\d+/i, // "135x10"
20 /\b(bench|squat|deadlift|press|curl|row|pull-?up|dip)\b/i, // gym exercises
21 /\b(push-?ups?|sit-?ups?|burpees?|plank|lunges?)\b/i, // bodyweight
22 /\b(ran|run|jog|sprint|mile|marathon|pace|tempo|5k|10k)\b/i, // running
23 /\b(workouts?|exercises?|training|sets|reps)\b/i, // fitness-specific
24 /\b(chest|back|legs|shoulders|core|calves|bicep|tricep)\b/i, // muscle groups
25 /\b(yoga|stretching|plank|hold.*seconds|pose)\b/i, // flexibility
26 /\b(pr|personal record|fastest|heaviest|longest)\b/i, // records
27 ],
28
29 needs: {
30 models: ["Node", "Note"],
31 services: ["hooks", "metadata"],
32 },
33
34 optional: {
35 services: ["llm"],
36 extensions: [
37 "values", // numeric tracking on exercise nodes
38 "channels", // signal paths from log to exercise nodes
39 "breath", // session timing
40 "schedules", // workout schedule
41 "scheduler", // missed workout detection
42 "food", // nutrition integration via channels
43 "notifications", // missed workout alerts
44 "phase", // suppress during focus
45 "treeos-base", // tool navigation registration
46 "html-rendering", // dashboard page
47 ],
48 },
49
50 provides: {
51 models: {},
52 routes: "./routes.js",
53 tools: true,
54 jobs: false,
55 guidedMode: "tree:fitness-coach",
56
57 hooks: {
58 fires: [],
59 listens: ["enrichContext", "onCascade", "afterBoot"],
60 },
61
62 cli: [
63 {
64 command: "fitness [message...]",
65 scope: ["tree"],
66 description: "Log any workout, start a guided session, or ask about progress.",
67 method: "POST",
68 endpoint: "/root/:rootId/fitness",
69 bodyMap: { message: 0 },
70 },
71 ],
72 },
73};
74
1/**
2 * Fitness Coach Mode
3 *
4 * Guided workout sessions. Walks through today's program exercise by exercise.
5 * Different coaching style per modality. Reads program from tree state.
6 */
7
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9import { getExerciseState, getProfile } from "../core.js";
10
11export default {
12 emoji: "💪",
13 label: "Fitness Coach",
14 bigMode: "tree",
15 hidden: true,
16 maxMessagesBeforeLoop: 20,
17 preserveContextOnLoop: true,
18
19 toolNames: [
20 "fitness-log-workout",
21 "fitness-add-exercise",
22 "fitness-add-group",
23 "fitness-adopt-exercise",
24 ],
25
26 async buildSystemPrompt({ username, rootId, currentNodeId }) {
27 const fitRoot = await findExtensionRoot(currentNodeId || rootId, "fitness") || rootId;
28 const state = await getExerciseState(fitRoot);
29 const profile = await getProfile(fitRoot);
30
31 const exerciseSummary = state ? Object.entries(state.groups).map(([group, data]) => {
32 const exs = data.exercises.map(e => {
33 const vals = e.values || {};
34 const schema = e.schema;
35 if (schema?.type === "distance-time") return `${e.name}: ${vals.weeklyMiles || vals.lastDistance || 0} ${schema.unit || "mi"}`;
36 if (schema?.type === "duration") return `${e.name}: ${vals.duration || "?"}s`;
37 if (schema?.type === "reps") return `${e.name}: ${vals.set1 || vals.totalReps || "?"}`;
38 return `${e.name}: ${vals.weight || "?"}${profile?.weightUnit || schema?.unit || "lb"}`;
39 }).join(", ");
40 return `${group} [${data.modality}]: ${exs}`;
41 }).join("\n") : "No exercises configured yet.";
42
43 const unadopted = state?._unadopted;
44 const unadoptedBlock = unadopted?.length > 0
45 ? `\nUNADOPTED NODES (new children without fitness tracking):\n${unadopted.map(u => `- "${u.name}" (id: ${u.id})`).join("\n")}\nIf the user wants to track these, use fitness-adopt-exercise to set them up. Ask what type of exercise and how to track it.`
46 : "";
47
48 return `You are ${username}'s training partner.
49
50CURRENT PROGRAM:
51${exerciseSummary}${unadoptedBlock}
52
53Profile: ${profile?.sessionsPerWeek || "?"} days/week, ${profile?.weightUnit || "lb"}, ${profile?.distanceUnit || "miles"}
54
55GUIDED WORKOUT:
56Walk through exercises one at a time, set by set.
57
58GYM EXERCISES:
59 "Bench Press. 135lb. Set 1 of 3. Goal: 12."
60 User: "10"
61 "10 reps. Rest up. Set 2."
62 ...after last set: "135x10/11/9. Vol: 4050. Moving on."
63
64 If all goals met: "All 12s at 135. Go 140 next time."
65 If missed: "Two of three. Stay at 135."
66
67RUNNING:
68 "Today: Easy 4 miles. Target pace: 8:30-9:00/mi."
69 "Start when ready. Log distance and time when done."
70 User: "done, 4.1 miles 35 min"
71 "4.1mi in 35:00. 8:32/mi pace. In the zone. Weekly: 15.5/20mi."
72
73BODYWEIGHT:
74 "Push-ups. 3 sets. Goal: 20 each."
75 "Set 1. Go."
76 User: "18"
77 "18. Rest 60s. Set 2."
78 ...after last set: "18/17/15 = 50 total. Up from 42 last time."
79
80 If all goals met: "All 20s. Time for diamond push-ups."
81
82AFTER EACH EXERCISE (when all sets are done):
83Call fitness-log-workout IMMEDIATELY with rootId ${fitRoot} and that exercise's data.
84Do not wait until the end of the session. Log each exercise as it's completed.
85The user might leave at any time. Every completed exercise must be saved.
86Then report the tool's response (volume, progression) and move to the next exercise.
87
88GUIDING VS ADAPTING:
89- By default, guide: suggest what to do next based on what's been neglected, what's due for progression, what the program says. "Legs are due. Squats at 155. Ready?"
90- But when the user overrides, adapt instantly. No pushback. They know their body. "I want to bench instead" means log bench. Their legs might be tired. That's valid information, not a failure.
91- If they do something not in the program, log it anyway (fitness-log-workout auto-creates exercises). Then fold it into future guidance.
92- After logging, one line about the bigger picture: "Bench logged. 3rd time this week. Legs haven't been hit since Tuesday." Observation, not instruction.
93- Some users will ask "what should I do today?" Guide them fully. Others will say "did bench 160x5x5." Just log it and observe. Match their energy.
94
95STYLE:
96- Talk like a training partner. Short messages between sets.
97- Use actual numbers. No filler. No motivational speeches.
98- One line per set response. Log the exercise after the last set.
99- Never mention node IDs, metadata, or tools.`.trim();
100 },
101};
102
1/**
2 * Fitness Log Mode
3 *
4 * Universal receiver. Detects modality from input. Parses into structured data.
5 * No tools. Returns JSON. One LLM call.
6 *
7 * The exercise list is built dynamically from the tree structure.
8 * Handles gym, running, bodyweight, and mixed workouts.
9 */
10
11import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
12import { findFitnessNodes, buildExerciseListForPrompt } from "../core.js";
13
14export default {
15 emoji: "💪",
16 label: "Fitness Log",
17 bigMode: "tree",
18 hidden: true,
19 maxMessagesBeforeLoop: 4,
20 preserveContextOnLoop: true,
21 toolNames: ["fitness-log-workout"],
22
23 async buildSystemPrompt({ rootId, currentNodeId }) {
24 const fitRoot = await findExtensionRoot(currentNodeId || rootId, "fitness") || rootId;
25 // Read the tree to know what exercises exist
26 const nodes = await findFitnessNodes(fitRoot);
27 const exerciseList = buildExerciseListForPrompt(nodes);
28
29 return `You are a multi-modality workout parser. Detect the workout type and parse into structured JSON.
30
31${exerciseList ? `KNOWN EXERCISES (match these names when possible):\n${exerciseList}\n` : ""}${nodes?._unadopted?.length > 0 ? `NOTE: These nodes exist but aren't configured yet. Still parse any matching input: ${nodes._unadopted.map(u => u.name).join(", ")}\n\n` : ""}
32DETECT MODALITY AND PARSE:
33
34GYM (weight x reps): Contains weight amounts or equipment names (bench, squat, deadlift, press, curl, row, cable, dumbbell, barbell, machine).
35{
36 "exercises": [{
37 "modality": "gym",
38 "name": "exact exercise name from list above or standard name",
39 "group": "group name from list above",
40 "sets": [{ "weight": number, "reps": number, "unit": "lb"|"kg"|"bodyweight" }]
41 }],
42 "date": "YYYY-MM-DD"
43}
44
45RUNNING (distance x time): Contains distance, time, pace, or run words (ran, run, jog, sprint, mile, km, tempo, easy, intervals).
46{
47 "modality": "running",
48 "distance": number,
49 "distanceUnit": "miles"|"km",
50 "duration": number_in_seconds,
51 "pace": seconds_per_distance_unit,
52 "type": "easy"|"tempo"|"interval"|"long"|"race",
53 "date": "YYYY-MM-DD"
54}
55
56BODYWEIGHT / HOME (reps or duration, no external weight): Contains bodyweight exercises (pushups, pullups, dips, plank, burpees, situps, lunges).
57{
58 "exercises": [{
59 "modality": "home",
60 "name": "exercise name",
61 "sets": [{ "reps": number }],
62 "variation": "standard"|"diamond"|"archer"|etc,
63 "duration": number_if_timed_hold
64 }],
65 "date": "YYYY-MM-DD"
66}
67
68MIXED (multiple modalities in one message): Return all pieces. Gym exercises as exercises array with modality:"gym", running as separate running object, bodyweight as exercises with modality:"home".
69{
70 "exercises": [
71 { "modality": "gym", "name": "Bench Press", "group": "Chest", "sets": [...] },
72 { "modality": "home", "name": "Push-ups", "sets": [...] }
73 ],
74 "modality": "running", "distance": 2, ...
75 "date": "YYYY-MM-DD"
76}
77
78PARSING RULES:
79- "bench 135x10,10,8" = Bench Press, gym, 3 sets at 135lb
80- "squat 225 5x5" = Squats, gym, 5 sets of 5 at 225lb
81- "pull-ups 10,8,6" = Pull-ups, gym (if under Gym) or home (if under Home), bodyweight
82- "ran 3 miles in 24 min" = running, 3 miles, 1440 seconds, 480 pace
83- "5k in 25 min" = running, 3.1 miles, 1500 seconds
84- "50 pushups" = Push-ups, home, [{reps: 50}]
85- "pushups 15,15,12" = Push-ups, home, 3 sets
86- "plank 90 seconds" = Plank, home, duration: 90
87- "did chest then ran 2 miles" = MIXED: gym exercises + running
88- Default weight unit: lb. Default distance: miles. Override if user says kg or km.
89- Date defaults to today if not specified.
90- If exercise not in known list, use best standard name and mark group as "unknown".
91
92AFTER PARSING:
93Call fitness-log-workout ONCE with rootId ${fitRoot} and the exercises array from your parsed output.
94Include the date field. The tool handles everything: delivering to exercise nodes, recording session history, tracking PRs, and detecting progression.
95Confirm naturally using the summary returned by the tool. Do not return raw JSON to the user.`.trim();
96 },
97};
98
1/**
2 * Fitness Plan Mode
3 *
4 * Creates and modifies training programs. Uses fitness tools to scaffold
5 * the tree structure conversationally. Handles first-time setup and
6 * ongoing program modifications.
7 */
8
9import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
10import { getExerciseState, getProfile } from "../core.js";
11
12export default {
13 emoji: "📋",
14 label: "Fitness Plan",
15 bigMode: "tree",
16 hidden: true,
17 maxMessagesBeforeLoop: 25,
18 preserveContextOnLoop: true,
19 toolNames: [
20 "fitness-add-modality",
21 "fitness-add-group",
22 "fitness-add-exercise",
23 "fitness-remove-exercise",
24 "fitness-adopt-exercise",
25 "fitness-complete-setup",
26 "fitness-save-profile",
27 ],
28
29 async buildSystemPrompt({ username, rootId, currentNodeId }) {
30 const fitRoot = await findExtensionRoot(currentNodeId || rootId, "fitness") || rootId;
31 const profile = await getProfile(fitRoot);
32 const state = await getExerciseState(fitRoot);
33 const hasExercises = state && Object.values(state.groups).some(g => g.exercises?.length > 0);
34
35 const exerciseList = state ? Object.entries(state.groups).map(([group, data]) =>
36 `${group} [${data.modality}]: ${data.exercises.map(e => e.name).join(", ")}`
37 ).join("\n") : "No exercises found";
38
39 const unadopted = state?._unadopted;
40 const unadoptedBlock = unadopted?.length > 0
41 ? `\nUNADOPTED NODES:\n${unadopted.map(u => `- "${u.name}" (id: ${u.id})`).join("\n")}\nUse fitness-adopt-exercise to set these up if the user wants to track them.`
42 : "";
43
44 return `You are ${username}'s fitness coach.
45
46${hasExercises ? "STATUS: Program exists. Modify or extend." : "STATUS: No exercises yet. Build their program."}
47
48CURRENT PROGRAM:
49${exerciseList}${unadoptedBlock}
50
51Profile: ${profile?.sessionsPerWeek || "?"} days/week, ${profile?.weightUnit || "lb"}, ${profile?.distanceUnit || "miles"}
52
53${!hasExercises ? `SETUP (new program):
541. Ask what kind of training they do: gym (barbell/dumbbell/machines), running, bodyweight/home, or a mix
552. Ask how many days per week they train
563. Ask their preferred units (lb/kg for weights, miles/km for distance)
574. For each selected modality, ask about their exercises and current levels
58` : ""}TOOLS:
59- fitness-add-modality: Create a Gym, Running, or Home branch
60- fitness-add-group: Create muscle groups under Gym (Chest, Back, Legs...) or categories under Home
61- fitness-add-exercise: Create exercise nodes with tracking type, starting values, goals, and progression rules
62- fitness-remove-exercise: Remove an exercise
63- fitness-adopt-exercise: Adopt existing nodes as exercises
64- fitness-save-profile: Save preferences (units, weekly goal)
65- fitness-complete-setup: Call when ALL exercises are created
66
67EXERCISE TYPES:
68- weight-reps: Gym lifts. Values: weight, set1, set2, set3. Goals: rep targets. Progression: weight + increment.
69- reps: Bodyweight. Values: set1, set2, set3. Goals: rep targets. Progression: harder variation.
70- duration: Holds/planks. Values: duration. Goals: time targets. Progression: longer holds.
71- distance-time: Running. Created automatically by fitness-add-modality "running".
72
73DEFAULTS (if user says "just set me up" or gives minimal info):
74- Gym: Push/Pull/Legs split. Bench, OHP, Rows, Pull-ups, Squats, RDL. Start at reasonable beginner weights.
75- Running: Runs + PRs + Plan. Weekly mileage goal based on current level.
76- Home: Push-ups, Pull-ups, Dips, Squats, Plank. Standard variations.
77
78CRITICAL RULES:
79- NEVER say you created an exercise without calling fitness-add-exercise first. The tool creates the node. Without the tool call, nothing exists.
80- NEVER describe a program without building it. When the user gives you exercises, IMMEDIATELY call the tools. Tools first, summary after.
81- Call fitness-add-modality FIRST (gym/running/home), then fitness-add-group for muscle groups, then fitness-add-exercise for each exercise. Do them in order. Do them NOW, not after more conversation.
82- Call fitness-save-profile with their units and weekly goal.
83- Call fitness-complete-setup LAST after all exercises are created.
84- If the user gives you 8 exercises, make 8 fitness-add-exercise calls. No shortcuts. No "I'll set those up." Actually call the tool for each one.
85- Be concise in your response AFTER the tools run. Confirm what was created. Don't repeat what they told you.`;
86 },
87};
88
1/**
2 * Fitness Review Mode
3 *
4 * Cross-modality analysis. Weekly summary, progression tracking,
5 * PR detection, consistency patterns, overdue exercises, nutrition correlation.
6 */
7
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9import { getExerciseState, getProfile, getWeeklyStats } from "../core.js";
10
11export default {
12 emoji: "📊",
13 label: "Fitness Review",
14 bigMode: "tree",
15 hidden: true,
16 maxMessagesBeforeLoop: 30,
17 preserveContextOnLoop: true,
18
19 toolNames: [
20 ],
21
22 async buildSystemPrompt({ username, rootId, currentNodeId }) {
23 const fitRoot = await findExtensionRoot(currentNodeId || rootId, "fitness") || rootId;
24 const state = await getExerciseState(fitRoot);
25 const profile = await getProfile(fitRoot);
26 const weekly = await getWeeklyStats(fitRoot);
27
28 const exerciseSummary = state ? Object.entries(state.groups).map(([group, data]) => {
29 const exs = data.exercises.map(e => {
30 const vals = e.values || {};
31 const goals = e.goals || {};
32 if (data.modality === "gym") {
33 const sets = Object.keys(vals).filter(k => k.startsWith("set")).map(k => vals[k]).filter(v => v != null);
34 const goalVals = Object.keys(goals).filter(k => k.startsWith("set")).map(k => goals[k]).filter(v => v != null);
35 return `${e.name}: ${vals.weight || "?"}lb ${sets.join("/")} (goals: ${goalVals.join("/")}) last: ${vals.lastWorked || "never"} [${e.historyCount} sessions]`;
36 }
37 if (data.modality === "running") {
38 return `${e.name}: ${JSON.stringify(vals)}`;
39 }
40 return `${e.name}: ${JSON.stringify(vals)} goals: ${JSON.stringify(goals)}`;
41 }).join("\n ");
42 return ` ${group} [${data.modality}]:\n ${exs}`;
43 }).join("\n") : "No data.";
44
45 const weeklyStr = weekly
46 ? `Sessions: ${weekly.sessions}, Gym: ${weekly.gymSessions}, Runs: ${weekly.runs} (${weekly.runMiles}mi), Home: ${weekly.homeSessions}, Volume: ${weekly.totalVolume}lb`
47 : "No data this week.";
48
49 return `You are ${username}'s fitness analyst. Analyze their training data across all modalities.
50
51CURRENT STATE:
52${exerciseSummary}
53
54THIS WEEK: ${weeklyStr}
55Profile: ${profile?.sessionsPerWeek || "?"} days/week target
56
57ANALYZE:
581. Progressive overload: Which exercises are progressing? Which are stalled?
592. Consistency: Sessions this week vs target. Missed modalities.
603. Volume trends: Is total volume trending up, flat, or down?
614. PRs: Any new personal records (gym lifts, run times)?
625. Overdue: Exercises not worked in 7+ days.
636. Balance: Are they neglecting any modality or muscle group?
647. Recovery: Training too many consecutive days? Enough rest?
658. Cross-modality: Running affecting leg day recovery? Bodyweight complementing gym?
66
67Use navigate-tree and get-node-notes to read History notes for trends over time.
68Read exercise node notes for detailed session history.
69
70STYLE:
71- Lead with what's working. Then what needs attention.
72- Use actual numbers and percentages. "Bench: 130->140 (+7.7%) in 4 weeks."
73- Compare to their goals. "15.5/20 weekly miles (78%)."
74- Be direct. If something is stalling, say so and suggest a fix.
75- Never mention node IDs, metadata, or tools.`.trim();
76 },
77};
78
1/**
2 * Fitness Setup Mode - Step 1
3 *
4 * Simple. Ask what they train. Return JSON with modalities and basic info.
5 * No tools. No tree building. Just parse the user's intent.
6 */
7
8export default {
9 emoji: "💪",
10 label: "Fitness Setup",
11 bigMode: "tree",
12 hidden: true,
13 maxMessagesBeforeLoop: 4,
14 preserveContextOnLoop: false,
15 toolNames: [],
16
17 buildSystemPrompt({ username }) {
18 return `You are setting up ${username}'s fitness tracking.
19
20Ask ONE question: What kind of training do you do?
21- Gym (barbell, dumbbell, machines)
22- Running
23- Bodyweight / home workouts
24- Mix of everything
25
26If they already told you (in their message), skip asking.
27
28Then return ONLY this JSON:
29{
30 "modalities": ["gym", "running", "home"],
31 "weightUnit": "lb",
32 "distanceUnit": "miles",
33 "sessionsPerWeek": 4,
34 "goal": "hypertrophy"
35}
36
37Rules:
38- modalities: array of "gym", "running", "home". Include what they mentioned.
39- If they say "everything" or "mix", include all three.
40- If they say "hypertrophy" or "muscle", goal is "hypertrophy". "strength" or "strong" = "strength". Default "general".
41- Default weightUnit "lb", distanceUnit "miles", sessionsPerWeek 4.
42- If they mention days (e.g. "4 days"), use that for sessionsPerWeek.
43- If they mention kg or km, use those units.
44- Return ONLY the JSON. No explanation.`.trim();
45 },
46};
47
1/**
2 * Fitness Dashboard
3 *
4 * Exercises with values/goals, weekly stats, modality breakdown,
5 * progression alerts. Renders via the generic app dashboard.
6 */
7
8import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
9import { esc, timeAgo } from "../../html-rendering/html/utils.js";
10import { page } from "../../html-rendering/html/layout.js";
11import { glassCardStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
12import { chatBarCss, chatBarHtml, chatBarJs, commandsRefHtml } from "../../html-rendering/html/chatBar.js";
13
14function goalColor(current, goal) {
15 if (!goal || goal === 0) return "rgba(255,255,255,0.15)";
16 if (current >= goal) return "rgba(72,187,120,0.3)";
17 if (current >= goal * 0.7) return "rgba(236,201,75,0.2)";
18 return "rgba(255,255,255,0.08)";
19}
20
21export function renderFitnessDashboard({ rootId, rootName, state, weekly, profile, token, userId, inApp }) {
22 const modalities = state?.modalities || [];
23 const groups = state?.groups || {};
24 const today = new Date();
25 const dateStr = today.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
26
27 // If no exercises, use generic dashboard with empty state
28 if (Object.keys(groups).length === 0) {
29 return renderAppDashboard({
30 rootId, rootName, token, userId, inApp,
31 tags: modalities.map(m => ({ label: m, color: m === "gym" ? "#667eea" : m === "running" ? "#48bb78" : "#ecc94b" })),
32 emptyState: { title: "No exercises configured yet", message: "Type a message below to get started." },
33 commands: [
34 { cmd: "fitness <message>", desc: "Log any workout" },
35 { cmd: "fitness workout", desc: "Start guided session" },
36 { cmd: "fitness plan", desc: "Build or modify program" },
37 { cmd: "be", desc: "Coach walks you through today" },
38 ],
39 chatBar: { placeholder: "Log a workout, say 'workout' to start, or ask about progress...", endpoint: `/api/v1/root/${rootId}/fitness` },
40 });
41 }
42
43 // Fitness has a specialized exercise grid layout that doesn't fit the generic card model.
44 // Build custom HTML but still use shared styles and chatbar.
45 const css = `
46 ${glassHeaderStyles}
47 ${glassCardStyles}
48 ${responsiveBase}
49
50 .fit-layout { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
51
52 .stat-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 20px; }
53 .stat-chip { background: rgba(255,255,255,0.06); border-radius: 16px; padding: 4px 12px; font-size: 0.8rem; color: rgba(255,255,255,0.5); }
54 .stat-chip strong { color: rgba(255,255,255,0.8); }
55
56 .modality-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; margin-left: 6px; }
57 .mod-gym { background: rgba(102,126,234,0.15); color: rgba(102,126,234,0.8); }
58 .mod-running { background: rgba(72,187,120,0.15); color: rgba(72,187,120,0.8); }
59 .mod-home { background: rgba(236,201,75,0.15); color: rgba(236,201,75,0.8); }
60
61 .section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(255,255,255,0.5); margin-bottom: 0.5rem; margin-top: 1.5rem; }
62
63 .group-card { margin-bottom: 16px; padding: 16px; }
64 .group-name { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 10px; }
65
66 .ex-row { display: flex; flex-direction: column; gap: 4px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.9rem; cursor: pointer; }
67 .ex-row:last-child { border-bottom: none; }
68 .ex-header { display: flex; align-items: center; gap: 10px; }
69 .ex-detail { display: none; padding: 6px 0 2px; }
70 .ex-row.expanded .ex-detail { display: block; }
71 .ex-session-row { font-size: 0.78rem; color: rgba(255,255,255,0.45); padding: 2px 0; font-family: 'JetBrains Mono', monospace; }
72 .ex-weight-prog { font-size: 0.7rem; color: rgba(255,255,255,0.3); font-family: 'JetBrains Mono', monospace; }
73 .ex-name { flex: 1; color: rgba(255,255,255,0.8); }
74 .ex-weight { color: rgba(255,255,255,0.6); font-size: 0.85rem; min-width: 50px; }
75 .ex-sets { display: flex; gap: 4px; }
76 .ex-set { min-width: 26px; height: 20px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; color: rgba(255,255,255,0.7); }
77 .ex-meta { display: flex; gap: 8px; align-items: center; font-size: 0.78rem; color: rgba(255,255,255,0.35); flex-wrap: wrap; }
78 .ex-last { font-size: 0.75rem; color: rgba(255,255,255,0.3); }
79 .ex-sessions { font-size: 0.7rem; color: rgba(255,255,255,0.2); }
80 .ex-progression { font-size: 0.75rem; color: #48bb78; padding: 2px 8px; background: rgba(72,187,120,0.1); border-radius: 10px; }
81
82 .running-stat { display: flex; justify-content: space-between; padding: 6px 0; font-size: 0.85rem; }
83 .running-label { color: rgba(255,255,255,0.5); }
84 .running-val { color: rgba(255,255,255,0.8); }
85 `;
86
87 // Weekly stats
88 const weekChips = [];
89 if (weekly) {
90 if (weekly.sessions) weekChips.push(`<span class="stat-chip"><strong>${weekly.sessions}</strong> sessions</span>`);
91 if (weekly.gymSessions) weekChips.push(`<span class="stat-chip"><strong>${weekly.gymSessions}</strong> gym</span>`);
92 if (weekly.runs) weekChips.push(`<span class="stat-chip"><strong>${weekly.runs}</strong> run${weekly.runs > 1 ? "s" : ""} (${weekly.runMiles?.toFixed(1) || 0} mi)</span>`);
93 if (weekly.homeSessions) weekChips.push(`<span class="stat-chip"><strong>${weekly.homeSessions}</strong> home</span>`);
94 if (weekly.totalVolume) weekChips.push(`<span class="stat-chip"><strong>${weekly.totalVolume.toLocaleString()}</strong> lb vol</span>`);
95 if (profile?.sessionsPerWeek) weekChips.push(`<span class="stat-chip">${weekly.sessions || 0}/<strong>${profile.sessionsPerWeek}</strong> goal</span>`);
96 }
97
98 const modTags = modalities.map(m => {
99 const cls = m === "gym" ? "mod-gym" : m === "running" ? "mod-running" : "mod-home";
100 return `<span class="modality-tag ${cls}">${m}</span>`;
101 }).join("");
102
103 // Build group cards
104 let groupsHtml = "";
105 for (const [groupName, data] of Object.entries(groups)) {
106 const mod = data.modality || "gym";
107 const modCls = mod === "gym" ? "mod-gym" : mod === "running" ? "mod-running" : "mod-home";
108
109 const exercisesHtml = data.exercises.map(ex => {
110 const vals = ex.values || {};
111 const goals = ex.goals || {};
112
113 if (mod === "running") {
114 const stats = [];
115 if (vals.weeklyMiles != null) stats.push(["Weekly miles", `${vals.weeklyMiles || 0}${goals.weeklyMilesGoal ? "/" + goals.weeklyMilesGoal : ""}`]);
116 if (vals.lastDistance) stats.push(["Last run", `${vals.lastDistance} mi`]);
117 if (vals.lastPace) {
118 const min = Math.floor(vals.lastPace / 60);
119 const sec = Math.round(vals.lastPace % 60);
120 stats.push(["Last pace", `${min}:${String(sec).padStart(2, "0")}/mi`]);
121 }
122 if (vals.runsThisWeek != null) stats.push(["Runs this week", vals.runsThisWeek]);
123 if (ex.name === "PRs") {
124 for (const [k, v] of Object.entries(vals)) {
125 if (v && k !== "lastWorked") {
126 const min = Math.floor(v / 60);
127 const sec = Math.round(v % 60);
128 stats.push([k.toUpperCase(), `${min}:${String(sec).padStart(2, "0")}`]);
129 }
130 }
131 }
132 if (stats.length === 0) return `<div class="ex-row"><span class="ex-name">${esc(ex.name)}</span><span class="ex-last">no data</span></div>`;
133 return stats.map(([label, val]) =>
134 `<div class="running-stat"><span class="running-label">${label}</span><span class="running-val">${val}</span></div>`
135 ).join("");
136 }
137
138 const weight = vals.weight || 0;
139 const setKeys = Object.keys(vals).filter(k => /^set\d+$/.test(k)).sort();
140 const goalKeys = Object.keys(goals).filter(k => /^set\d+$/.test(k)).sort();
141
142 const setsHtml = setKeys.map((k, i) => {
143 const v = vals[k];
144 const g = goalKeys[i] ? goals[goalKeys[i]] : null;
145 const bg = goalColor(v, g);
146 return `<span class="ex-set" style="background:${bg}">${v != null ? v : "-"}</span>`;
147 }).join("");
148
149 let allMet = setKeys.length > 0;
150 for (let i = 0; i < setKeys.length; i++) {
151 const g = goalKeys[i] ? goals[goalKeys[i]] : null;
152 if (g && (vals[setKeys[i]] == null || vals[setKeys[i]] < g)) { allMet = false; break; }
153 }
154
155 const lastStr = vals.lastWorked ? timeAgo(new Date(vals.lastWorked)) : "";
156
157 // Weight progression from recent history
158 const recentHist = ex.recentHistory || [];
159 const weightProgression = recentHist
160 .filter(h => h.weight)
161 .map(h => h.weight)
162 .join(" > ");
163
164 // Recent sessions detail (expandable)
165 const sessionDetail = recentHist.length > 0
166 ? recentHist.map(h => {
167 const setStr = (h.sets || []).map(s => `${s.weight || ""}x${s.reps || "?"}`).join(", ");
168 return `<div class="ex-session-row">${h.date || "?"}: ${setStr || `${h.weight || "?"}x${Object.keys(h).filter(k => /^set\d/.test(k)).map(k => h[k]).join("/")}`}</div>`;
169 }).join("")
170 : "";
171
172 return `
173 <div class="ex-row" onclick="this.classList.toggle('expanded')">
174 <div class="ex-header">
175 <span class="ex-name">${esc(ex.name)}</span>
176 ${weight ? `<span class="ex-weight">${weight}${profile?.weightUnit || "lb"}</span>` : ""}
177 <div class="ex-sets">${setsHtml || '<span style="color:rgba(255,255,255,0.2);font-size:0.8rem">no sets</span>'}</div>
178 ${allMet && setKeys.length > 0 ? '<span class="ex-progression">ready</span>' : ""}
179 </div>
180 <div class="ex-meta">
181 <span class="ex-last">${lastStr}</span>
182 ${ex.historyCount ? `<span class="ex-sessions">${ex.historyCount} sessions</span>` : ""}
183 ${weightProgression ? `<span class="ex-weight-prog">${weightProgression}</span>` : ""}
184 </div>
185 ${sessionDetail ? `<div class="ex-detail">${sessionDetail}</div>` : ""}
186 </div>`;
187 }).join("");
188
189 groupsHtml += `
190 <div class="group-card glass-card">
191 <div class="group-name">${esc(groupName)} <span class="modality-tag ${modCls}">${mod}</span></div>
192 ${exercisesHtml || '<div style="color:rgba(255,255,255,0.35);font-size:0.9rem;font-style:italic;padding:1rem 0">No exercises yet.</div>'}
193 </div>`;
194 }
195
196 const navHtml = userId
197 ? `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><a href="/api/v1/user/${esc(userId)}/apps?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">\u2190 Apps</a><div style="display:flex;gap:16px;"><a href="/api/v1/root/${esc(rootId)}?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Tree</a><a href="/api/v1/user/${esc(userId)}/llm?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">LLM</a></div></div>`
198 : "";
199
200 const body = `
201 <div class="fit-layout">
202 ${navHtml}
203 <h1 style="font-size:1.5rem;color:#fff;margin-bottom:0.2rem">${esc(rootName || "Fitness")} ${modTags}</h1>
204 <div style="color:rgba(255,255,255,0.35);font-size:0.85rem;margin-top:4px">${dateStr}</div>
205 ${weekChips.length > 0 ? `<div class="stat-row">${weekChips.join("")}</div>` : ""}
206 <div class="section-title">Exercises</div>
207 ${groupsHtml}
208 ${commandsRefHtml([
209 { cmd: "fitness <message>", desc: "Log any workout" },
210 { cmd: "fitness workout", desc: "Start guided session" },
211 { cmd: "fitness progress", desc: "Review your progress" },
212 { cmd: "fitness plan", desc: "Build or modify program" },
213 { cmd: "be", desc: "Coach walks you through today" },
214 ])}
215 </div>`;
216
217 return page({
218 title: `${rootName || "Fitness"} . ${dateStr}`,
219 css: css + (!inApp ? chatBarCss() : ""),
220 body: body + (!inApp ? chatBarHtml({ placeholder: "Log a workout, say 'workout' to start, or ask about progress..." }) : ""),
221 js: !inApp ? chatBarJs({ endpoint: `/api/v1/root/${rootId}/fitness`, token, rootId }) : "",
222 });
223}
224
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import log from "../../seed/log.js";
5import NodeModel from "../../seed/models/node.js";
6import UserModel from "../../seed/models/user.js";
7import { handleMessage } from "./handler.js";
8
9let Node = NodeModel;
10export function setServices({ Node: N }) { if (N) Node = N; }
11
12const router = express.Router();
13
14// ── HTML Dashboard (GET with ?html) ──
15router.get("/root/:rootId/fitness", async (req, res, next) => {
16 if (!("html" in req.query)) return next();
17 try {
18 const { isHtmlEnabled } = await import("../html-rendering/config.js");
19 if (!isHtmlEnabled()) return next();
20 const urlAuth = (await import("../html-rendering/urlAuth.js")).default;
21 urlAuth(req, res, async () => {
22 const { rootId } = req.params;
23 const root = await Node.findById(rootId).select("name metadata").lean();
24 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Not found");
25 const meta = root.metadata instanceof Map ? root.metadata.get("fitness") : root.metadata?.fitness;
26 let state = null, weekly = null, profile = null;
27 if (meta?.initialized) {
28 const core = await import("./core.js");
29 [state, weekly, profile] = await Promise.all([core.getExerciseState(rootId), core.getWeeklyStats(rootId), core.getProfile(rootId)]);
30 }
31 const { renderFitnessDashboard } = await import("./pages/dashboard.js");
32 res.send(renderFitnessDashboard({ rootId, rootName: root.name, state, weekly, profile, token: req.query.token || null, userId: req.userId, inApp: !!req.query.inApp }));
33 });
34 } catch (err) {
35 sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
36 }
37});
38
39/**
40 * POST /root/:rootId/fitness
41 *
42 * Four paths:
43 * 1. First use: scaffold base, enter plan mode for conversational setup
44 * 2. Setup incomplete: continue plan mode
45 * 3. Workout input: parse, route to exercise nodes, record history
46 * 4. Intent-based: route to coach, review, or plan mode
47 */
48router.post("/root/:rootId/fitness", authenticate, async (req, res) => {
49 try {
50 const { rootId } = req.params;
51 const rawMessage = req.body.message;
52 const message = Array.isArray(rawMessage) ? rawMessage.join(" ") : rawMessage;
53 if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
54
55 const root = await Node.findById(rootId).select("rootOwner contributors name").lean();
56 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
57
58 const userId = req.userId;
59 const isOwner = root.rootOwner?.toString() === userId;
60 const isContributor = root.contributors?.some(c => c.toString() === userId);
61 if (!isOwner && !isContributor) {
62 return sendError(res, 403, ERR.FORBIDDEN, "No access to this tree");
63 }
64
65 const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
66 if (await isExtensionBlockedAtNode("fitness", rootId)) {
67 return sendError(res, 403, ERR.EXTENSION_BLOCKED, "Fitness is blocked on this branch.");
68 }
69
70 const user = await UserModel.findById(userId).select("username").lean();
71 const username = user?.username || "user";
72
73 const result = await handleMessage(message, { userId, username, rootId, res });
74 if (!res.headersSent) sendOk(res, result);
75 } catch (err) {
76 log.error("Fitness", "Route error:", err.message);
77 if (!res.headersSent) sendError(res, 500, ERR.INTERNAL, "Fitness request failed");
78 }
79});
80
81export default router;
82
1/**
2 * Fitness Setup
3 *
4 * Scaffolds the fitness tree structure. Base scaffold creates Log, Program, History.
5 * Modality scaffolders create Gym, Running, Home branches on demand.
6 * Node creator functions let the AI (via tools) build the tree conversationally.
7 *
8 * No hardcoded programs. No hardcoded exercises. The AI and user decide the shape.
9 */
10
11import log from "../../seed/log.js";
12import { setNodeMode } from "../../seed/modes/registry.js";
13
14let _metadata = null;
15let _Node = null;
16
17export function setDeps({ metadata, Node }) {
18 _metadata = metadata;
19 _Node = Node;
20}
21
22// ── Base scaffold (Log, Program, History) ──
23
24export async function scaffoldFitnessBase(rootId, userId) {
25 const { createNode } = await import("../../seed/tree/treeManagement.js");
26
27 const logNode = await createNode({ name: "Log", parentId: rootId, userId });
28 const programNode = await createNode({ name: "Program", parentId: rootId, userId });
29 const historyNode = await createNode({ name: "History", parentId: rootId, userId });
30
31 await _metadata.setExtMeta(logNode, "fitness", { role: "log" });
32 await _metadata.setExtMeta(programNode, "fitness", { role: "program" });
33 await _metadata.setExtMeta(historyNode, "fitness", { role: "history" });
34
35 // Mode overrides: plan on root until setup complete, log on Log node
36 await setNodeMode(rootId, "respond", "tree:fitness-plan");
37 await setNodeMode(logNode._id, "respond", "tree:fitness-log");
38
39 // Mark as initialized with base phase
40 const rootNode = await _Node.findById(rootId);
41 if (rootNode) {
42 await _metadata.setExtMeta(rootNode, "fitness", {
43 initialized: true,
44 setupPhase: "scaffolded",
45 });
46 }
47
48 // Wire food channel if food tree is a sibling
49 await wireFoodChannel(rootId, logNode._id, userId);
50
51 log.info("Fitness", `Base scaffold complete: Log, Program, History`);
52
53 return {
54 log: String(logNode._id),
55 program: String(programNode._id),
56 history: String(historyNode._id),
57 };
58}
59
60// ── Modality scaffolders ──
61
62export async function scaffoldGym(rootId, userId) {
63 const { createNode } = await import("../../seed/tree/treeManagement.js");
64 const gymNode = await createNode({ name: "Gym", parentId: rootId, userId });
65 await _metadata.setExtMeta(gymNode, "fitness", { role: "modality", modality: "gym" });
66 log.info("Fitness", "Gym modality scaffolded");
67 return { id: String(gymNode._id), name: "Gym" };
68}
69
70export async function scaffoldRunning(rootId, userId) {
71 const { createNode } = await import("../../seed/tree/treeManagement.js");
72 const runningNode = await createNode({ name: "Running", parentId: rootId, userId });
73 await _metadata.setExtMeta(runningNode, "fitness", { role: "modality", modality: "running" });
74
75 // Running has fixed structure: Runs, PRs, Plan
76 const runsNode = await createNode({ name: "Runs", parentId: runningNode._id, userId });
77 const prsNode = await createNode({ name: "PRs", parentId: runningNode._id, userId });
78 const planNode = await createNode({ name: "Plan", parentId: runningNode._id, userId });
79
80 await _metadata.setExtMeta(runsNode, "fitness", {
81 role: "exercise",
82 valueSchema: { type: "distance-time", distanceUnit: "miles", timeUnit: "min" },
83 });
84 await _metadata.setExtMeta(prsNode, "fitness", {
85 role: "exercise",
86 valueSchema: { type: "prs" },
87 });
88 await _metadata.setExtMeta(planNode, "fitness", { role: "plan" });
89
90 // Create Log -> Runs channel
91 await createLogChannel(rootId, runsNode._id, "runs-log", userId);
92
93 log.info("Fitness", "Running modality scaffolded: Runs, PRs, Plan");
94 return { id: String(runningNode._id), name: "Running" };
95}
96
97export async function scaffoldHome(rootId, userId) {
98 const { createNode } = await import("../../seed/tree/treeManagement.js");
99 const homeNode = await createNode({ name: "Home", parentId: rootId, userId });
100 await _metadata.setExtMeta(homeNode, "fitness", { role: "modality", modality: "home" });
101
102 const routineNode = await createNode({ name: "Routine", parentId: homeNode._id, userId });
103 await _metadata.setExtMeta(routineNode, "fitness", { role: "plan" });
104
105 log.info("Fitness", "Home/bodyweight modality scaffolded");
106 return { id: String(homeNode._id), name: "Home" };
107}
108
109// ── Node creators (called by AI tools during setup/modification) ──
110
111export async function addGroupNode({ parentId, name, userId }) {
112 const { createNode } = await import("../../seed/tree/treeManagement.js");
113 const groupNode = await createNode({ name, parentId, userId });
114 await _metadata.setExtMeta(groupNode, "fitness", { role: "group" });
115 return { id: String(groupNode._id), name };
116}
117
118export async function addExerciseNode({
119 groupId, name, exerciseType, unit, sets,
120 startingValues, goals, progressionIncrement, progressionPath,
121 rootId, userId,
122}) {
123 const { createNode } = await import("../../seed/tree/treeManagement.js");
124 const exNode = await createNode({ name, parentId: groupId, userId });
125
126 // Build value schema
127 const valueSchema = { type: exerciseType || "weight-reps" };
128 if (unit) valueSchema.unit = unit;
129 if (sets) valueSchema.sets = sets;
130
131 const fitnessMeta = {
132 role: "exercise",
133 history: [],
134 valueSchema,
135 };
136 if (progressionIncrement) fitnessMeta.progressionIncrement = progressionIncrement;
137 if (progressionPath) fitnessMeta.progressionPath = progressionPath;
138
139 await _metadata.setExtMeta(exNode, "fitness", fitnessMeta);
140
141 // Set initial values
142 if (startingValues && typeof startingValues === "object") {
143 await _metadata.batchSetExtMeta(exNode._id, "values", startingValues);
144 }
145
146 // Set goals
147 if (goals && typeof goals === "object") {
148 await _metadata.batchSetExtMeta(exNode._id, "goals", goals);
149 }
150
151 // Create Log -> exercise channel
152 if (rootId) {
153 await createLogChannel(rootId, exNode._id, name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-log", userId);
154 }
155
156 return { id: String(exNode._id), name };
157}
158
159export async function removeExerciseNode(exerciseNodeId, userId) {
160 try {
161 const { deleteNodeBranch } = await import("../../seed/tree/treeManagement.js");
162 await deleteNodeBranch(exerciseNodeId, userId, true);
163 return true;
164 } catch (err) {
165 log.warn("Fitness", `Remove exercise failed: ${err.message}`);
166 return false;
167 }
168}
169
170export async function completeSetup(rootId) {
171 const rootNode = await _Node.findById(rootId);
172 if (!rootNode) return;
173 const existing = _metadata.getExtMeta(rootNode, "fitness") || {};
174 await _metadata.setExtMeta(rootNode, "fitness", { ...existing, setupPhase: "complete" });
175 // Switch from plan mode to coach mode now that setup is done
176 await setNodeMode(rootId, "respond", "tree:fitness-coach");
177 log.info("Fitness", "Setup phase complete, switched to coach mode");
178}
179
180export async function saveProfile(rootId, profile) {
181 const rootNode = await _Node.findById(rootId);
182 if (!rootNode) return;
183 const existing = _metadata.getExtMeta(rootNode, "fitness") || {};
184 await _metadata.setExtMeta(rootNode, "fitness", { ...existing, profile });
185}
186
187// ── Helpers ──
188
189async function createLogChannel(rootId, targetNodeId, channelName, userId) {
190 try {
191 // Find the Log node
192 const children = await _Node.find({ parent: rootId }).select("_id metadata").lean();
193 let logNodeId = null;
194 for (const c of children) {
195 const meta = c.metadata instanceof Map ? c.metadata.get("fitness") : c.metadata?.fitness;
196 if (meta?.role === "log") { logNodeId = String(c._id); break; }
197 }
198 if (!logNodeId) return;
199
200 const { getExtension } = await import("../loader.js");
201 const ch = getExtension("channels");
202 if (ch?.exports?.createChannel) {
203 await ch.exports.createChannel({
204 sourceNodeId: logNodeId,
205 targetNodeId: String(targetNodeId),
206 channelName,
207 direction: "outbound",
208 userId,
209 });
210 }
211 } catch (err) {
212 log.verbose("Fitness", `Channel creation skipped: ${err.message}`);
213 }
214}
215
216async function wireFoodChannel(rootId, logNodeId, userId) {
217 try {
218 const parent = await _Node.findById(rootId).select("parent").lean();
219 if (!parent?.parent) return;
220 const siblings = await _Node.find({ parent: parent.parent }).select("_id metadata").lean();
221 for (const sib of siblings) {
222 const sibMeta = sib.metadata instanceof Map ? sib.metadata.get("food") : sib.metadata?.food;
223 if (sibMeta?.initialized) {
224 const foodChildren = await _Node.find({ parent: sib._id }).select("_id metadata").lean();
225 const dailyNode = foodChildren.find(c => {
226 const fm = c.metadata instanceof Map ? c.metadata.get("food") : c.metadata?.food;
227 return fm?.role === "daily";
228 });
229 if (dailyNode) {
230 const { getExtension } = await import("../loader.js");
231 const ch = getExtension("channels");
232 if (ch?.exports?.createChannel) {
233 await ch.exports.createChannel({
234 sourceNodeId: String(logNodeId),
235 targetNodeId: String(dailyNode._id),
236 channelName: "fitness-food",
237 direction: "bidirectional",
238 filter: { tags: ["nutrition", "workout"] },
239 userId,
240 });
241 log.info("Fitness", "Channel created: fitness-food (bidirectional with Food/Daily)");
242 }
243 }
244 break;
245 }
246 }
247 } catch (err) {
248 log.verbose("Fitness", `Food channel not created: ${err.message}`);
249 }
250}
251
1/**
2 * Fitness Tools
3 *
4 * MCP tools for building and modifying the fitness tree.
5 * Used by the plan and coach modes during setup and program changes.
6 */
7
8import { z } from "zod";
9import {
10 addGroupNode, addExerciseNode, removeExerciseNode,
11 completeSetup, scaffoldGym, scaffoldRunning, scaffoldHome,
12 saveProfile,
13} from "./setup.js";
14import {
15 adoptExercise, findFitnessNodes, deliverToExerciseNodes,
16 recordSessionHistory, buildWorkoutSummary, checkProgression,
17} from "./core.js";
18
19export default function getTools() {
20 return [
21 {
22 name: "fitness-add-modality",
23 description: "Add a training modality branch (Gym, Running, or Home/bodyweight) to the fitness tree.",
24 schema: {
25 rootId: z.string().describe("Fitness root node ID."),
26 modality: z.enum(["gym", "running", "home"]).describe("Which modality to add."),
27 userId: z.string().describe("Injected by server. Ignore."),
28 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
29 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
30 },
31 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
32 handler: async ({ rootId, modality, userId }) => {
33 try {
34 let result;
35 if (modality === "gym") result = await scaffoldGym(rootId, userId);
36 else if (modality === "running") result = await scaffoldRunning(rootId, userId);
37 else if (modality === "home") result = await scaffoldHome(rootId, userId);
38 else return { content: [{ type: "text", text: `Unknown modality: ${modality}` }] };
39 return { content: [{ type: "text", text: `Created ${result.name} branch (${result.id})` }] };
40 } catch (err) {
41 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
42 }
43 },
44 },
45 {
46 name: "fitness-add-group",
47 description: "Add a group (muscle group, category, or activity type) under a modality branch.",
48 schema: {
49 parentId: z.string().describe("Parent node ID (modality branch like Gym or Home)."),
50 name: z.string().describe("Group name (e.g. Chest, Push, Morning Routine)."),
51 userId: z.string().describe("Injected by server. Ignore."),
52 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
53 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
54 },
55 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
56 handler: async ({ parentId, name, userId }) => {
57 try {
58 const result = await addGroupNode({ parentId, name, userId });
59 return { content: [{ type: "text", text: `Created group "${result.name}" (${result.id})` }] };
60 } catch (err) {
61 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
62 }
63 },
64 },
65 {
66 name: "fitness-add-exercise",
67 description:
68 "Add an exercise node under a group. Sets the tracking type, initial values, goals, and progression rules. " +
69 "exerciseType: 'weight-reps' for gym lifts, 'reps' for bodyweight, 'duration' for holds/planks, 'distance-time' for running.",
70 schema: {
71 groupId: z.string().describe("Parent group node ID."),
72 name: z.string().describe("Exercise name (e.g. Bench Press, Push-ups, Plank)."),
73 exerciseType: z.enum(["weight-reps", "reps", "duration", "distance-time"]).default("weight-reps")
74 .describe("How this exercise is tracked."),
75 unit: z.string().optional().describe("Unit: lb, kg, bodyweight, seconds, minutes, miles, km."),
76 sets: z.number().optional().describe("Number of tracked sets (for weight-reps and reps types)."),
77 startingValues: z.record(z.number()).optional()
78 .describe("Initial values object (e.g. {weight: 135, set1: 0, set2: 0, set3: 0})."),
79 goals: z.record(z.number()).optional()
80 .describe("Goal values object (e.g. {set1: 12, set2: 12, set3: 12})."),
81 progressionIncrement: z.record(z.number()).optional()
82 .describe("How much to increase on goal met (e.g. {weight: 5} or {duration: 10})."),
83 progressionPath: z.array(z.string()).optional()
84 .describe("Variation progression for bodyweight (e.g. ['standard', 'diamond', 'archer'])."),
85 rootId: z.string().describe("Fitness root node ID (for channel creation)."),
86 userId: z.string().describe("Injected by server. Ignore."),
87 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
88 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
89 },
90 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
91 handler: async ({ groupId, name, exerciseType, unit, sets, startingValues, goals, progressionIncrement, progressionPath, rootId, userId }) => {
92 try {
93 const result = await addExerciseNode({
94 groupId, name, exerciseType, unit, sets,
95 startingValues, goals, progressionIncrement, progressionPath,
96 rootId, userId,
97 });
98 return { content: [{ type: "text", text: `Created exercise "${result.name}" (${result.id})` }] };
99 } catch (err) {
100 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
101 }
102 },
103 },
104 {
105 name: "fitness-remove-exercise",
106 description: "Remove an exercise node from the tree.",
107 schema: {
108 exerciseId: z.string().describe("Exercise node ID to remove."),
109 userId: z.string().describe("Injected by server. Ignore."),
110 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
111 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
112 },
113 annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
114 handler: async ({ exerciseId, userId }) => {
115 try {
116 const ok = await removeExerciseNode(exerciseId, userId);
117 return { content: [{ type: "text", text: ok ? "Exercise removed." : "Failed to remove exercise." }] };
118 } catch (err) {
119 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
120 }
121 },
122 },
123 {
124 name: "fitness-complete-setup",
125 description: "Mark fitness setup as complete after all modalities, groups, and exercises have been created.",
126 schema: {
127 rootId: z.string().describe("Fitness root node ID."),
128 userId: z.string().describe("Injected by server. Ignore."),
129 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
130 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
131 },
132 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
133 handler: async ({ rootId }) => {
134 try {
135 await completeSetup(rootId);
136 return { content: [{ type: "text", text: "Fitness setup complete. Ready to track workouts." }] };
137 } catch (err) {
138 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
139 }
140 },
141 },
142 {
143 name: "fitness-save-profile",
144 description: "Save the user's fitness profile (units, weekly goal, modalities, etc.).",
145 schema: {
146 rootId: z.string().describe("Fitness root node ID."),
147 profile: z.object({
148 weightUnit: z.enum(["lb", "kg"]).optional(),
149 distanceUnit: z.enum(["miles", "km"]).optional(),
150 sessionsPerWeek: z.number().optional(),
151 modalities: z.array(z.string()).optional(),
152 weightIncrement: z.number().optional(),
153 weeklyMilesGoal: z.number().optional(),
154 }).describe("Profile settings."),
155 userId: z.string().describe("Injected by server. Ignore."),
156 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
157 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
158 },
159 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
160 handler: async ({ rootId, profile }) => {
161 try {
162 await saveProfile(rootId, profile);
163 return { content: [{ type: "text", text: "Profile saved." }] };
164 } catch (err) {
165 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
166 }
167 },
168 },
169 {
170 name: "fitness-adopt-exercise",
171 description:
172 "Adopt an existing node into the fitness tree as a tracked exercise. " +
173 "Use when you see unadopted child nodes that should be tracked. " +
174 "Sets the exercise type, unit, and optionally goals on the node.",
175 schema: {
176 nodeId: z.string().describe("The node ID to adopt as an exercise."),
177 exerciseType: z.enum(["weight-reps", "reps", "duration", "distance-time"]).default("weight-reps")
178 .describe("How this exercise is tracked."),
179 unit: z.string().optional().describe("Unit: lb, kg, bodyweight, seconds, minutes, miles, km."),
180 goals: z.record(z.number()).optional().describe("Optional goal values."),
181 userId: z.string().describe("Injected by server. Ignore."),
182 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
183 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
184 },
185 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
186 handler: async ({ nodeId, exerciseType, unit, goals }) => {
187 try {
188 await adoptExercise(nodeId, { exerciseType, unit, goals });
189 return { content: [{ type: "text", text: `Adopted as ${exerciseType} exercise.${unit ? ` Unit: ${unit}.` : ""} It will now appear in workout tracking and the dashboard.` }] };
190 } catch (err) {
191 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
192 }
193 },
194 },
195 {
196 name: "fitness-log-workout",
197 description:
198 "Log a workout. Delivers data to exercise nodes, records session history, tracks PRs, " +
199 "and detects progression. Call this ONCE after parsing the workout into structured data. " +
200 "Pass the exercises array from your parsed JSON output.",
201 schema: {
202 rootId: z.string().describe("Fitness root node ID."),
203 exercises: z.array(z.object({
204 modality: z.enum(["gym", "running", "home"]).describe("Exercise modality."),
205 name: z.string().describe("Exercise name."),
206 group: z.string().optional().describe("Muscle group (gym exercises)."),
207 sets: z.array(z.object({
208 weight: z.number().optional(),
209 reps: z.number().optional(),
210 duration: z.number().optional(),
211 unit: z.string().optional(),
212 }).passthrough()).optional().describe("Sets data for gym/home exercises."),
213 distance: z.number().optional().describe("Distance for running."),
214 distanceUnit: z.string().optional().describe("Distance unit (miles/km)."),
215 duration: z.number().optional().describe("Duration in seconds for running or holds."),
216 pace: z.number().optional().describe("Pace in seconds per unit for running."),
217 type: z.string().optional().describe("Run type: easy, tempo, intervals, race."),
218 variation: z.string().optional().describe("Bodyweight variation name."),
219 }).passthrough()).describe("Parsed exercises from workout input."),
220 date: z.string().optional().describe("Workout date (YYYY-MM-DD). Defaults to today."),
221 userId: z.string().describe("Injected by server. Ignore."),
222 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
223 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
224 },
225 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
226 handler: async (args) => {
227 try {
228 const { rootId, exercises, date, userId, chatId, sessionId } = args;
229 const fitnessNodes = await findFitnessNodes(rootId);
230 if (!fitnessNodes) return { content: [{ type: "text", text: "Fitness tree not found." }] };
231
232 const parsed = {
233 exercises,
234 date: date || new Date().toISOString().slice(0, 10),
235 _userId: userId,
236 _rootId: rootId,
237 };
238
239 // Deliver to exercise nodes (updates values, history, PRs)
240 const delivered = await deliverToExerciseNodes(fitnessNodes, parsed);
241
242 // Record session to History node
243 const historyNodeId = fitnessNodes.history?.id;
244 const record = await recordSessionHistory(historyNodeId, parsed, delivered, userId, { chatId, sessionId });
245
246 // Build human-readable summary
247 const { lines, summary } = buildWorkoutSummary(parsed, delivered);
248
249 // Check progression on each delivered exercise
250 const progressionAlerts = [];
251 if (delivered?.length > 0) {
252 const Node = (await import("../../seed/models/node.js")).default;
253 for (const d of delivered) {
254 if (!d.nodeId) continue;
255 try {
256 const node = await Node.findById(d.nodeId).select("metadata").lean();
257 if (!node) continue;
258 const prog = checkProgression(node);
259 if (prog?.allGoalsMet && prog.suggestion) {
260 progressionAlerts.push(`${d.exercise.name}: All goals met. ${prog.suggestion}`);
261 }
262 } catch {}
263 }
264 }
265
266 const parts = [summary];
267 if (progressionAlerts.length > 0) {
268 parts.push("PROGRESSION: " + progressionAlerts.join(". "));
269 }
270 if (delivered?.length === 0) {
271 parts.push("No matching exercises found in tree. Exercises may need to be added via setup first.");
272 }
273
274 return { content: [{ type: "text", text: parts.join("\n") }] };
275 } catch (err) {
276 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
277 }
278 },
279 },
280 ];
281}
282
Loading comments...