EXTENSION for TreeOS
food
Nutrition tracking through tree structure. Log node receives food input. One LLM call parses macros. Cascade routes to Protein, Carbs, Fats nodes. Meals subtree tracks patterns by slot (Breakfast, Lunch, Dinner, Snacks). Profile node holds goals and restrictions. History node archives daily summaries with weekly averages and hit rates. Three modes: food-log (parser), food-review (advisor with weekly patterns), food-coach (setup and goal setting). Fitness channel carries workout data both ways. Type 'be' at the Food tree to start logging: just say what you ate. The tree IS the app.
v2.1.2 by TreeOS Site 0 downloads 12 files 2,750 lines 106.8 KB published 37d ago
treeos ext install food
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 4 CLI commands

Requires

  • services: hooks, metadata
  • models: Node

Optional

  • services: llm
  • extensions: values, channels, breath, notifications, phase, schedules, html-rendering, treeos-base
SHA256: 223576f0e9a19f1eb7be6cb2596da9094aabe82d26ba2bc6a7183159ebdd6542

CLI Commands

CommandMethodDescription
food [message...]POSTLog food or ask about nutrition.
food-dailyGETToday's nutrition dashboard.
food-weekGETWeekly nutrition review.
food-profileGETDietary profile and goals.

Hooks

Listens To

  • enrichContext
  • onCascade
  • breath:exhale

Source Code

