EXTENSION for TreeOS
fitness
Multi-modality workout tracking. Three languages: gym (weight x reps x sets), running (distance x time x pace), bodyweight (reps x sets or duration). One extension, one LLM call detects modality and parses. The tree structure defines what exercises exist. Gym bro, marathon runner, and someone doing pushups in their apartment all use the same command. Progressive overload tracked per modality: weight goes up for gym, mileage increases for running, harder variations for bodyweight. Four modes: log (universal parser), coach (guided sessions), review (cross-modality analysis), plan (program creation). Channels route logged data to exercise nodes. Food channel integrates nutrition awareness. Type 'be' at the Fitness tree to start a guided workout: the coach walks you through today's program set by set.
v3.0.3 by TreeOS Site 0 downloads 14 files 2,494 lines 101.1 KB published 37d ago
treeos ext install fitness
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks, metadata
  • models: Node, Note

Optional

  • services: llm
  • extensions: values, channels, breath, schedules, scheduler, food, notifications, phase, treeos-base, html-rendering
SHA256: 6d566961337af12a6b91233bf1c3ff2647b51481560923d9a83f212a51eb3081

CLI Commands

CommandMethodDescription
fitness [message...]POSTLog any workout, start a guided session, or ask about progress.

Hooks

Listens To

  • enrichContext
  • onCascade
  • afterBoot

Source Code

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

Versions

Version Published Downloads
3.0.3 37d ago 0
3.0.2 38d ago 0
3.0.1 46d ago 0
3.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star fitness

Comments

Loading comments...

Post comments from the CLI: treeos ext comment fitness "your comment"
Max 3 comments per extension. One star and one flag per user.