1/**
2 * Food Core
3 *
4 * Parse food input, scaffold tree structure, deliver cascade signals,
5 * daily reset, and macro reading. The tree does the orchestration.
6 */
7
8import log from "../../seed/log.js";
9import { setNodeMode } from "../../seed/modes/registry.js";
10import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
11
12// ── Dependencies (set by configure) ──
13
14let _Node = null;
15let _runChat = null;
16let _metadata = null;
17let _Note = null;
18
19export function configure({ Node, Note, runChat, metadata }) {
20  _Node = Node;
21  _Note = Note;
22  _runChat = runChat;
23  _metadata = metadata;
24}
25
26// ── Food node roles ──
27
28const ROLES = {
29  LOG: "log", PROTEIN: "protein", CARBS: "carbs", FATS: "fats",
30  DAILY: "daily", MEALS: "meals", PROFILE: "profile", HISTORY: "history",
31  ARCHIVE: "archive",
32};
33
34// Roles that are structural (not metrics). Used everywhere to distinguish trackable nodes.
35export const STRUCTURAL_ROLES = ["log", "daily", "meals", "profile", "history", "archive", "mealSlots", "_unadopted"];
36
37// ── Tree scaffold ──
38
39/**
40 * Create the food tree structure under a root node.
41 * Returns the node IDs for each role.
42 */
43export async function scaffold(foodRootId, userId) {
44  if (!_Node) throw new Error("Food core not configured");
45
46  const { createNode } = await import("../../seed/tree/treeManagement.js");
47
48  // Create core child nodes
49  const logNode = await createNode({ name: "Log", parentId: foodRootId, userId });
50  const proteinNode = await createNode({ name: "Protein", parentId: foodRootId, userId });
51  const carbsNode = await createNode({ name: "Carbs", parentId: foodRootId, userId });
52  const fatsNode = await createNode({ name: "Fats", parentId: foodRootId, userId });
53  const dailyNode = await createNode({ name: "Daily", parentId: foodRootId, userId });
54  const profileNode = await createNode({ name: "Profile", parentId: foodRootId, userId });
55  const historyNode = await createNode({ name: "History", parentId: foodRootId, userId });
56
57  // Create Meals subtree for pattern tracking
58  const mealsNode = await createNode({ name: "Meals", parentId: foodRootId, userId });
59  const breakfastNode = await createNode({ name: "Breakfast", parentId: mealsNode._id, userId });
60  const lunchNode = await createNode({ name: "Lunch", parentId: mealsNode._id, userId });
61  const dinnerNode = await createNode({ name: "Dinner", parentId: mealsNode._id, userId });
62  const snacksNode = await createNode({ name: "Snacks", parentId: mealsNode._id, userId });
63
64  // Tag each node with its food role
65  const nodes = [
66    { node: logNode, role: ROLES.LOG },
67    { node: proteinNode, role: ROLES.PROTEIN },
68    { node: carbsNode, role: ROLES.CARBS },
69    { node: fatsNode, role: ROLES.FATS },
70    { node: dailyNode, role: ROLES.DAILY },
71    { node: profileNode, role: ROLES.PROFILE },
72    { node: historyNode, role: ROLES.HISTORY },
73    { node: mealsNode, role: ROLES.MEALS },
74  ];
75
76  for (const { node, role } of nodes) {
77    await _metadata.setExtMeta(node, "food", { role });
78  }
79
80  // Tag meal slot children
81  for (const [node, slot] of [[breakfastNode, "breakfast"], [lunchNode, "lunch"], [dinnerNode, "dinner"], [snacksNode, "snack"]]) {
82    await _metadata.setExtMeta(node, "food", { role: "meal", mealSlot: slot });
83  }
84
85  // Set mode overrides
86  await setNodeMode(foodRootId, "respond", "tree:food-coach");
87  await setNodeMode(logNode._id, "respond", "tree:food-log");
88  await setNodeMode(dailyNode._id, "respond", "tree:food-review");
89
90  // Create channels: Log -> each macro node
91  try {
92    const { getExtension } = await import("../loader.js");
93    const channelsExt = getExtension("channels");
94    if (channelsExt?.exports?.createChannel) {
95      const create = channelsExt.exports.createChannel;
96      await create({ sourceNodeId: String(logNode._id), targetNodeId: String(proteinNode._id), channelName: "protein-log", direction: "outbound", filter: { tags: ["protein"] }, userId });
97      await create({ sourceNodeId: String(logNode._id), targetNodeId: String(carbsNode._id), channelName: "carbs-log", direction: "outbound", filter: { tags: ["carbs"] }, userId });
98      await create({ sourceNodeId: String(logNode._id), targetNodeId: String(fatsNode._id), channelName: "fats-log", direction: "outbound", filter: { tags: ["fats"] }, userId });
99      log.info("Food", "Channels created: protein-log, carbs-log, fats-log");
100    } else {
101      log.warn("Food", "Channels extension not available. Cascade routing will use direct delivery.");
102    }
103  } catch (err) {
104    log.warn("Food", `Channel creation failed: ${err.message}. Using direct delivery.`);
105  }
106
107  // Mark root as initialized (base phase: scaffold done, profile not yet set)
108  const rootNode = await _Node.findById(foodRootId);
109  if (rootNode) {
110    await _metadata.setExtMeta(rootNode, "food", { initialized: true, setupPhase: "scaffolded" });
111  }
112
113  const ids = {
114    log: String(logNode._id),
115    protein: String(proteinNode._id),
116    carbs: String(carbsNode._id),
117    fats: String(fatsNode._id),
118    daily: String(dailyNode._id),
119  };
120
121  // ── Fitness-Food channel ──
122  // If fitness is a sibling (same parent tree), wire a bidirectional channel
123  // so the food AI sees workouts and the fitness AI sees nutrition.
124  try {
125    const rootDoc = await _Node.findById(foodRootId).select("parent").lean();
126    if (rootDoc?.parent) {
127      const siblings = await _Node.find({ parent: rootDoc.parent }).select("_id metadata").lean();
128      for (const sib of siblings) {
129        const sibMeta = sib.metadata instanceof Map
130          ? sib.metadata.get("fitness")
131          : sib.metadata?.fitness;
132        if (sibMeta?.initialized) {
133          // Found fitness tree. Find its Log node.
134          const fitChildren = await _Node.find({ parent: sib._id }).select("_id metadata").lean();
135          const fitLog = fitChildren.find(c => {
136            const fm = c.metadata instanceof Map ? c.metadata.get("fitness") : c.metadata?.fitness;
137            return fm?.role === "log";
138          });
139          if (fitLog) {
140            const { getExtension } = await import("../loader.js");
141            const ch = getExtension("channels");
142            if (ch?.exports?.createChannel) {
143              await ch.exports.createChannel({
144                sourceNodeId: String(dailyNode._id),
145                targetNodeId: String(fitLog._id),
146                channelName: "food-fitness",
147                direction: "bidirectional",
148                filter: { tags: ["nutrition", "workout"] },
149                userId,
150              });
151              log.info("Food", "Channel created: food-fitness (bidirectional with Fitness/Log)");
152            }
153          }
154          break;
155        }
156      }
157    }
158  } catch (err) {
159    log.verbose("Food", `Fitness channel not created: ${err.message}`);
160  }
161
162  log.info("Food", `Scaffolded tree under ${foodRootId}: ${JSON.stringify(ids)}`);
163  return ids;
164}
165
166/**
167 * Find food child nodes by role under a root.
168 */
169export async function findFoodNodes(foodRootId) {
170  if (!_Node) return null;
171  const children = await _Node.find({ parent: foodRootId }).select("_id name metadata").lean();
172  const result = {};
173  for (const child of children) {
174    const meta = child.metadata instanceof Map
175      ? child.metadata.get("food")
176      : child.metadata?.food;
177    if (meta?.role) {
178      result[meta.role] = { id: String(child._id), name: child.name };
179    } else {
180      // Skip. Nodes without food.role are not food's concern.
181      // The user adds metrics through the dashboard or by asking the AI.
182    }
183  }
184  // Find meal slot children under Meals node
185  if (result.meals) {
186    const mealChildren = await _Node.find({ parent: result.meals.id }).select("_id name metadata").lean();
187    result.mealSlots = {};
188    for (const mc of mealChildren) {
189      const meta = mc.metadata instanceof Map ? mc.metadata.get("food") : mc.metadata?.food;
190      if (meta?.mealSlot) result.mealSlots[meta.mealSlot] = { id: String(mc._id), name: mc.name };
191    }
192  }
193  return result;
194}
195
196/**
197 * Adopt a node into the food tree as a tracked metric.
198 * Sets metadata.food.role and optionally a daily goal.
199 */
200export async function adoptNode(nodeId, role, goal) {
201  if (!_metadata || !_Node) throw new Error("Metadata service not configured");
202  const node = await _Node.findById(nodeId);
203  if (!node) throw new Error("Node not found");
204  await _metadata.setExtMeta(node, "food", { role });
205  if (goal != null && goal > 0) {
206    await _metadata.setExtMeta(node, "goals", { today: goal });
207  }
208  log.info("Food", `Adopted node ${String(nodeId).slice(0, 8)} as "${role}"${goal ? ` (goal: ${goal}g)` : ""}`);
209}
210
211/**
212 * Check if the food tree is initialized.
213 */
214export async function isInitialized(foodRootId) {
215  if (!_Node) return false;
216  const root = await _Node.findById(foodRootId).select("metadata").lean();
217  if (!root) return false;
218  const meta = root.metadata instanceof Map
219    ? root.metadata.get("food")
220    : root.metadata?.food;
221  return !!meta?.initialized;
222}
223
224export async function getSetupPhase(foodRootId) {
225  if (!_Node) return null;
226  const root = await _Node.findById(foodRootId).select("metadata").lean();
227  if (!root) return null;
228  const meta = root.metadata instanceof Map
229    ? root.metadata.get("food")
230    : root.metadata?.food;
231  return meta?.setupPhase || (meta?.initialized ? "scaffolded" : null);
232}
233
234// ── Food parsing ──
235
236// ── Meal slot detection ──
237
238/**
239 * Determine which meal slot a food entry belongs to.
240 * Keyword override takes priority over time-based detection.
241 */
242export function detectMealSlot(message, when) {
243  if (when) {
244    const w = when.toLowerCase();
245    if (w === "breakfast") return "breakfast";
246    if (w === "lunch") return "lunch";
247    if (w === "dinner" || w === "supper") return "dinner";
248    if (w === "snack") return "snack";
249  }
250  const lower = (message || "").toLowerCase();
251  if (/\bbreakfast\b/.test(lower)) return "breakfast";
252  if (/\blunch\b/.test(lower)) return "lunch";
253  if (/\b(dinner|supper)\b/.test(lower)) return "dinner";
254  if (/\bsnack\b/.test(lower)) return "snack";
255  // Time-based fallback
256  const hour = new Date().getHours();
257  if (hour < 11) return "breakfast";
258  if (hour < 14) return "lunch";
259  if (hour < 17) return "snack";
260  return "dinner";
261}
262
263/**
264 * Write a meal note to the appropriate Meals/{slot} child node.
265 */
266export async function writeMealNote(foodNodes, mealSlot, summary, userId, ctx = {}) {
267  if (!foodNodes?.mealSlots?.[mealSlot]) return;
268  try {
269    const { createNote } = await import("../../seed/tree/notes.js");
270    await createNote({
271      nodeId: foodNodes.mealSlots[mealSlot].id,
272      content: summary,
273      contentType: "text",
274      userId,
275      wasAi: ctx.chatId != null || ctx.wasAi === true,
276      chatId: ctx.chatId ?? null,
277      sessionId: ctx.sessionId ?? null,
278    });
279  } catch (err) {
280    log.debug("Food", `Meal note write failed: ${err.message}`);
281  }
282}
283
284/**
285 * Parse food input into structured macros via one LLM call.
286 */
287export async function parseFood(message, userId, username, rootId) {
288  if (!_runChat) throw new Error("LLM not configured");
289
290  const { answer } = await _runChat({
291    userId,
292    username,
293    message,
294    mode: "tree:food-log",
295    rootId,
296    slot: "food",
297  });
298
299  if (!answer) return null;
300
301  const parsed = parseJsonSafe(answer);
302  if (!parsed?.items?.length) {
303    log.warn("Food", `Parse returned no items from: "${message}"`);
304    return null;
305  }
306
307  // Ensure totals exist (sum all numeric fields from items dynamically)
308  if (!parsed.totals) {
309    parsed.totals = {};
310    for (const item of parsed.items) {
311      for (const [key, val] of Object.entries(item)) {
312        if (key === "name" || typeof val !== "number") continue;
313        parsed.totals[key] = (parsed.totals[key] || 0) + val;
314      }
315    }
316  }
317
318  return parsed;
319}
320
321// ── Cascade delivery ──
322
323/**
324 * Deliver macro totals to tracking nodes. Direct atomic increment only.
325 * One path, one write per metric, no double-counting.
326 */
327export async function deliverMacros(logNodeId, foodNodes, parsed) {
328  const { totals } = parsed;
329  if (!foodNodes || !totals) return;
330
331  for (const [role, info] of Object.entries(foodNodes)) {
332    if (STRUCTURAL_ROLES.includes(role) || !info?.id) continue;
333    const amount = totals[role] || 0;
334    if (amount > 0) {
335      await _metadata.incExtMeta(info.id, "values", "today", amount);
336    }
337  }
338}
339
340/**
341 * Handle an incoming cascade signal at a macro node.
342 * Called from the onCascade hook when a food signal arrives.
343 * Increments the today value atomically.
344 */
345export async function handleMacroCascade(node, payload) {
346  if (!_Node) return;
347  const meta = node.metadata instanceof Map
348    ? node.metadata.get("food")
349    : node.metadata?.food;
350  if (!meta?.role) return;
351
352  const role = meta.role;
353  const STRUCTURAL = STRUCTURAL_ROLES;
354  if (STRUCTURAL.includes(role)) return;
355
356  // Match cascade payload key to node role (protein->protein, sugar->sugar, etc.)
357  const amount = payload[role] || 0;
358
359  if (amount <= 0) return;
360
361  await _metadata.incExtMeta(node, "values", "today", amount);
362
363  log.verbose("Food", `${role}: +${amount}g (node ${String(node._id).slice(0, 8)}...)`);
364}
365
366// ── Daily reset ──
367
368// Track last reset date per root to avoid double resets
369const lastReset = new Map(); // rootId -> "YYYY-MM-DD"
370
371/**
372 * Check if a daily reset is needed and perform it.
373 * Archives yesterday's totals as a note on the History node.
374 * Calculates weekly averages. Resets values.today to 0 on each macro node.
375 */
376export async function checkDailyReset(rootId) {
377  if (!_Node) return;
378
379  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
380  if (lastReset.get(rootId) === today) return;
381
382  // Check persisted reset date (survives server restarts)
383  const root = await _Node.findById(rootId).select("metadata").lean();
384  const foodMeta = root?.metadata instanceof Map ? root.metadata.get("food") : root?.metadata?.food;
385  if (foodMeta?.lastResetDate === today) {
386    lastReset.set(rootId, today);
387    log.debug("Food", `Daily reset skipped (already reset today) for ${String(rootId).slice(0, 8)}`);
388    return;
389  }
390  log.verbose("Food", `Daily reset firing for ${String(rootId).slice(0, 8)} (lastResetDate=${foodMeta?.lastResetDate}, today=${today})`);
391
392  const foodNodes = await findFoodNodes(rootId);
393  if (!foodNodes) return;
394
395  // Discover all metric roles (anything that isn't structural)
396  const metricRoles = Object.keys(foodNodes).filter(r => !STRUCTURAL_ROLES.includes(r) && foodNodes[r]?.id);
397  if (metricRoles.length === 0) return;
398
399  // Read current totals and goals for all metric nodes
400  const macros = {};
401  const goals = {};
402  for (const role of metricRoles) {
403    const node = await _Node.findById(foodNodes[role].id).select("metadata").lean();
404    if (!node) continue;
405    const values = node.metadata instanceof Map ? node.metadata.get("values") : node.metadata?.values;
406    const goalMeta = node.metadata instanceof Map ? node.metadata.get("goals") : node.metadata?.goals;
407    macros[role] = values?.today || 0;
408    goals[role] = goalMeta?.today || 0;
409  }
410
411  const hadData = metricRoles.some(r => macros[r] > 0);
412
413  // Archive yesterday's totals as a note on the History node
414  if (hadData && foodNodes.history) {
415    try {
416      const date = lastReset.get(rootId) || new Date(Date.now() - 86400000).toISOString().slice(0, 10);
417      const p = macros.protein || 0, c = macros.carbs || 0, f = macros.fats || 0;
418      const calories = (p * 4) + (c * 4) + (f * 9);
419      const summary = { type: "daily", date, calories };
420      // Include all tracked metrics and their hit status
421      for (const role of metricRoles) {
422        summary[role] = macros[role] || 0;
423        if (goals[role] > 0) {
424          summary[`hit${role.charAt(0).toUpperCase() + role.slice(1)}Goal`] = macros[role] >= goals[role];
425        }
426      }
427      const { createNote } = await import("../../seed/tree/notes.js");
428      await createNote({
429        nodeId: foodNodes.history.id,
430        content: JSON.stringify(summary),
431        contentType: "text",
432        userId: "SYSTEM",
433      });
434    } catch (err) {
435      log.debug("Food", `History note write failed: ${err.message}`);
436    }
437  }
438
439  // Reset all metric nodes and calculate weekly averages
440  const resetMetrics = async (withAverages, days) => {
441    for (const role of metricRoles) {
442      if (!foodNodes[role]) continue;
443      if (withAverages && days?.length > 0) {
444        const avg = Math.round(days.reduce((s, d) => s + (d[role] || 0), 0) / days.length);
445        const hitKey = `hit${role.charAt(0).toUpperCase() + role.slice(1)}Goal`;
446        const hitCount = days.filter(d => d[hitKey]).length;
447        const hitRate = Math.round((hitCount / days.length) * 100) / 100;
448        await _metadata.batchSetExtMeta(foodNodes[role].id, "values", { today: 0, weeklyAvg: avg, weeklyHitRate: hitRate });
449      } else {
450        await _metadata.batchSetExtMeta(foodNodes[role].id, "values", { today: 0 });
451      }
452    }
453  };
454
455  if (foodNodes.history && _Note) {
456    try {
457      const recentNotes = await _Note.find({ nodeId: foodNodes.history.id })
458        .sort({ createdAt: -1 }).limit(7).select("content").lean();
459      const days = recentNotes.map(n => { try { return JSON.parse(n.content); } catch { return null; } }).filter(Boolean);
460      await resetMetrics(days.length > 0, days);
461    } catch (err) {
462      log.debug("Food", `Weekly average calculation failed: ${err.message}`);
463      await resetMetrics(false);
464    }
465  } else {
466    await resetMetrics(false);
467  }
468
469  // Clear meal slot notes (Breakfast, Lunch, Dinner, Snacks).
470  // These are ephemeral "today's meals" buckets. The data is already archived
471  // in the History node and the Log node. Meal slots empty each day.
472  if (foodNodes.mealSlots && _Note) {
473    for (const [slot, info] of Object.entries(foodNodes.mealSlots)) {
474      if (!info?.id) continue;
475      try {
476        await _Note.deleteMany({ nodeId: info.id });
477        log.verbose("Food", `  Cleared meal slot: ${slot}`);
478      } catch (err) {
479        log.debug("Food", `  Failed to clear ${slot}: ${err.message}`);
480      }
481    }
482  }
483
484  lastReset.set(rootId, today);
485
486  // Persist reset date so server restarts don't re-zero today's data
487  try {
488    const rootNode = await _Node.findById(rootId);
489    if (rootNode) {
490      const existing = _metadata.getExtMeta(rootNode, "food") || {};
491      await _metadata.setExtMeta(rootNode, "food", { ...existing, lastResetDate: today });
492      log.verbose("Food", `Persisted lastResetDate=${today} for ${String(rootId).slice(0, 8)}`);
493
494      // Weekly summary: write a rollup note every 7 days
495      const lastWeekly = existing.lastWeeklySummaryDate || null;
496      const daysSinceWeekly = lastWeekly
497        ? Math.floor((new Date(today) - new Date(lastWeekly)) / 86400000)
498        : 8; // force first weekly if never written
499      if (daysSinceWeekly >= 7 && foodNodes.history && _Note) {
500        try {
501          const weekNotes = await _Note.find({ nodeId: foodNodes.history.id })
502            .sort({ createdAt: -1 }).limit(7).select("content").lean();
503          const weekDays = weekNotes
504            .map(n => { try { return JSON.parse(n.content); } catch { return null; } })
505            .filter(d => d && d.type !== "weekly");
506          if (weekDays.length >= 3) {
507            const count = weekDays.length;
508            const averages = {};
509            const hitRates = {};
510            for (const role of metricRoles) {
511              const sum = weekDays.reduce((s, d) => s + (d[role] || 0), 0);
512              averages[role] = Math.round(sum / count);
513              const hitKey = `hit${role.charAt(0).toUpperCase() + role.slice(1)}Goal`;
514              const hitCount = weekDays.filter(d => d[hitKey]).length;
515              hitRates[role] = Math.round((hitCount / count) * 100) / 100;
516            }
517            const avgP = averages.protein || 0, avgC = averages.carbs || 0, avgF = averages.fats || 0;
518            const avgCal = (avgP * 4) + (avgC * 4) + (avgF * 9);
519            const totalCal = weekDays.reduce((s, d) => s + (d.calories || 0), 0);
520            const weekStart = weekDays[weekDays.length - 1]?.date || lastWeekly || today;
521            const weekEnd = weekDays[0]?.date || today;
522            const weeklySummary = {
523              type: "weekly", weekStart, weekEnd,
524              daysTracked: count,
525              averages, hitRates,
526              calories: { avg: avgCal, total: totalCal },
527            };
528            const { createNote } = await import("../../seed/tree/notes.js");
529            await createNote({
530              nodeId: foodNodes.history.id,
531              content: JSON.stringify(weeklySummary),
532              contentType: "text",
533              userId: "SYSTEM",
534            });
535            await _metadata.setExtMeta(rootNode, "food", {
536              ...existing, lastResetDate: today, lastWeeklySummaryDate: today,
537            });
538            log.verbose("Food", `Weekly summary written for ${String(rootId).slice(0, 8)} (${count} days, avg ${avgCal} cal)`);
539          }
540        } catch (err) {
541          log.debug("Food", `Weekly summary write failed: ${err.message}`);
542        }
543      }
544    }
545  } catch (err) {
546    log.warn("Food", `Failed to persist reset date: ${err.message}`);
547  }
548
549  if (hadData) {
550    const macroStr = metricRoles.map(r => `${r}:${macros[r] || 0}`).join(" ");
551    log.verbose("Food", `Daily reset for ${rootId.slice(0, 8)}... (${macroStr})`);
552  }
553}
554
555// ── Reading current state ──
556
557/**
558 * Read the full daily picture for a food tree.
559 * Used by enrichContext on the Daily node and by the daily mode.
560 */
561export async function getDailyPicture(foodRootId, { historyDays = 7 } = {}) {
562  if (!_Node) return null;
563
564  const foodNodes = await findFoodNodes(foodRootId);
565  if (!foodNodes) return null;
566
567  const CORE_MACROS = ["protein", "carbs", "fats"];
568  // Uses module-level STRUCTURAL_ROLES
569  const picture = {};
570
571  // Discover all metric nodes (core macros + any user-created ones like sugar, fiber)
572  // Structural roles (log, daily, meals, etc.) are skipped. Everything else is a metric.
573  const valueRoles = [];
574  for (const [role, info] of Object.entries(foodNodes)) {
575    if (role === "mealSlots" || !info?.id || STRUCTURAL_ROLES.includes(role)) continue;
576    const node = await _Node.findById(info.id).select("metadata").lean();
577    if (!node) continue;
578    const values = node.metadata instanceof Map ? node.metadata.get("values") : node.metadata?.values;
579    const goals = node.metadata instanceof Map ? node.metadata.get("goals") : node.metadata?.goals;
580    picture[role] = {
581      today: values?.today || 0,
582      goal: goals?.today || 0,
583      weeklyAvg: values?.weeklyAvg || 0,
584      weeklyHitRate: values?.weeklyHitRate || 0,
585      name: info.name,
586      nodeId: info.id,
587      isCoreMacro: CORE_MACROS.includes(role),
588    };
589    valueRoles.push(role);
590  }
591
592  // Calculate calories from core macros (protein*4 + carbs*4 + fats*9)
593  const p = picture.protein || {};
594  const c = picture.carbs || {};
595  const f = picture.fats || {};
596  picture.calories = {
597    today: ((p.today || 0) * 4) + ((c.today || 0) * 4) + ((f.today || 0) * 9),
598    goal: ((p.goal || 0) * 4) + ((c.goal || 0) * 4) + ((f.goal || 0) * 9),
599  };
600  picture._valueRoles = valueRoles;
601
602  // Get profile from Profile node
603  if (foodNodes.profile && _Note) {
604    try {
605      const profileNote = await _Note.findOne({ nodeId: foodNodes.profile.id })
606        .sort({ createdAt: -1 })
607        .select("content")
608        .lean();
609      if (profileNote?.content) {
610        try { picture.profile = JSON.parse(profileNote.content); } catch { picture.profile = null; }
611      }
612    } catch {}
613  }
614  // Fallback: read from root metadata (legacy)
615  if (!picture.profile) {
616    const root = await _Node.findById(foodRootId).select("metadata").lean();
617    const foodMeta = root?.metadata instanceof Map ? root.metadata.get("food") : root?.metadata?.food;
618    if (foodMeta?.profile) picture.profile = foodMeta.profile;
619  }
620
621  // Get history from History node notes
622  if (foodNodes.history) {
623    try {
624      const Note = _Note || (await import("../../seed/models/note.js")).default;
625      const historyNotes = await Note.find({ nodeId: foodNodes.history.id })
626        .sort({ createdAt: -1 })
627        .limit(historyDays)
628        .select("content")
629        .lean();
630      picture.recentHistory = historyNotes
631        .map(n => { try { return JSON.parse(n.content); } catch { return null; } })
632        .filter(Boolean);
633    } catch {}
634  }
635
636  // Get recent meals from Log node
637  if (foodNodes.log) {
638    try {
639      const Note = _Note || (await import("../../seed/models/note.js")).default;
640      const recentNotes = await Note.find({ nodeId: foodNodes.log.id })
641        .sort({ createdAt: -1 })
642        .limit(10)
643        .select("content createdAt")
644        .lean();
645      picture.recentMeals = recentNotes.map(n => {
646        let text = typeof n.content === "string" ? n.content.slice(0, 200) : "";
647        let totals = null;
648        // Try to parse structured content (new format)
649        try {
650          const parsed = JSON.parse(n.content);
651          if (parsed.meal) {
652            const parts = Object.entries(parsed.totals || {})
653              .filter(([k, v]) => typeof v === "number" && k !== "calories")
654              .map(([k, v]) => `${v}${k.charAt(0)}`);
655            if (parsed.totals?.calories) parts.push(`${parsed.totals.calories}cal`);
656            text = `${parsed.meal} (${parts.join("/")})`;
657            totals = parsed.totals;
658          }
659        } catch {}
660        return { id: String(n._id), text, date: n.createdAt, totals };
661      });
662    } catch (err) {
663      log.warn("Food", `Meal query failed: ${err.message}`);
664    }
665  }
666
667  // Get meals by slot (Breakfast, Lunch, Dinner, Snacks)
668  if (foodNodes.mealSlots) {
669    picture.mealsBySlot = {};
670    const Note = _Note || (await import("../../seed/models/note.js")).default;
671    for (const [slot, node] of Object.entries(foodNodes.mealSlots)) {
672      try {
673        const notes = await Note.find({ nodeId: node.id })
674          .sort({ createdAt: -1 })
675          .limit(5)
676          .select("content createdAt")
677          .lean();
678        if (notes.length > 0) {
679          picture.mealsBySlot[slot] = notes.map(n => {
680            let text = typeof n.content === "string" ? n.content.slice(0, 150) : "";
681            let logNoteId = null;
682            try {
683              const parsed = JSON.parse(n.content);
684              if (parsed.text) text = parsed.text;
685              if (parsed.logNoteId) logNoteId = parsed.logNoteId;
686            } catch {}
687            return { id: String(n._id), text, date: n.createdAt, logNoteId };
688          });
689        }
690      } catch {}
691    }
692  }
693
694  return picture;
695}
696
697// ── Setup helpers ──
698
699/**
700 * Save the user's food profile and set goals on macro nodes.
701 */
702export async function saveProfile(foodRootId, profile, foodNodes, userId) {
703  if (!_Node) return;
704
705  // Write profile as a note on the Profile node
706  if (foodNodes?.profile) {
707    try {
708      const { createNote } = await import("../../seed/tree/notes.js");
709      await createNote({
710        nodeId: foodNodes.profile.id,
711        content: JSON.stringify(profile),
712        contentType: "text",
713        userId: userId || "SYSTEM",
714      });
715    } catch (err) {
716      log.debug("Food", `Profile note write failed: ${err.message}`);
717    }
718  }
719
720  // Mark setup as complete (profile saved = setup done)
721  const root = await _Node.findById(foodRootId);
722  if (root) {
723    const existing = _metadata.getExtMeta(root, "food") || {};
724    await _metadata.setExtMeta(root, "food", { ...existing, setupPhase: "complete" });
725  }
726
727  // Set goals on all metric nodes that have a matching goal in the profile
728  // Supports both legacy keys (proteinGoal) and dynamic keys (sugarGoal, fiberGoal, etc.)
729  for (const [role, info] of Object.entries(foodNodes)) {
730    if (STRUCTURAL_ROLES.includes(role) || !info?.id) continue;
731    // Check for role-specific goal: proteinGoal, sugarGoal, etc.
732    const goalKey = `${role}Goal`;
733    const goal = profile[goalKey];
734    if (goal) {
735      await _metadata.batchSetExtMeta(info.id, "goals", { today: goal });
736      await _metadata.batchSetExtMeta(info.id, "values", { today: 0 });
737    }
738  }
739}
740
741// ── History queries ──
742
743/**
744 * Query history notes from the History node with optional type filter.
745 * Returns parsed JSON array sorted newest-first.
746 *   type: "daily" | "weekly" | null (all)
747 *   limit: max entries to return (default 90)
748 */
749export async function getHistory(foodRootId, { limit = 90, type = null } = {}) {
750  if (!_Node) return [];
751  const foodNodes = await findFoodNodes(foodRootId);
752  if (!foodNodes?.history) return [];
753
754  const Note = _Note || (await import("../../seed/models/note.js")).default;
755  const notes = await Note.find({ nodeId: foodNodes.history.id })
756    .sort({ createdAt: -1 })
757    .limit(limit * 2) // over-fetch to account for type filtering
758    .select("content createdAt")
759    .lean();
760
761  let entries = notes
762    .map(n => {
763      try { return JSON.parse(n.content); } catch { return null; }
764    })
765    .filter(Boolean);
766
767  if (type) {
768    // Backwards compat: notes without type field are treated as "daily"
769    entries = entries.filter(e => (e.type || "daily") === type);
770  }
771
772  return entries.slice(0, limit);
773}
774
1/**
2 * Food handleMessage: lock to coach mode during setup.
3 *
4 * Same pattern as fitness. The orchestrator calls this BEFORE suffix routing.
5 * Returning { mode } forces the orchestrator to use that mode.
6 *
7 * Why: until goals are configured (setupPhase = "complete"), every message
8 * should go to food-coach which has the food-save-profile tool and knows
9 * how to gather goals. Without this, suffix routing sends food-related
10 * messages to food-log which doesn't handle setup and the goals never save.
11 *
12 * Once setup is complete, return null and let suffix routing pick the right
13 * mode: "ate eggs" -> food-log, "how am i doing" -> food-review, etc.
14 */
15import Node from "../../seed/models/node.js";
16
17export async function handleMessage(message, { rootId, targetNodeId }) {
18  const startId = targetNodeId || rootId;
19  if (!startId) return null;
20
21  try {
22    // Walk up to find the food root (might be at the target node or an ancestor)
23    let current = await Node.findById(startId).select("_id parent metadata").lean();
24    let depth = 0;
25    let foodRoot = null;
26    while (current && depth < 20) {
27      const meta = current.metadata instanceof Map
28        ? current.metadata.get("food")
29        : current.metadata?.food;
30      if (meta?.initialized) {
31        foodRoot = { node: current, meta };
32        break;
33      }
34      if (!current.parent) break;
35      current = await Node.findById(current.parent).select("_id parent metadata").lean();
36      depth++;
37    }
38
39    if (!foodRoot) return null;
40
41    // Setup complete = let the orchestrator do its normal suffix routing
42    if (foodRoot.meta.setupPhase === "complete") return null;
43
44    // Setup in progress = force coach mode regardless of what the user said
45    return { mode: "tree:food-coach" };
46  } catch {
47    return null;
48  }
49}
50
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 { isInitialized, getDailyPicture, getHistory } from "./core.js";
7import { renderFoodDashboard } from "./pages/dashboard.js";
8
9const router = express.Router();
10
11router.get("/root/:rootId/food", 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, "Food tree not found");
16
17    if (!(await isInitialized(rootId))) {
18      return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not initialized");
19    }
20
21    const picture = await getDailyPicture(rootId);
22    const weeklySummaries = await getHistory(rootId, { limit: 8, type: "weekly" });
23
24    res.send(renderFoodDashboard({
25      rootId,
26      rootName: root.name,
27      picture,
28      weeklySummaries,
29      token: req.query.token || null,
30      userId: req.user?._id?.toString() || req.user?.id || null,
31    }));
32  } catch (err) {
33    sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
34  }
35});
36
37export default router;
38
1/**
2 * Food
3 *
4 * The tree IS the app. One node to talk to (Log), three nodes that count
5 * (Protein, Carbs, Fats), one node that sees the picture (Daily).
6 * Cascade routes macros. enrichContext assembles the view. The AI reads
7 * structure, not a database.
8 */
9
10import log from "../../seed/log.js";
11import logMode from "./modes/log.js";
12import reviewMode from "./modes/review.js";
13import coachMode from "./modes/coach.js";
14import dailyMode from "./modes/daily.js";
15import {
16  configure,
17  scaffold,
18  isInitialized,
19  findFoodNodes,
20  STRUCTURAL_ROLES,
21  handleMacroCascade,
22  checkDailyReset,
23  getDailyPicture,
24  getHistory,
25} from "./core.js";
26import { handleMessage } from "./handler.js";
27
28export async function init(core) {
29  core.llm.registerRootLlmSlot?.("food");
30
31  // Wire dependencies
32  const runChat = core.llm?.runChat || null;
33  configure({
34    Node: core.models.Node,
35    runChat: runChat
36      ? async (opts) => {
37          if (opts.userId && opts.userId !== "SYSTEM") {
38            const hasLlm = await core.llm.userHasLlm(opts.userId);
39            if (!hasLlm) return { answer: null };
40          }
41          return core.llm.runChat({
42            ...opts,
43            llmPriority: core.llm.LLM_PRIORITY.INTERACTIVE,
44          });
45        }
46      : null,
47    Note: core.models.Note,
48    metadata: core.metadata,
49  });
50
51  // Register modes
52  core.modes.registerMode("tree:food-log", logMode, "food");
53  core.modes.registerMode("tree:food-review", reviewMode, "food");
54  core.modes.registerMode("tree:food-coach", coachMode, "food");
55  core.modes.registerMode("tree:food-daily", dailyMode, "food");
56
57  if (core.llm?.registerModeAssignment) {
58    core.llm.registerModeAssignment("tree:food-log", "foodLog");
59    core.llm.registerModeAssignment("tree:food-review", "foodReview");
60    core.llm.registerModeAssignment("tree:food-coach", "foodCoach");
61    core.llm.registerModeAssignment("tree:food-daily", "foodDaily");
62  }
63
64  // ── onCascade: macro accumulation ──
65  // ── Boot self-heal: ensure food roots have mode override ──
66  core.hooks.register("afterBoot", async () => {
67    try {
68      const foodRoots = await core.models.Node.find({
69        "metadata.food.initialized": true,
70      }).select("_id metadata").lean();
71      for (const root of foodRoots) {
72        const modes = root.metadata instanceof Map
73          ? root.metadata.get("modes")
74          : root.metadata?.modes;
75        if (!modes?.respond) {
76          await core.modes.setNodeMode(root._id, "respond", "tree:food-log");
77          log.verbose("Food", `Self-healed mode override on ${String(root._id).slice(0, 8)}...`);
78        }
79      }
80    } catch {}
81  }, "food");
82
83  // ── onCascade: macro accumulation ──
84  // When a signal arrives at a macro node via channel, increment the value.
85  // No LLM call. Pure data routing.
86
87  core.hooks.register("onCascade", async (hookData) => {
88    const { node, signalId } = hookData;
89    if (!node) return;
90
91    const meta = node.metadata instanceof Map
92      ? node.metadata.get("food")
93      : node.metadata?.food;
94    if (!meta?.role) return;
95    if (STRUCTURAL_ROLES.includes(meta.role)) return;
96
97    // This is a food metric node receiving a cascade signal
98    const payload = hookData.writeContext || hookData.payload || {};
99    await handleMacroCascade(node, payload);
100
101    // Mark cascade as handled
102    hookData._resultStatus = "SUCCEEDED";
103    hookData._resultExtName = "food";
104    hookData._resultPayload = { role: meta.role, handled: true };
105  }, "food");
106
107  // ── afterNote: decrement values when a food log note is deleted externally ──
108  core.hooks.register("afterNote", async ({ nodeId, action, note }) => {
109    if (action !== "delete" || !nodeId) return;
110    // Check if this note belongs to a food Log node
111    try {
112      const node = await core.models.Node.findById(nodeId).select("metadata parent").lean();
113      if (!node) return;
114      const foodMeta = node.metadata instanceof Map ? node.metadata.get("food") : node.metadata?.food;
115      if (foodMeta?.role !== "log") return;
116      // Try to parse totals from the deleted note
117      let totals = null;
118      try {
119        const data = JSON.parse(note?.content || "");
120        totals = data.totals || null;
121      } catch {}
122      if (!totals) return;
123      // Decrement metric values
124      const rootId = node.parent ? String(node.parent) : null;
125      if (!rootId) return;
126      const foodNodes = await findFoodNodes(rootId);
127      for (const [role, info] of Object.entries(foodNodes)) {
128        if (STRUCTURAL_ROLES.includes(role) || !info?.id) continue;
129        const amount = totals[role] || 0;
130        if (amount > 0) {
131          await core.metadata.incExtMeta(info.id, "values", "today", -amount);
132        }
133      }
134    } catch {}
135  }, "food");
136
137  // ── breath:exhale: daily reset ──
138  // On each exhale, check if midnight has passed. If so, archive and reset.
139
140  let fallbackTimer = null;
141  const trackedRoots = new Set();
142
143  // Find the food root within a tree (it may be the tree root itself, or
144  // a descendant like /Life/Health/Food). Caches the mapping.
145  const _foodRootCache = new Map(); // treeRootId -> foodNodeId | null
146  async function findFoodRootInTree(treeRootId) {
147    if (_foodRootCache.has(treeRootId)) return _foodRootCache.get(treeRootId);
148
149    // Is the tree root itself a food root?
150    if (await isInitialized(treeRootId)) {
151      _foodRootCache.set(treeRootId, treeRootId);
152      return treeRootId;
153    }
154
155    // Search descendants (up to depth 4) for a food-initialized node
156    const Node = core.models.Node;
157    const queue = [treeRootId];
158    let depth = 0;
159    while (queue.length > 0 && depth < 4) {
160      const batch = [...queue];
161      queue.length = 0;
162      const children = await Node.find({ parent: { $in: batch } }).select("_id metadata").lean();
163      for (const child of children) {
164        const meta = child.metadata instanceof Map ? child.metadata.get("food") : child.metadata?.food;
165        if (meta?.initialized) {
166          const id = String(child._id);
167          _foodRootCache.set(treeRootId, id);
168          return id;
169        }
170        queue.push(String(child._id));
171      }
172      depth++;
173    }
174
175    _foodRootCache.set(treeRootId, null);
176    return null;
177  }
178
179  core.hooks.register("breath:exhale", async ({ rootId }) => {
180    if (!rootId) return;
181    try {
182      const foodRootId = await findFoodRootInTree(rootId);
183      if (foodRootId) {
184        trackedRoots.add(foodRootId);
185        await checkDailyReset(foodRootId);
186      }
187    } catch {}
188  }, "food");
189
190  // ── Data integrity check: validate metric totals match log notes ──
191  // Runs every breath cycle. If the sum of today's Log notes diverges from
192  // the metric node values (double increment, missed delivery, stale cache),
193  // correct the metrics. Also caps old data to prevent unbounded growth.
194  const _lastIntegrityCheck = new Map();
195  const INTEGRITY_INTERVAL_MS = 10 * 60 * 1000; // every 10 minutes max per root
196
197  core.hooks.register("breath:exhale", async ({ rootId }) => {
198    if (!rootId) return;
199    try {
200      const foodRootId = await findFoodRootInTree(rootId);
201      if (!foodRootId) return;
202
203      const last = _lastIntegrityCheck.get(foodRootId) || 0;
204      if (Date.now() - last < INTEGRITY_INTERVAL_MS) return;
205      _lastIntegrityCheck.set(foodRootId, Date.now());
206
207      const foodNodes = await findFoodNodes(foodRootId);
208      if (!foodNodes?.log?.id) return;
209
210      const Note = core.models.Node.db.model("Note");
211      const Node = core.models.Node;
212      const today = new Date().toISOString().slice(0, 10);
213      const todayStart = new Date(today + "T00:00:00.000Z");
214
215      // Sum today's log notes
216      const logNotes = await Note.find({
217        nodeId: foodNodes.log.id,
218        createdAt: { $gte: todayStart },
219      }).select("content").lean();
220
221      const logTotals = {};
222      for (const note of logNotes) {
223        try {
224          const parsed = JSON.parse(note.content);
225          if (parsed?.totals) {
226            for (const [key, val] of Object.entries(parsed.totals)) {
227              if (typeof val === "number") logTotals[key] = (logTotals[key] || 0) + val;
228            }
229          }
230        } catch {}
231      }
232
233      // Compare with metric node values
234      let corrected = 0;
235      for (const [role, info] of Object.entries(foodNodes)) {
236        if (STRUCTURAL_ROLES.includes(role) || !info?.id) continue;
237        const node = await Node.findById(info.id).select("metadata").lean();
238        if (!node) continue;
239        const values = node.metadata instanceof Map ? node.metadata.get("values") : node.metadata?.values;
240        const current = values?.today || 0;
241        const expected = logTotals[role] || 0;
242
243        if (current !== expected) {
244          await core.metadata.batchSetExtMeta(info.id, "values", { today: expected });
245          corrected++;
246          log.verbose("Food", `  Integrity fix: ${role} was ${current}, should be ${expected}`);
247        }
248      }
249
250      if (corrected > 0) {
251        log.info("Food", `Integrity check fixed ${corrected} metrics for ${String(foodRootId).slice(0, 8)}`);
252      }
253
254      // Cap old log notes (keep last 500)
255      const logCount = await Note.countDocuments({ nodeId: foodNodes.log.id });
256      if (logCount > 500) {
257        const oldest = await Note.find({ nodeId: foodNodes.log.id })
258          .sort({ createdAt: 1 }).limit(logCount - 500).select("_id").lean();
259        if (oldest.length > 0) {
260          await Note.deleteMany({ _id: { $in: oldest.map(n => n._id) } });
261          log.verbose("Food", `  Capped log notes: deleted ${oldest.length} old entries`);
262        }
263      }
264
265      // Cap history notes (keep last 365)
266      if (foodNodes.history?.id) {
267        const histCount = await Note.countDocuments({ nodeId: foodNodes.history.id });
268        if (histCount > 365) {
269          const oldHist = await Note.find({ nodeId: foodNodes.history.id })
270            .sort({ createdAt: 1 }).limit(histCount - 365).select("_id").lean();
271          if (oldHist.length > 0) {
272            await Note.deleteMany({ _id: { $in: oldHist.map(n => n._id) } });
273            log.verbose("Food", `  Capped history notes: deleted ${oldHist.length} old entries`);
274          }
275        }
276      }
277    } catch {}
278  }, "food-integrity");
279
280  // Track food roots from navigation
281  core.hooks.register("afterNavigate", async ({ rootId }) => {
282    if (!rootId) return;
283    try {
284      const foodRootId = await findFoodRootInTree(rootId);
285      if (foodRootId) trackedRoots.add(foodRootId);
286    } catch {}
287  }, "food");
288
289  // Fallback daily reset check (if breath not installed)
290  setTimeout(() => {
291    fallbackTimer = setInterval(async () => {
292      for (const rootId of trackedRoots) {
293        try { await checkDailyReset(rootId); } catch {}
294      }
295    }, 300000); // every 5 min
296    if (fallbackTimer.unref) fallbackTimer.unref();
297  }, 60000);
298
299  // ── enrichContext: assemble the daily picture ──
300  // On Daily node: full macro view with goals, history, recent meals.
301  // On macro nodes: show the running total.
302  // On Log node: show what's been logged today.
303
304  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
305    if (!node?._id) return;
306
307    const foodMeta = meta?.food;
308    if (!foodMeta?.role) return;
309
310    const role = foodMeta.role;
311
312    if (role === "daily") {
313      // Assemble the full picture from siblings
314      const parent = node.parent;
315      if (!parent) return;
316
317      const picture = await getDailyPicture(String(parent));
318      if (picture) {
319        const lines = [];
320        // Render all value-tracked nodes (core macros + user-created)
321        for (const role of (picture._valueRoles || ["protein", "carbs", "fats"])) {
322          const m = picture[role];
323          if (m) {
324            const label = m.name || role;
325            const pct = m.goal > 0 ? Math.round((m.today / m.goal) * 100) : 0;
326            const weekPart = m.weeklyAvg > 0 ? `, weekly avg ${m.weeklyAvg}g (${Math.round(m.weeklyHitRate * 100)}% hit rate)` : "";
327            lines.push(`${label}: ${m.today}/${m.goal}g (${pct}%)${weekPart}`);
328          }
329        }
330        if (picture.calories) {
331          const c = picture.calories;
332          const pct = c.goal > 0 ? Math.round((c.today / c.goal) * 100) : 0;
333          lines.push(`calories: ${c.today}/${c.goal} (${pct}%)`);
334        }
335        context.foodToday = lines.join(", ");
336
337        if (picture.recentMeals?.length > 0) {
338          context.foodRecentMeals = picture.recentMeals.map(m => m.text).join("; ");
339        }
340        if (picture.profile) {
341          context.foodProfile = picture.profile;
342        }
343        if (picture.recentHistory?.length > 0) {
344          context.foodHistory = picture.recentHistory;
345        }
346      }
347    } else if (meta?.values?.today != null) {
348      // Any value-tracked node (core macros or user-created)
349      const values = meta?.values;
350      const goals = meta?.goals;
351      context.foodMacro = {
352        type: foodMeta.role,
353        name: node.name,
354        today: values.today,
355        goal: goals?.today || 0,
356      };
357    }
358
359    // Cross-domain: fitness and recovery state (coach-level nodes only)
360    if (role === "log" || role === "daily") {
361      try {
362        const { getExtension } = await import("../loader.js");
363        const life = getExtension("life");
364        if (life?.exports?.getDomainNodes) {
365          const treeRoot = node.rootOwner || String(node._id);
366          const domains = await life.exports.getDomainNodes(treeRoot);
367
368          if (domains.fitness?.id) {
369            const fitness = getExtension("fitness");
370            if (fitness?.exports?.getWeeklyStats) {
371              const stats = await fitness.exports.getWeeklyStats(domains.fitness.id);
372              if (stats?.workoutsThisWeek > 0) {
373                context.fitnessThisWeek = { workouts: stats.workoutsThisWeek, lastWorkout: stats.lastWorkoutDate };
374              }
375            }
376          }
377
378          if (domains.recovery?.id) {
379            const recovery = getExtension("recovery");
380            if (recovery?.exports?.getStatus) {
381              const status = await recovery.exports.getStatus(domains.recovery.id);
382              if (status?.feelings) {
383                context.recoveryToday = { mood: status.feelings.mood, energy: status.feelings.energy };
384              }
385            }
386          }
387        }
388      } catch {}
389    }
390  }, "food");
391
392  // ── Live dashboard updates ──
393  // Walk up to find the food root (node with metadata.food.initialized)
394  async function findFoodRootFromNode(nodeId) {
395    let current = await core.models.Node.findById(nodeId).select("metadata parent rootOwner").lean();
396    let depth = 0;
397    while (current && depth < 10) {
398      const fm = current.metadata instanceof Map ? current.metadata.get("food") : current.metadata?.food;
399      if (fm?.initialized) return { foodRootId: String(current._id), ownerId: current.rootOwner ? String(current.rootOwner) : null };
400      if (!current.parent || current.rootOwner) break;
401      current = await core.models.Node.findById(current.parent).select("metadata parent rootOwner").lean();
402      depth++;
403    }
404    return current?.rootOwner ? { foodRootId: null, ownerId: String(current.rootOwner) } : null;
405  }
406
407  core.hooks.register("afterNote", async ({ nodeId }) => {
408    if (!nodeId) return;
409    try {
410      const node = await core.models.Node.findById(nodeId).select("metadata").lean();
411      const fm = node?.metadata instanceof Map ? node.metadata.get("food") : node?.metadata?.food;
412      if (!fm?.role) return;
413      const info = await findFoodRootFromNode(nodeId);
414      if (!info?.ownerId) return;
415      if (info.foodRootId) core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.foodRootId });
416      core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.ownerId });
417    } catch {}
418  }, "food");
419
420  core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
421    if (extName !== "values" && extName !== "food" && extName !== "goals") return;
422    try {
423      const info = await findFoodRootFromNode(nodeId);
424      if (!info?.ownerId) return;
425      if (info.foodRootId) core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.foodRootId });
426      core.websocket?.emitToUser?.(info.ownerId, "dashboardUpdate", { rootId: info.ownerId });
427    } catch {}
428  }, "food");
429
430  // ── Register apps-grid slot ──
431  try {
432    const { getExtension } = await import("../loader.js");
433    const base = getExtension("treeos-base");
434    base?.exports?.registerSlot?.("apps-grid", "food", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
435      const entries = rootMap.get("Food") || [];
436      const existing = entries.map(entry =>
437        entry.ready
438          ? `<a class="app-active" href="/api/v1/root/${entry.id}/food?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
439          : `<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}/food?html${tokenParam}">${e(entry.name)} (setup)</a>`
440      ).join("");
441      return `<div class="app-card">
442        <div class="app-header"><span class="app-emoji">🍎</span><span class="app-name">Food</span></div>
443        <div class="app-desc">Say what you ate. One LLM call parses macros. Daily totals tracked. History archives daily summaries.</div>
444        ${entries.length > 0
445          ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
446          : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
447              ${tokenField}<input type="hidden" name="app" value="food" />
448              <input class="app-input" name="message" placeholder="What did you eat? (or just say hi to set up your goals)" required />
449              <button class="app-start" type="submit">Start Food</button>
450            </form>`}
451      </div>`;
452    }, { priority: 20 });
453  } catch {}
454
455  // ── Import router ──
456  const { default: router, setServices } = await import("./routes.js");
457  setServices({ Node: core.models.Node });
458
459  const { default: getTools } = await import("./tools.js");
460  const tools = getTools();
461
462  log.info("Food", "Loaded. The tree tracks nutrition.");
463
464  return {
465    router,
466    tools,
467    modeTools: [
468      { modeKey: "tree:food-log", toolNames: ["food-log-entry"] },
469      { modeKey: "tree:food-coach", toolNames: ["food-save-profile", "food-adopt-node"] },
470      { modeKey: "tree:edit", toolNames: ["food-adopt-node"] },
471    ],
472    exports: {
473      scaffold,
474      isInitialized,
475      findFoodNodes,
476      getDailyPicture,
477      getHistory,
478      handleMessage,
479    },
480    jobs: [
481      {
482        name: "food-daily-reset",
483        start: () => {},
484        stop: () => {
485          if (fallbackTimer) clearInterval(fallbackTimer);
486        },
487      },
488    ],
489  };
490}
491
1export default {
2  name: "food",
3  version: "2.1.2",
4  builtFor: "TreeOS",
5  description:
6    "Nutrition tracking through tree structure. Log node receives food input. One LLM " +
7    "call parses macros. Cascade routes to Protein, Carbs, Fats nodes. Meals subtree " +
8    "tracks patterns by slot (Breakfast, Lunch, Dinner, Snacks). Profile node holds goals " +
9    "and restrictions. History node archives daily summaries with weekly averages and hit " +
10    "rates. Three modes: food-log (parser), food-review (advisor with weekly patterns), " +
11    "food-coach (setup and goal setting). Fitness channel carries workout data both ways. " +
12    "Type 'be' at the Food tree to start logging: just say what you ate. The tree IS the app.",
13
14  territory: "food, meals, eat, eating, ate, hungry, nutrition, cooking, calories, protein, carbs, diet, snack, breakfast, lunch, dinner",
15  classifierHints: [
16    /\b(ate|eaten|drank|breakfast|lunch|dinner|snack|calories|protein|carbs|fats|macro)\b/i,
17    /\b(egg|chicken|rice|bread|salmon|banana|oat|milk|cheese|beef|pork|tofu|yogurt)\b/i,
18    /\b(meal|food|nutrition|diet|eat|hungry|cook)\b/i,
19  ],
20
21  needs: {
22    models: ["Node"],
23    services: ["hooks", "metadata"],
24  },
25
26  optional: {
27    services: ["llm"],
28    extensions: [
29      "values",          // macro tracking (today/goal per node)
30      "channels",        // direct signal paths Log -> macro nodes
31      "breath",          // daily reset sync
32      "notifications",   // overdue meal reminders
33      "phase",           // suppress during focus
34      "schedules",       // meal timing
35      "html-rendering",  // dashboard page
36      "treeos-base",     // slot registration
37    ],
38  },
39
40  provides: {
41    models: {},
42    routes: "./routes.js",
43    tools: true,
44    jobs: true,
45    guidedMode: "tree:food-coach",
46
47    hooks: {
48      fires: [],
49      listens: ["enrichContext", "onCascade", "breath:exhale"],
50    },
51
52    cli: [
53      {
54        command: "food [message...]",
55        scope: ["tree"],
56        description: "Log food or ask about nutrition.",
57        method: "POST",
58        endpoint: "/root/:rootId/food",
59        body: ["message"],
60      },
61      {
62        command: "food-daily",
63        scope: ["tree"],
64        description: "Today's nutrition dashboard.",
65        method: "GET",
66        endpoint: "/root/:rootId/food/daily",
67      },
68      {
69        command: "food-week",
70        scope: ["tree"],
71        description: "Weekly nutrition review.",
72        method: "GET",
73        endpoint: "/root/:rootId/food/week",
74      },
75      {
76        command: "food-profile",
77        scope: ["tree"],
78        description: "Dietary profile and goals.",
79        method: "GET",
80        endpoint: "/root/:rootId/food/profile",
81      },
82    ],
83  },
84};
85
1// food/modes/coach.js
2// Setup and advisory mode. Runs on first use to gather goals and profile.
3// Also available at Daily node for meal planning and adjustment.
4// Has tools to set values and goals on the macro nodes.
5// Prompt is async: reads the live tree structure so the AI adapts to custom shapes.
6
7import { findFoodNodes, STRUCTURAL_ROLES } from "../core.js";
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9
10export default {
11  name: "tree:food-coach",
12  emoji: "🥗",
13  label: "Food Coach",
14  bigMode: "tree",
15  hidden: true,
16
17  maxMessagesBeforeLoop: 10,
18  preserveContextOnLoop: true,
19
20  toolNames: [
21    "food-save-profile",
22    "food-adopt-node",
23  ],
24
25  async buildSystemPrompt({ username, rootId, currentNodeId }) {
26    const Node = (await import("../../../seed/models/node.js")).default;
27    const foodRootId = await findExtensionRoot(currentNodeId || rootId, "food") || rootId;
28    const nodes = foodRootId ? await findFoodNodes(foodRootId) : null;
29
30    // Separate structural nodes from tracked metrics, include goal values
31    const metrics = [];
32    const structural = [];
33    let hasLog = false;
34    if (nodes) {
35      for (const [role, info] of Object.entries(nodes)) {
36        if (role === "mealSlots" || role === "_unadopted" || !info?.id) continue;
37        if (STRUCTURAL_ROLES.includes(role)) {
38          structural.push(role);
39          if (role === "log") hasLog = true;
40        } else {
41          let goalStr = "no goal set";
42          try {
43            const n = await Node.findById(info.id).select("metadata").lean();
44            const goals = n?.metadata instanceof Map ? n.metadata.get("goals") : n?.metadata?.goals;
45            if (goals?.today > 0) goalStr = `goal: ${goals.today}g`;
46          } catch {}
47          metrics.push(`${info.name} (role: ${role}, id: ${info.id}, ${goalStr})`);
48        }
49      }
50    }
51
52    const structureBlock = metrics.length > 0
53      ? `TRACKED METRICS\n${metrics.map(f => `- ${f}`).join("\n")}`
54      : "No metrics tracked yet. The user needs to add what they want to track.";
55
56    const missingBlock = !hasLog && nodes
57      ? `\nMISSING REQUIRED: log node. Use create-new-node to create it.`
58      : "";
59
60    const unadopted = nodes?._unadopted || [];
61    const unadoptedBlock = unadopted.length > 0
62      ? `\nUNADOPTED NODES (new children without a food role):\n${unadopted.map(u => `- "${u.name}" (id: ${u.id})`).join("\n")}\nThese were created by the user but not yet adopted into the food system. Use food-adopt-node to assign each a role (lowercase name, e.g. "sugar", "fiber"). Ask the user if they want to track these and what their daily goals should be.`
63      : "";
64
65    // Check actual setupPhase from the food root, not just goal presence
66    let setupPhase = "scaffolded";
67    try {
68      const rootNode = await Node.findById(foodRootId).select("metadata").lean();
69      const fm = rootNode?.metadata instanceof Map ? rootNode.metadata.get("food") : rootNode?.metadata?.food;
70      if (fm?.setupPhase) setupPhase = fm.setupPhase;
71    } catch {}
72    const needsSetup = setupPhase !== "complete";
73
74    return `You are ${username}'s nutrition coach. You handle setup, goal configuration, and questions about nutrition.
75
76You do NOT log food in this mode. When the user describes something they ate, the orchestrator routes to the food-log mode. Your job here is the conversation around goals, restrictions, and the structure of their tracking.
77
78Root ID: ${foodRootId}
79${needsSetup ? "STATUS: Goals not configured yet. Run setup." : "STATUS: Goals configured."}
80
81${structureBlock}${missingBlock}${unadoptedBlock}
82
83${needsSetup ? "SETUP FLOW (goals not yet configured)" : "SETUP FLOW (already done, skip unless user asks to change goals)"}
84Ask these things naturally:
851. What's your daily calorie target? (or help calculate: goal + weight + activity level)
862. What metrics do they want to track? Look at the CURRENT TREE STRUCTURE above. Set goals for whatever nodes exist. Do NOT create nodes that aren't there. The user chose their metrics.
873. Any dietary restrictions or preferences?
88
89AFTER THEY ANSWER
90IMMEDIATELY call food-save-profile with the rootId and goal keys. Do NOT say "I've saved your goals" without calling the tool first. The tool saves the data. Without the tool call, nothing is saved. Tool first, confirmation after.
91
92ADAPTING TO CUSTOM STRUCTURE
93The tree structure above is the truth. Only those metrics exist. Do not assume protein, carbs, or fats should exist if they are not listed. The user controls which metrics they track. If someone only tracks sugar and fiber, that is correct. Do not suggest adding missing macros unless asked.
94
95COMMON KNOWLEDGE (use only if relevant to the user's tracked metrics)
96- 1g protein = 4 cal, 1g carbs = 4 cal, 1g fat = 9 cal
97- Cutting: TDEE minus 500cal, Bulking: TDEE plus 300cal, Maintenance: TDEE
98
99COMMUNICATION
100- Be practical and specific to their tracked metrics.
101- If they don't know, suggest based on what they track.
102- After setting goals, confirm what was set. Only mention metrics that exist in the tree.
103- Never mention node IDs, metadata keys, or internal structure to the user.`.trim();
104  },
105};
106
1// food/modes/daily.js
2// The advisor. Read-only. Reads the assembled picture from macro node
3// values, recent meals from Log, and profile from root. Responds to
4// questions about intake, suggestions, and patterns.
5
6import { findFoodNodes, getDailyPicture, getHistory, STRUCTURAL_ROLES } from "../core.js";
7import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
8
9export default {
10  name: "tree:food-daily",
11  emoji: "\u{1F4CA}",
12  label: "Daily Summary",
13  bigMode: "tree",
14  hidden: true,
15
16  maxMessagesBeforeLoop: 6,
17  preserveContextOnLoop: true,
18
19  toolNames: [],
20
21  async buildSystemPrompt({ username, rootId, currentNodeId }) {
22    const Node = (await import("../../../seed/models/node.js")).default;
23    const foodRootId = await findExtensionRoot(currentNodeId || rootId, "food") || rootId;
24    const picture = foodRootId ? await getDailyPicture(foodRootId) : null;
25
26    // Build today's macro summary
27    let todayBlock = "No data yet today.";
28    if (picture?.calories) {
29      const lines = [];
30      for (const role of (picture._valueRoles || [])) {
31        const m = picture[role];
32        if (!m) continue;
33        const pct = m.goal > 0 ? ` (${Math.round((m.today / m.goal) * 100)}%)` : "";
34        const avg = m.weeklyAvg > 0 ? `, weekly avg ${m.weeklyAvg}g` : "";
35        const hit = m.weeklyHitRate > 0 ? `, hit rate ${Math.round(m.weeklyHitRate * 100)}%` : "";
36        lines.push(`${m.name || role}: ${m.today}${m.goal > 0 ? `/${m.goal}g` : "g"}${pct}${avg}${hit}`);
37      }
38      const cal = picture.calories;
39      const calPct = cal.goal > 0 ? ` (${Math.round((cal.today / cal.goal) * 100)}%)` : "";
40      lines.push(`Calories: ${cal.today}${cal.goal > 0 ? `/${cal.goal}` : ""}${calPct}`);
41      if (cal.goal > 0) lines.push(`Remaining: ${Math.max(0, cal.goal - cal.today)} cal`);
42      todayBlock = lines.join("\n");
43    }
44
45    // Recent meals
46    let mealsBlock = "";
47    if (picture?.recentMeals?.length > 0) {
48      mealsBlock = "RECENT MEALS:\n" + picture.recentMeals.map(m => `- ${m.text}`).join("\n");
49    }
50
51    // Profile
52    let profileBlock = "";
53    if (picture?.profile) {
54      const p = picture.profile;
55      const parts = [];
56      if (p.goal) parts.push(`goal: ${p.goal}`);
57      if (p.restrictions) parts.push(`restrictions: ${p.restrictions}`);
58      if (p.calorieGoal) parts.push(`target: ${p.calorieGoal} cal`);
59      if (parts.length > 0) profileBlock = `PROFILE: ${parts.join(", ")}`;
60    }
61
62    // Weekly trends from history
63    let weeklyBlock = "";
64    const weeklySummaries = await getHistory(foodRootId, { limit: 4, type: "weekly" });
65    if (weeklySummaries.length > 0) {
66      const latest = weeklySummaries[0];
67      const lines = [`Week of ${latest.weekStart} to ${latest.weekEnd} (${latest.daysTracked} days tracked)`];
68      if (latest.calories) lines.push(`Avg calories: ${latest.calories.avg}`);
69      if (latest.averages) {
70        for (const [role, avg] of Object.entries(latest.averages)) {
71          const hit = latest.hitRates?.[role];
72          const hitStr = hit != null ? ` (hit rate ${Math.round(hit * 100)}%)` : "";
73          lines.push(`Avg ${role}: ${avg}g${hitStr}`);
74        }
75      }
76      weeklyBlock = "WEEKLY TRENDS:\n" + lines.join("\n");
77    }
78
79    // Recent daily history
80    let historyBlock = "";
81    if (picture?.recentHistory?.length > 0) {
82      const days = picture.recentHistory.filter(d => (d.type || "daily") === "daily").slice(0, 7);
83      if (days.length > 0) {
84        historyBlock = "PAST 7 DAYS:\n" + days.map(d => {
85          const parts = [`${d.date}: ${d.calories || 0} cal`];
86          if (d.protein != null) parts.push(`P:${d.protein}`);
87          if (d.carbs != null) parts.push(`C:${d.carbs}`);
88          if (d.fats != null) parts.push(`F:${d.fats}`);
89          return parts.join(" ");
90        }).join("\n");
91      }
92    }
93
94    return `You are ${username}'s nutrition advisor at the Daily node.
95
96TODAY:
97${todayBlock}
98${mealsBlock ? `\n${mealsBlock}` : ""}
99${profileBlock ? `\n${profileBlock}` : ""}
100${weeklyBlock ? `\n${weeklyBlock}` : ""}
101${historyBlock ? `\n${historyBlock}` : ""}
102
103YOUR ROLE
104- Answer questions about today's intake: "how am I doing", "am I on track"
105- Suggest meals that fit remaining macros: "what should I eat for dinner"
106- Spot patterns from history: "you've been low on protein 4 of the last 7 days"
107- Be practical and specific. Use actual numbers. "You need 52g protein. Chicken thigh would cover it."
108
109RULES
110- Never mention node IDs, metadata, tools, or internal structure
111- Reference meals by name, not by technical identifier
112- Use the profile for context: goal (cut/bulk/maintain), restrictions, preferences
113- If history shows a pattern, mention it naturally: "you usually skip breakfast on Wednesdays"
114- Match the user's energy. "How am I doing" gets a quick summary. "Plan my week" gets detail.
115- When suggesting meals, use foods from their recent history when possible
116- Be honest about overages: "you went 300 over yesterday, mostly from the pizza at dinner"`.trim();
117  },
118};
119
1// food/modes/log.js
2// The default mode at the food node. Handles food logging AND conversation.
3// When the user says food: parse it, log it with tools, confirm with totals.
4// When the user says anything else: respond conversationally with food awareness.
5
6import { findFoodNodes, getDailyPicture, STRUCTURAL_ROLES } from "../core.js";
7import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
8
9export default {
10  name: "tree:food-log",
11  emoji: "📝",
12  label: "Food Log",
13  bigMode: "tree",
14  hidden: true,
15
16  maxMessagesBeforeLoop: 10,
17  preserveContextOnLoop: true,
18
19  toolNames: [
20    "food-log-entry",
21    "food-save-profile",
22  ],
23
24  async buildSystemPrompt({ username, rootId, currentNodeId }) {
25    const Node = (await import("../../../seed/models/node.js")).default;
26    // Find the food extension root from wherever we are in the tree
27    const foodRootId = await findExtensionRoot(currentNodeId || rootId, "food") || rootId;
28    const nodes = foodRootId ? await findFoodNodes(foodRootId) : null;
29
30    // Discover tracked metrics with current values
31    const metrics = [];
32    if (nodes) {
33      for (const [role, info] of Object.entries(nodes)) {
34        if (role === "mealSlots" || role === "_unadopted" || !info?.id || STRUCTURAL_ROLES.includes(role)) continue;
35        let goalStr = "no goal";
36        let todayStr = "0";
37        try {
38          const n = await Node.findById(info.id).select("metadata").lean();
39          const goals = n?.metadata instanceof Map ? n.metadata.get("goals") : n?.metadata?.goals;
40          const values = n?.metadata instanceof Map ? n.metadata.get("values") : n?.metadata?.values;
41          if (goals?.today > 0) goalStr = `goal: ${goals.today}g`;
42          if (values?.today > 0) todayStr = String(values.today);
43        } catch {}
44        metrics.push({ role, name: info.name, id: info.id, goalStr, todayStr });
45      }
46    }
47
48    const metricList = metrics.length > 0
49      ? metrics.map(m => `- ${m.name} (id: ${m.id}): ${m.todayStr}g today, ${m.goalStr}`).join("\n")
50      : "No metrics tracked yet.";
51
52    // Check actual setupPhase, not just whether goals exist. Goals can be zero.
53    let setupPhase = "scaffolded";
54    try {
55      const rootNode = await Node.findById(foodRootId).select("metadata").lean();
56      const fm = rootNode?.metadata instanceof Map ? rootNode.metadata.get("food") : rootNode?.metadata?.food;
57      if (fm?.setupPhase) setupPhase = fm.setupPhase;
58    } catch {}
59    const needsSetup = setupPhase !== "complete";
60    // Get today's picture for context
61    let todaySummary = "";
62    try {
63      const picture = await getDailyPicture(foodRootId);
64      if (picture?.calories) {
65        const parts = [];
66        for (const role of (picture._valueRoles || [])) {
67          const m = picture[role];
68          if (m) parts.push(`${m.name || role}: ${m.today}${m.goal > 0 ? `/${m.goal}g` : "g"}`);
69        }
70        if (picture.calories) parts.push(`cal: ${picture.calories.today}${picture.calories.goal > 0 ? `/${picture.calories.goal}` : ""}`);
71        if (parts.length > 0) todaySummary = `Today so far: ${parts.join(", ")}`;
72      }
73    } catch {}
74
75    return `You are ${username}'s food tracker.
76
77${needsSetup ? "STATUS: Goals not configured. Ask about calorie target and macro goals." : "STATUS: Tracking active."}
78
79METRICS:
80${metricList}
81${todaySummary ? `\n${todaySummary}` : ""}
82
83${needsSetup ? `SETUP (no goals yet):
84Ask their daily calorie target, macro goals, and dietary restrictions.
85Use food-save-profile with rootId ${foodRootId} to save goals.
86` : ""}WHEN THE USER TELLS YOU WHAT THEY ATE:
871. Estimate macros for the food items (use common knowledge for portions).
882. Call food-log-entry ONCE with rootId ${foodRootId}, the items array, totals, and a summary.
89   The tool handles everything: writes to Log, updates all metrics, places in the right meal slot.
903. Confirm naturally using the running totals returned by the tool.
91
92WHEN THE USER ASKS QUESTIONS:
93Respond conversationally. You know their daily totals, goals, and what they've eaten.
94
95RULES:
96- Be concise. One line for logging confirmation with running totals.
97- Use actual numbers from the metrics above. Don't make up totals.
98- For food estimates: egg = 6p/0c/5f/70cal, chicken 4oz = 35p/0c/4f/185cal, rice 1cup = 4p/45c/0f/200cal.
99- Never expose node IDs or metadata to the user.
100- If the message isn't about food, respond briefly. You live here but your tools are food-specific.
101- ALWAYS use food-log-entry for logging. Never manually call edit-node-value or create-node-note for food entries.`.trim();
102  },
103};
104
1// food/modes/review.js
2// The reviewer. Read-only. Reads macro values, weekly averages, History notes,
3// Meals patterns, and fitness channel data. Analyzes trends and gives advice.
4
5import { getDailyPicture, getHistory } from "../core.js";
6import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
7
8export default {
9  name: "tree:food-review",
10  emoji: "\u{1F4CA}",
11  label: "Nutrition Review",
12  bigMode: "tree",
13  hidden: true,
14
15  maxMessagesBeforeLoop: 8,
16  preserveContextOnLoop: true,
17
18  toolNames: [],
19
20  async buildSystemPrompt({ username, rootId, currentNodeId }) {
21    const foodRootId = await findExtensionRoot(currentNodeId || rootId, "food") || rootId;
22
23    // Get today's picture with 30 days of daily history
24    const picture = foodRootId ? await getDailyPicture(foodRootId, { historyDays: 30 }) : null;
25
26    // Get weekly summaries for longer-term trends
27    const weeklySummaries = await getHistory(foodRootId, { limit: 8, type: "weekly" });
28
29    // Build today's snapshot
30    let todayBlock = "";
31    if (picture?.calories) {
32      const lines = [];
33      for (const role of (picture._valueRoles || [])) {
34        const m = picture[role];
35        if (!m) continue;
36        const pct = m.goal > 0 ? ` (${Math.round((m.today / m.goal) * 100)}%)` : "";
37        const avg = m.weeklyAvg > 0 ? `, avg ${m.weeklyAvg}g` : "";
38        const hit = m.weeklyHitRate > 0 ? `, hit ${Math.round(m.weeklyHitRate * 100)}%` : "";
39        lines.push(`${m.name || role}: ${m.today}${m.goal > 0 ? `/${m.goal}g` : "g"}${pct}${avg}${hit}`);
40      }
41      const cal = picture.calories;
42      lines.push(`Calories: ${cal.today}${cal.goal > 0 ? `/${cal.goal}` : ""}`);
43      todayBlock = "TODAY:\n" + lines.join("\n");
44    }
45
46    // Build weekly trends block
47    let weeklyBlock = "";
48    if (weeklySummaries.length > 0) {
49      const weekLines = weeklySummaries.map(w => {
50        const parts = [`${w.weekStart} to ${w.weekEnd} (${w.daysTracked}d)`];
51        if (w.calories) parts.push(`${w.calories.avg} cal/day`);
52        if (w.averages) {
53          const macros = Object.entries(w.averages).map(([r, v]) => {
54            const hit = w.hitRates?.[r];
55            return `${r}:${v}g${hit != null ? `(${Math.round(hit * 100)}%)` : ""}`;
56          });
57          parts.push(macros.join(" "));
58        }
59        return parts.join(" | ");
60      });
61      weeklyBlock = "WEEKLY TRENDS:\n" + weekLines.join("\n");
62    }
63
64    // Build 30-day history block
65    let historyBlock = "";
66    if (picture?.recentHistory?.length > 0) {
67      const days = picture.recentHistory.filter(d => (d.type || "daily") === "daily");
68      if (days.length > 0) {
69        historyBlock = `DAILY HISTORY (${days.length} days):\n` + days.map(d => {
70          const parts = [d.date];
71          if (d.calories != null) parts.push(`${d.calories}cal`);
72          if (d.protein != null) parts.push(`P:${d.protein}`);
73          if (d.carbs != null) parts.push(`C:${d.carbs}`);
74          if (d.fats != null) parts.push(`F:${d.fats}`);
75          return parts.join(" ");
76        }).join("\n");
77      }
78    }
79
80    // Profile
81    let profileBlock = "";
82    if (picture?.profile) {
83      const p = picture.profile;
84      const parts = [];
85      if (p.goal) parts.push(`goal: ${p.goal}`);
86      if (p.restrictions) parts.push(`restrictions: ${p.restrictions}`);
87      if (p.calorieGoal) parts.push(`target: ${p.calorieGoal} cal`);
88      if (parts.length > 0) profileBlock = `PROFILE: ${parts.join(", ")}`;
89    }
90
91    // Recent meals
92    let mealsBlock = "";
93    if (picture?.mealsBySlot) {
94      const slotLines = [];
95      for (const [slot, meals] of Object.entries(picture.mealsBySlot)) {
96        if (meals?.length > 0) {
97          slotLines.push(`${slot}: ${meals.map(m => m.text).join(", ")}`);
98        }
99      }
100      if (slotLines.length > 0) mealsBlock = "MEALS BY SLOT:\n" + slotLines.join("\n");
101    }
102
103    return `You are ${username}'s nutrition reviewer.
104
105${todayBlock}
106${weeklyBlock ? `\n${weeklyBlock}` : ""}
107${historyBlock ? `\n${historyBlock}` : ""}
108${profileBlock ? `\n${profileBlock}` : ""}
109${mealsBlock ? `\n${mealsBlock}` : ""}
110
111TWO DIRECTIONS
112If the user asks about past patterns, analyze history. Look at weekly trends, hit rates,
113meal slot patterns, and daily history. "You've been under on protein 4 of the last 7 days.
114You skip breakfast 3 days a week. On days you eat breakfast, your protein hits target."
115
116If the user asks what to eat next, look forward. Read today's remaining macros against goals,
117their meal slot history for variety, and fitness data for recovery needs. Be specific:
118"You need 52g protein and 800 calories to hit your targets. You trained chest today so recovery
119matters. You've had chicken five times this week. Try salmon and sweet potato."
120
121YOUR ROLE
122- Answer questions about today's intake and longer-term trends
123- Suggest specific meals that fit remaining macros and respect restrictions
124- Spot patterns from weekly trends and daily history
125- Use meal slot patterns: "you eat eggs 4 out of 5 mornings, your protein is highest on egg days"
126- Be practical and specific. Use actual numbers.
127- When fitness data is in context, factor it in
128- Suggest variety based on meal history
129
130RULES
131- Never mention node IDs, metadata, tools, or internal structure
132- Reference meals by name, not by technical identifier
133- Use the profile for context: goals, restrictions, preferences
134- If weekly hit rate is below 60%, call it out with the pattern
135- Match the user's energy. "How am I doing" gets a quick summary. "Plan my week" gets detail.
136- When suggesting meals, use foods from their recent history when possible
137- Be honest about overages and patterns. No false praise.`.trim();
138  },
139};
140
1/**
2 * Food Dashboard
3 *
4 * Builds from getDailyPicture() and getHistory() data.
5 * The surface: what matters right now rises. What's quiet recedes.
6 * No LLM call. The data tells us what to say.
7 */
8
9import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
10import { timeAgo } from "../../html-rendering/html/utils.js";
11
12const MACRO_COLORS = { protein: "#667eea", carbs: "#48bb78", fats: "#ecc94b" };
13
14// ── Surfacing logic ──
15
16function buildSurface(picture, weeklySummaries) {
17  const cal = picture?.calories || {};
18  const profile = picture?.profile || {};
19  const history = (picture?.recentHistory || []).filter(d => (d.type || "daily") === "daily");
20  const meals = picture?.recentMeals || [];
21  const valueRoles = picture?._valueRoles || [];
22
23  const hour = new Date().getHours();
24  const calPct = cal.goal > 0 ? Math.round((cal.today / cal.goal) * 100) : 0;
25  const calRemaining = Math.max(0, (cal.goal || 0) - (cal.today || 0));
26  const hasMealsToday = meals.length > 0 && meals.some(m => {
27    if (!m.date) return false;
28    const d = new Date(m.date);
29    const now = new Date();
30    return d.toDateString() === now.toDateString();
31  });
32
33  // Find weak macros (hit rate < 60% this week)
34  const weakMacros = [];
35  const strongMacros = [];
36  for (const role of valueRoles) {
37    const m = picture[role];
38    if (!m || !m.weeklyHitRate) continue;
39    if (m.weeklyHitRate < 0.6 && m.goal > 0) weakMacros.push(m.name || role);
40    if (m.weeklyHitRate >= 0.85 && m.goal > 0) strongMacros.push(m.name || role);
41  }
42
43  // Streak detection from history
44  let streak = 0;
45  for (const day of history) {
46    const allHit = valueRoles.every(role => {
47      const hitKey = `hit${role.charAt(0).toUpperCase() + role.slice(1)}Goal`;
48      return day[hitKey] !== false; // true or undefined (no goal) counts
49    });
50    if (allHit && day.calories > 0) streak++;
51    else break;
52  }
53
54  // No profile
55  if (!profile.calorieGoal && !picture?.protein?.goal) {
56    return { text: "Set your goals to start tracking.", tone: "neutral" };
57  }
58
59  // First day (no history)
60  if (history.length === 0 && !hasMealsToday) {
61    return { text: "First day tracking. Log what you eat and the picture builds.", tone: "neutral" };
62  }
63
64  // Evening, goals hit
65  if (hour >= 17 && calPct >= 90 && calPct <= 110 && weakMacros.length === 0) {
66    const streakStr = streak >= 2 ? ` ${streak} days in a row.` : "";
67    return { text: `All targets hit today. ${Math.round(cal.today)} of ${Math.round(cal.goal)} calories.${streakStr}`, tone: "good" };
68  }
69
70  // Over target
71  if (calPct > 115 && cal.goal > 0) {
72    const over = Math.round(cal.today - cal.goal);
73    return { text: `${over} calories over target today.`, tone: "warn" };
74  }
75
76  // Weak macro pattern
77  if (weakMacros.length > 0 && history.length >= 3) {
78    const names = weakMacros.join(" and ");
79    return { text: `${names} has been low this week. Hit rate under 60%.`, tone: "warn" };
80  }
81
82  // Morning, no meals
83  if (hour < 11 && !hasMealsToday) {
84    return { text: "Good morning. What's for breakfast?", tone: "neutral" };
85  }
86
87  // Afternoon with room
88  if (hour >= 11 && hour < 17 && calRemaining > 400) {
89    // Find the most behind macro
90    let behindRole = null;
91    let behindPct = 100;
92    for (const role of valueRoles) {
93      const m = picture[role];
94      if (!m || !m.goal) continue;
95      const pct = m.goal > 0 ? (m.today / m.goal) * 100 : 100;
96      if (pct < behindPct) { behindPct = pct; behindRole = m.name || role; }
97    }
98    if (behindRole && behindPct < 50) {
99      const m = picture[valueRoles.find(r => (picture[r]?.name || r) === behindRole)];
100      const remaining = m ? Math.round(m.goal - m.today) : 0;
101      return { text: `${remaining}g ${behindRole.toLowerCase()} to go. ${Math.round(calRemaining)} calories remaining.`, tone: "neutral" };
102    }
103    return { text: `${Math.round(calRemaining)} calories remaining today.`, tone: "neutral" };
104  }
105
106  // Evening with room
107  if (hour >= 17 && calRemaining > 200) {
108    return { text: `${Math.round(calRemaining)} calories left for dinner.`, tone: "neutral" };
109  }
110
111  // Streak
112  if (streak >= 3) {
113    return { text: `${streak} days hitting all targets.`, tone: "good" };
114  }
115
116  // Default
117  if (hasMealsToday) {
118    return { text: `${Math.round(cal.today)} calories so far today.`, tone: "neutral" };
119  }
120
121  return { text: "Log what you eat. The surface builds.", tone: "neutral" };
122}
123
124function surfaceToneColor(tone) {
125  if (tone === "good") return "#48bb78";
126  if (tone === "warn") return "#ecc94b";
127  return "rgba(255,255,255,0.5)";
128}
129
130// ── Dashboard renderer ──
131
132export function renderFoodDashboard({ rootId, rootName, picture, weeklySummaries, token, userId, inApp }) {
133  const p = picture || {};
134  const calories = p.calories || {};
135  const profile = p.profile || {};
136  const recentMeals = p.recentMeals || [];
137  const mealsBySlot = p.mealsBySlot || {};
138  const recentHistory = (p.recentHistory || []).filter(d => (d.type || "daily") === "daily");
139  const weeks = weeklySummaries || [];
140
141  const calPct = calories.goal > 0 ? Math.round((calories.today / calories.goal) * 100) : 0;
142  const calRemaining = Math.max(0, (calories.goal || 0) - (calories.today || 0));
143
144  // ── Surface ──
145  const surface = buildSurface(p, weeks);
146
147  // ── Profile subtitle ──
148  const profileParts = [];
149  if (profile.calorieGoal) profileParts.push(`${profile.calorieGoal} cal target`);
150  if (profile.goal) profileParts.push(profile.goal);
151  if (profile.restrictions) profileParts.push(profile.restrictions);
152
153  // ── Stats: only show what earned its place ──
154  const stats = [];
155  const weeklyAvgCals = recentHistory.length > 0
156    ? Math.round(recentHistory.reduce((s, h) => s + (h.calories || ((h.protein || 0) * 4 + (h.carbs || 0) * 4 + (h.fats || 0) * 9)), 0) / recentHistory.length)
157    : null;
158  if (weeklyAvgCals && recentHistory.length >= 3) stats.push({ value: String(weeklyAvgCals), label: "avg cal/day" });
159  if (recentHistory.length >= 2) stats.push({ value: String(recentHistory.length), label: "days tracked" });
160  const proteinHitRate = p.protein?.weeklyHitRate;
161  if (proteinHitRate && proteinHitRate > 0) stats.push({ value: Math.round(proteinHitRate * 100) + "%", label: "protein hit" });
162
163  // ── Macro bars ──
164  const bars = [];
165  const coreOrder = ["protein", "carbs", "fats"];
166  const valueRoles = p._valueRoles || coreOrder;
167  const ordered = [...coreOrder.filter(r => valueRoles.includes(r)), ...valueRoles.filter(r => !coreOrder.includes(r))];
168
169  for (const role of ordered) {
170    const m = p[role];
171    if (!m) continue;
172    const label = m.name || role.charAt(0).toUpperCase() + role.slice(1);
173    const sub = [];
174    if (m.weeklyAvg) sub.push("avg: " + Math.round(m.weeklyAvg) + "g");
175    if (m.weeklyHitRate > 0) {
176      const hitPct = Math.round(m.weeklyHitRate * 100);
177      sub.push(hitPct < 60 ? `hit: ${hitPct}% (low)` : `hit: ${hitPct}%`);
178    }
179    bars.push({
180      label,
181      current: m.today || 0,
182      goal: m.goal || 0,
183      color: MACRO_COLORS[role] || "#a78bfa",
184      sub: sub.join(" . "),
185      deleteUrl: m.nodeId ? `/api/v1/root/${rootId}/food/metric/${m.nodeId}` : null,
186    });
187  }
188
189  // ── Cards: ordered by relevancy ──
190  const cards = [];
191
192  // Check what kind of day it is
193  const hasMealsToday = recentMeals.some(m => {
194    if (!m.date) return false;
195    return new Date(m.date).toDateString() === new Date().toDateString();
196  });
197
198  // Meal slot cards
199  const mealSlotCards = [];
200  const slotNames = ["breakfast", "lunch", "dinner", "snack"];
201  for (const slot of slotNames) {
202    const meals = mealsBySlot[slot];
203    if (!meals || meals.length === 0) continue;
204    mealSlotCards.push({
205      title: slot.charAt(0).toUpperCase() + slot.slice(1),
206      items: meals.map(m => {
207        const delId = m.logNoteId || m.id;
208        return {
209          text: (m.text || "").slice(0, 120),
210          sub: m.date ? new Date(m.date).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) : "",
211          deleteUrl: delId ? `/api/v1/root/${rootId}/food/entry/${delId}` : null,
212        };
213      }),
214    });
215  }
216
217  // Recent log card (only if no slot cards)
218  const recentLogCard = mealSlotCards.length === 0 && recentMeals.length > 0
219    ? {
220        title: "Recent Log",
221        items: recentMeals.slice(0, 10).map(m => ({
222          text: (m.text || "").slice(0, 120),
223          sub: m.date ? timeAgo(new Date(m.date)) : "",
224          deleteUrl: m.id ? `/api/v1/root/${rootId}/food/entry/${m.id}` : null,
225        })),
226        empty: "No meals logged today. Type below to start.",
227      }
228    : null;
229
230  // History card
231  const historyCard = recentHistory.length > 0
232    ? {
233        title: `Past ${Math.min(recentHistory.length, 7)} Days`,
234        items: recentHistory.slice(0, 7).map(h => {
235          const dayCals = h.calories || Math.round((h.protein || 0) * 4 + (h.carbs || 0) * 4 + (h.fats || 0) * 9);
236          const dayPct = calories.goal > 0 ? Math.round((dayCals / calories.goal) * 100) : 0;
237          return {
238            text: h.date || "?",
239            detail: [`P:${h.protein || 0}g`, `C:${h.carbs || 0}g`, `F:${h.fats || 0}g`],
240            sub: `${dayCals} cal (${dayPct}% of goal)`,
241            bg: true,
242          };
243        }),
244        empty: "History builds as you log meals each day.",
245      }
246    : null;
247
248  // Weekly trends card (only surfaces when there's something to say)
249  const weeklyCard = weeks.length >= 2
250    ? {
251        title: "Weekly Trends",
252        items: weeks.slice(0, 4).map(w => {
253          const parts = [];
254          if (w.calories?.avg) parts.push(`${Math.round(w.calories.avg)} cal/day`);
255          if (w.averages) {
256            const macroStrs = Object.entries(w.averages)
257              .filter(([, v]) => v > 0)
258              .slice(0, 3)
259              .map(([r, v]) => {
260                const hit = w.hitRates?.[r];
261                const hitStr = hit != null ? ` (${Math.round(hit * 100)}%)` : "";
262                return `${r.charAt(0).toUpperCase() + r.slice(1)}:${v}g${hitStr}`;
263              });
264            if (macroStrs.length > 0) parts.push(macroStrs.join(", "));
265          }
266          return {
267            text: `${w.weekStart || "?"} to ${w.weekEnd || "?"}`,
268            sub: parts.join(" . ") || `${w.daysTracked || 0} days tracked`,
269            bg: true,
270          };
271        }),
272      }
273    : null;
274
275  // ── Relevancy ordering ──
276  // Meals logged today? Show them first (confirmation). Otherwise history first (context).
277  if (hasMealsToday) {
278    // Today is active: meals first, then weekly trends if notable, then history
279    cards.push(...mealSlotCards);
280    if (recentLogCard) cards.push(recentLogCard);
281    if (weeklyCard) cards.push(weeklyCard);
282    if (historyCard) cards.push(historyCard);
283  } else {
284    // Quiet day: history and trends surface, meals recede
285    if (weeklyCard) cards.push(weeklyCard);
286    if (historyCard) cards.push(historyCard);
287    cards.push(...mealSlotCards);
288    if (recentLogCard) cards.push(recentLogCard);
289  }
290
291  // If nothing at all, push an empty history card
292  if (cards.length === 0 && historyCard) {
293    cards.push(historyCard);
294  }
295
296  // ── Add metric input ──
297  const addMetricHtml = bars.length === 0 ? "" : `
298    <div style="display:flex;gap:8px;margin-top:8px;align-items:center">
299      <input id="newMetricName" type="text" placeholder="Add metric (sugar, fiber, sodium...)"
300        style="flex:1;padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);color:#fff;font-size:0.85rem;outline:none" />
301      <input id="newMetricGoal" type="number" placeholder="Goal (g)"
302        style="width:80px;padding:8px 10px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);color:#fff;font-size:0.85rem;outline:none" />
303      <button onclick="addMetric()" style="padding:8px 14px;border-radius:8px;border:none;background:rgba(72,187,120,0.15);color:#4ade80;font-size:0.85rem;cursor:pointer">+</button>
304    </div>`;
305
306  const addMetricJs = `
307    async function addMetric() {
308      const name = document.getElementById('newMetricName').value.trim();
309      if (!name) return;
310      const goal = document.getElementById('newMetricGoal').value || 0;
311      const url = '/api/v1/root/${rootId}/food/metric${token ? "?token=" + token : ""}';
312      const res = await fetch(url, {
313        method: 'POST',
314        headers: { 'Content-Type': 'application/json' },
315        credentials: 'include',
316        body: JSON.stringify({ name, goal: Number(goal) })
317      });
318      if (res.ok) location.reload();
319    }
320    document.getElementById('newMetricName')?.addEventListener('keydown', e => { if (e.key === 'Enter') addMetric(); });
321  `;
322
323  // ── Surface CSS ──
324  const surfaceCss = `
325    .surface-text {
326      text-align: center; padding: 12px 16px; margin-bottom: 4px;
327      font-size: 0.95rem; line-height: 1.5; font-weight: 400;
328      letter-spacing: 0.01em;
329    }
330  `;
331
332  // ── Surface HTML (injected before hero via subtitle slot) ──
333  // We pass it as a custom afterBars? No. Better: use the hero.sub field for the surface.
334  // The surface IS the hero's subtext. The number is the anchor. The words give it meaning.
335
336  return renderAppDashboard({
337    rootId, rootName, token, userId, inApp,
338    subtitle: profileParts.join(" . ") || null,
339    hero: {
340      value: String(Math.round(calories.today || 0)),
341      label: `of ${Math.round(calories.goal || 0)} calories (${calPct}%)`,
342      sub: surface.text,
343      color: calPct >= 90 ? "#48bb78" : calPct >= 60 ? "#ecc94b" : "#fff",
344    },
345    stats,
346    bars,
347    afterBars: addMetricHtml,
348    extraCss: surfaceCss,
349    extraJs: addMetricJs,
350    cards,
351    commands: [
352      { cmd: "food <message>", desc: "Log what you ate" },
353      { cmd: "food daily", desc: "Today's macro summary" },
354      { cmd: "food week", desc: "Weekly averages" },
355      { cmd: "be", desc: "Start logging meals" },
356    ],
357    chatBar: { placeholder: "What did you eat? Or ask about your macros...", endpoint: `/api/v1/root/${rootId}/food` },
358  });
359}
360
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 { isInitialized, findFoodNodes, getDailyPicture, getHistory } from "./core.js";
8import { handleMessage } from "./handler.js";
9
10let Node = NodeModel;
11export function setServices({ Node: N }) { if (N) Node = N; }
12
13// Auth middleware that accepts both cookie JWT and URL token (for frontend HTML pages)
14async function htmlAuth(req, res, next) {
15  if (req.query.token) {
16    try { const urlAuth = (await import("../html-rendering/urlAuth.js")).default; return urlAuth(req, res, next); } catch {}
17  }
18  return authenticate(req, res, next);
19}
20
21const router = express.Router();
22
23// ── HTML Dashboard (GET with ?html) ──
24router.get("/root/:rootId/food", async (req, res, next) => {
25  if (!("html" in req.query)) return next();
26  try {
27    const { isHtmlEnabled } = await import("../html-rendering/config.js");
28    if (!isHtmlEnabled()) return next();
29    const urlAuth = (await import("../html-rendering/urlAuth.js")).default;
30    urlAuth(req, res, async () => {
31      const { rootId } = req.params;
32      const root = await Node.findById(rootId).select("name metadata").lean();
33      if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Not found");
34      let picture = null;
35      let weeklySummaries = [];
36      if (await isInitialized(rootId)) {
37        const { getDailyPicture, getHistory } = await import("./core.js");
38        picture = await getDailyPicture(rootId);
39        weeklySummaries = await getHistory(rootId, { limit: 8, type: "weekly" });
40      }
41      const { renderFoodDashboard } = await import("./pages/dashboard.js");
42      res.send(renderFoodDashboard({ rootId, rootName: root.name, picture, weeklySummaries, token: req.query.token || null, userId: req.userId, inApp: !!req.query.inApp }));
43    });
44  } catch (err) {
45    sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
46  }
47});
48
49/**
50 * POST /root/:rootId/food
51 *
52 * Three paths:
53 * 1. First use (not initialized): scaffold tree, run coach mode for setup conversation
54 * 2. Food input (has food words): parse, cascade, respond with totals
55 * 3. Questions/advice: route to coach mode at the Daily node
56 */
57router.post("/root/:rootId/food", authenticate, async (req, res) => {
58  try {
59    const { rootId } = req.params;
60    const rawMessage = req.body.message;
61    const message = Array.isArray(rawMessage) ? rawMessage.join(" ") : rawMessage;
62    if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
63
64    const root = await Node.findById(rootId).select("rootOwner contributors name").lean();
65    if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
66
67    const userId = req.userId;
68    const isOwner = root.rootOwner?.toString() === userId;
69    const isContributor = root.contributors?.some(c => c.toString() === userId);
70    if (!isOwner && !isContributor) {
71      return sendError(res, 403, ERR.FORBIDDEN, "No access to this tree");
72    }
73
74    // Check spatial scope
75    const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
76    if (await isExtensionBlockedAtNode("food", rootId)) {
77      return sendError(res, 403, ERR.EXTENSION_BLOCKED, "Food tracking is blocked on this branch.");
78    }
79
80    const user = await UserModel.findById(userId).select("username").lean();
81    const username = user?.username || "user";
82
83    const result = await handleMessage(message, { userId, username, rootId, res });
84    if (!res.headersSent) sendOk(res, result);
85  } catch (err) {
86    log.error("Food", "Route error:", err.message);
87    if (!res.headersSent) sendError(res, 500, ERR.INTERNAL, err.message);
88  }
89});
90
91/**
92 * GET /root/:rootId/food/daily
93 * Today's nutrition dashboard.
94 */
95router.get("/root/:rootId/food/daily", authenticate, async (req, res) => {
96  try {
97    const picture = await getDailyPicture(req.params.rootId);
98    if (!picture) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not found or not initialized");
99
100    // Format for CLI readability
101    const lines = [];
102    const cal = picture.calories || {};
103    lines.push(`Calories: ${cal.today || 0}${cal.goal ? " / " + cal.goal : ""}`);
104    for (const role of (picture._valueRoles || [])) {
105      const m = picture[role];
106      if (m) lines.push(`${m.name || role}: ${m.today || 0}g${m.goal ? " / " + m.goal + "g" : ""}`);
107    }
108    if (picture.recentMeals?.length > 0) {
109      lines.push("");
110      lines.push("Today's meals:");
111      for (const m of picture.recentMeals.slice(0, 10)) {
112        lines.push(`  ${m.text || m.summary || "?"}`);
113      }
114    }
115
116    sendOk(res, { answer: lines.join("\n"), raw: picture });
117  } catch (err) {
118    sendError(res, 500, ERR.INTERNAL, err.message);
119  }
120});
121
122/**
123 * GET /root/:rootId/food/week
124 * Weekly nutrition review (last 7 days from History node).
125 */
126router.get("/root/:rootId/food/week", authenticate, async (req, res) => {
127  try {
128    const foodNodes = await findFoodNodes(req.params.rootId);
129    if (!foodNodes?.history) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not found");
130
131    const Note = (await import("../../seed/models/note.js")).default;
132    const notes = await Note.find({ nodeId: foodNodes.history.id })
133      .sort({ createdAt: -1 })
134      .limit(7)
135      .select("content createdAt")
136      .lean();
137
138    const days = notes
139      .map(n => { try { return JSON.parse(n.content); } catch { return null; } })
140      .filter(Boolean);
141
142    // Format for CLI readability
143    const lines = [];
144    if (days.length === 0) {
145      lines.push("No history yet. Log meals daily and the weekly picture builds.");
146    } else {
147      for (const d of days) {
148        if (d.type === "weekly") continue;
149        const cal = d.calories || Math.round((d.protein || 0) * 4 + (d.carbs || 0) * 4 + (d.fats || 0) * 9);
150        const parts = [`P:${d.protein || 0}g`, `C:${d.carbs || 0}g`, `F:${d.fats || 0}g`];
151        lines.push(`${d.date || "?"}: ${cal} cal (${parts.join(", ")})`);
152      }
153    }
154
155    sendOk(res, { answer: lines.join("\n"), days, count: days.length });
156  } catch (err) {
157    sendError(res, 500, ERR.INTERNAL, err.message);
158  }
159});
160
161/**
162 * GET /root/:rootId/food/history
163 * Long-range history. ?days=90 (default 90), ?type=daily|weekly (optional filter).
164 */
165router.get("/root/:rootId/food/history", authenticate, async (req, res) => {
166  try {
167    const days = Math.min(Number(req.query.days) || 90, 365);
168    const type = ["daily", "weekly"].includes(req.query.type) ? req.query.type : null;
169    const entries = await getHistory(req.params.rootId, { limit: days, type });
170    if (!entries) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not found");
171    sendOk(res, { entries, count: entries.length });
172  } catch (err) {
173    sendError(res, 500, ERR.INTERNAL, err.message);
174  }
175});
176
177/**
178 * GET /root/:rootId/food/weekly
179 * Weekly summary notes only.
180 */
181router.get("/root/:rootId/food/weekly", authenticate, async (req, res) => {
182  try {
183    const limit = Math.min(Number(req.query.limit) || 12, 52);
184    const entries = await getHistory(req.params.rootId, { limit, type: "weekly" });
185    if (!entries) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not found");
186    sendOk(res, { weeks: entries, count: entries.length });
187  } catch (err) {
188    sendError(res, 500, ERR.INTERNAL, err.message);
189  }
190});
191
192/**
193 * GET /root/:rootId/food/profile
194 * Dietary profile and goals.
195 */
196router.get("/root/:rootId/food/profile", authenticate, async (req, res) => {
197  try {
198    const picture = await getDailyPicture(req.params.rootId);
199    if (!picture) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Food tree not found");
200    sendOk(res, { profile: picture.profile || null, macros: { protein: picture.protein, carbs: picture.carbs, fats: picture.fats } });
201  } catch (err) {
202    sendError(res, 500, ERR.INTERNAL, err.message);
203  }
204});
205
206/**
207 * DELETE /root/:rootId/food/entry/:noteId
208 * Delete a meal entry and decrement the metric values it added.
209 */
210router.delete("/root/:rootId/food/entry/:noteId", htmlAuth, async (req, res) => {
211  try {
212    const { rootId, noteId } = req.params;
213    const userId = req.userId;
214    const Note = (await import("../../seed/models/note.js")).default;
215    const note = await Note.findById(noteId).lean();
216    if (!note) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Note not found");
217
218    // Already soft-deleted. Don't decrement again.
219    if (note.nodeId === "DELETED" || note.userId === "DELETED") {
220      return sendOk(res, { deleted: true, alreadyDeleted: true });
221    }
222
223    // Parse totals from this note or its linked log note
224    let totals = null;
225    let logNoteId = null;
226    try {
227      const data = JSON.parse(note.content);
228      totals = data.totals || null;
229      logNoteId = data.logNoteId || null;
230    } catch {}
231
232    if (!totals && logNoteId) {
233      try {
234        const logNote = await Note.findById(logNoteId).lean();
235        if (logNote) {
236          try { totals = JSON.parse(logNote.content).totals || null; } catch {}
237        }
238      } catch {}
239    }
240
241    // Decrement metric values before deleting
242    if (totals) {
243      const foodNodes = await findFoodNodes(rootId);
244      const { STRUCTURAL_ROLES } = await import("./core.js");
245      const { incExtMeta } = await import("../../seed/tree/extensionMetadata.js");
246      for (const [role, info] of Object.entries(foodNodes)) {
247        if (STRUCTURAL_ROLES.includes(role) || !info?.id) continue;
248        const amount = totals[role] || 0;
249        if (amount > 0) {
250          await incExtMeta(info.id, "values", "today", -amount);
251        }
252      }
253    }
254
255    // Use kernel delete (soft-delete, fires afterNote hook)
256    const { deleteNoteAndFile } = await import("../../seed/tree/notes.js");
257    await deleteNoteAndFile({ noteId, userId });
258
259    // Delete the linked note too (log <-> slot)
260    if (logNoteId) {
261      try { await deleteNoteAndFile({ noteId: logNoteId, userId }); } catch {}
262    } else {
263      // This was a log note. Find and delete its linked slot note.
264      const foodNodes = await findFoodNodes(rootId);
265      if (foodNodes.mealSlots) {
266        const slotNodeIds = Object.values(foodNodes.mealSlots).map(s => s.id);
267        const slotNotes = await Note.find({
268          nodeId: { $in: slotNodeIds },
269        }).select("_id content").lean();
270        for (const sn of slotNotes) {
271          try {
272            const d = JSON.parse(sn.content);
273            if (d.logNoteId === noteId) {
274              await deleteNoteAndFile({ noteId: String(sn._id), userId });
275            }
276          } catch {}
277        }
278      }
279    }
280
281    sendOk(res, { deleted: true, decremented: !!totals });
282  } catch (err) {
283    log.error("Food", "Delete entry error:", err.message);
284    sendError(res, 500, ERR.INTERNAL, err.message);
285  }
286});
287
288/**
289 * POST /root/:rootId/food/metric - Create + adopt a metric node in one call
290 * DELETE /root/:rootId/food/metric/:nodeId - Delete a metric node
291 */
292router.post("/root/:rootId/food/metric", htmlAuth, async (req, res) => {
293  try {
294    const { name, goal } = req.body;
295    if (!name) return sendError(res, 400, ERR.INVALID_INPUT, "name required");
296
297    // Check if a child with this name already exists (unadopted or cleared)
298    const { adoptNode } = await import("./core.js");
299    const existing = await Node.findOne({ parent: req.params.rootId, name: name.trim() }).select("_id").lean();
300    if (existing) {
301      const role = name.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
302      await adoptNode(String(existing._id), role, goal ? Number(goal) : null);
303      sendOk(res, { id: String(existing._id), role });
304      return;
305    }
306
307    const { createNode } = await import("../../seed/tree/treeManagement.js");
308    const node = await createNode({ name: name.trim(), parentId: req.params.rootId, userId: req.userId });
309    const role = name.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
310    await adoptNode(String(node._id), role, goal ? Number(goal) : null);
311    sendOk(res, { id: String(node._id), role }, 201);
312  } catch (err) {
313    sendError(res, 500, ERR.INTERNAL, err.message);
314  }
315});
316
317router.delete("/root/:rootId/food/metric/:nodeId", htmlAuth, async (req, res) => {
318  try {
319    const { unsetExtMeta } = await import("../../seed/tree/extensionMetadata.js");
320    await unsetExtMeta(req.params.nodeId, "food");
321    await unsetExtMeta(req.params.nodeId, "values");
322    await unsetExtMeta(req.params.nodeId, "goals");
323    sendOk(res, { removed: true });
324  } catch (err) {
325    sendError(res, 500, ERR.INTERNAL, err.message);
326  }
327});
328
329export default router;
330
1/**
2 * Food Tools
3 *
4 * Extension-specific tools that call core functions directly.
5 * No MCP nodeId validation issues because these accept rootId.
6 */
7
8import { z } from "zod";
9import { saveProfile, findFoodNodes, adoptNode, deliverMacros, detectMealSlot, writeMealNote, getDailyPicture, STRUCTURAL_ROLES } from "./core.js";
10
11export default function getTools() {
12  return [
13    {
14      name: "food-save-profile",
15      description:
16        "Save the user's nutrition profile. Sets calorie target and metric goals on the tree. " +
17        "Call this after gathering calorie target, goals, and dietary restrictions. " +
18        "Goal keys are dynamic: proteinGoal, carbsGoal, fatsGoal, sugarGoal, fiberGoal, etc. " +
19        "Any key ending in 'Goal' sets the daily goal for the matching metric node.",
20      schema: {
21        rootId: z.string().describe("Food root node ID."),
22        calorieGoal: z.number().optional().describe("Daily calorie target."),
23        proteinGoal: z.number().optional().describe("Daily protein goal in grams."),
24        carbsGoal: z.number().optional().describe("Daily carbs goal in grams."),
25        fatsGoal: z.number().optional().describe("Daily fats goal in grams."),
26        sugarGoal: z.number().optional().describe("Daily sugar goal in grams."),
27        fiberGoal: z.number().optional().describe("Daily fiber goal in grams."),
28        sodiumGoal: z.number().optional().describe("Daily sodium goal in mg."),
29        goal: z.string().optional().describe("Goal type: bulk, cut, maintain, general."),
30        restrictions: z.string().nullable().optional().describe("Dietary restrictions or preferences."),
31        userId: z.string().describe("Injected by server. Ignore."),
32        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
33        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
34      },
35      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
36      handler: async (args) => {
37        try {
38          const { rootId, userId, chatId, sessionId, ...profile } = args;
39          const foodNodes = await findFoodNodes(rootId);
40          if (!foodNodes) return { content: [{ type: "text", text: "Food tree not found." }] };
41          await saveProfile(rootId, profile, foodNodes, userId);
42          const goalSummary = Object.entries(profile)
43            .filter(([k, v]) => k.endsWith("Goal") && v)
44            .map(([k, v]) => `${k.replace("Goal", "")}: ${v}`)
45            .join(", ");
46          return { content: [{ type: "text", text: `Profile saved.${goalSummary ? " Goals: " + goalSummary + "." : ""}` }] };
47        } catch (err) {
48          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
49        }
50      },
51    },
52    {
53      name: "food-adopt-node",
54      description:
55        "Adopt an existing node into the food tree as a tracked metric. " +
56        "Sets metadata.food.role on the node so the food system discovers it. " +
57        "Use when you see unadopted child nodes that should be tracked (e.g. Sugar, Fiber, Sodium).",
58      schema: {
59        nodeId: z.string().describe("The node ID to adopt."),
60        role: z.string().describe("The role name (lowercase, no spaces). e.g. 'sugar', 'fiber', 'sodium'."),
61        goal: z.number().optional().describe("Optional daily goal in grams."),
62        userId: z.string().describe("Injected by server. Ignore."),
63        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
64        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
65      },
66      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
67      handler: async ({ nodeId, role, goal }) => {
68        try {
69          await adoptNode(nodeId, role, goal);
70          return { content: [{ type: "text", text: `Adopted as "${role}".${goal ? ` Goal: ${goal}g/day.` : ""} It will now appear in tracking, dashboard, and daily resets.` }] };
71        } catch (err) {
72          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
73        }
74      },
75    },
76    {
77      name: "food-log-entry",
78      description:
79        "Log a food entry. Updates all tracked metrics atomically, writes to the correct meal slot, " +
80        "and creates a structured log note. Call this ONCE after estimating macros for what the user ate. " +
81        "Pass the items with their macro breakdown and the totals. The tool handles everything else.",
82      schema: {
83        rootId: z.string().describe("Food root node ID."),
84        items: z.array(z.object({
85          name: z.string().describe("Food item name."),
86          protein: z.number().optional().describe("Protein in grams."),
87          carbs: z.number().optional().describe("Carbs in grams."),
88          fats: z.number().optional().describe("Fats in grams."),
89          calories: z.number().optional().describe("Calories."),
90        }).passthrough()).describe("Parsed food items with macro estimates."),
91        totals: z.record(z.number()).describe("Sum of all items. Keys match metric roles: protein, carbs, fats, etc."),
92        meal: z.string().optional().describe("Meal slot: breakfast, lunch, dinner, snack. Auto-detected from time if omitted."),
93        summary: z.string().describe("Readable food description for the log note. e.g. '2 eggs and a cup of rice'"),
94        userId: z.string().describe("Injected by server. Ignore."),
95        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
96        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
97      },
98      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
99      handler: async (args) => {
100        try {
101          const { rootId, items, totals, meal, summary, userId, chatId, sessionId } = args;
102          const foodNodes = await findFoodNodes(rootId);
103          if (!foodNodes) return { content: [{ type: "text", text: "Food tree not found." }] };
104
105          const logNodeId = foodNodes.log?.id;
106          if (!logNodeId) return { content: [{ type: "text", text: "Log node not found in food tree." }] };
107
108          // Write structured note to Log node
109          const { createNote } = await import("../../seed/tree/notes.js");
110          const logContent = JSON.stringify({ items, totals, meal: meal || null, summary });
111          const logNote = await createNote({
112            nodeId: logNodeId,
113            content: logContent,
114            contentType: "text",
115            userId: userId || "SYSTEM",
116            wasAi: true,
117            chatId: chatId ?? null,
118            sessionId: sessionId ?? null,
119          });
120          const logNoteId = logNote?._id ? String(logNote._id) : null;
121
122          // Deliver macros to all metric nodes atomically
123          await deliverMacros(logNodeId, foodNodes, { totals, meal, when: meal });
124
125          // Write to meal slot
126          const slot = detectMealSlot(summary, meal);
127          const mealNoteContent = JSON.stringify({ text: summary, totals, logNoteId });
128          await writeMealNote(foodNodes, slot, mealNoteContent, userId || "SYSTEM", { chatId, sessionId });
129
130          // Build running totals for confirmation
131          const picture = await getDailyPicture(rootId);
132          const runningTotals = [];
133          for (const role of (picture?._valueRoles || [])) {
134            const m = picture[role];
135            if (!m) continue;
136            const goalStr = m.goal > 0 ? `/${m.goal}g` : "g";
137            runningTotals.push(`${m.name || role}: ${m.today}${goalStr}`);
138          }
139          const calStr = picture?.calories
140            ? `${picture.calories.today}${picture.calories.goal > 0 ? `/${picture.calories.goal}` : ""} cal`
141            : null;
142          if (calStr) runningTotals.push(calStr);
143
144          const result = `Logged to ${slot}. ${runningTotals.join(", ")}.`;
145          return { content: [{ type: "text", text: result }] };
146        } catch (err) {
147          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
148        }
149      },
150    },
151  ];
152}
153

Versions

Version Published Downloads
2.1.2 37d ago 0
2.1.1 38d ago 0
2.1.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star food

Comments

Loading comments...

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