223576f0e9a19f1eb7be6cb2596da9094aabe82d26ba2bc6a7183159ebdd6542| Command | Method | Description |
|---|---|---|
food [message...] | POST | Log food or ask about nutrition. |
food-daily | GET | Today's nutrition dashboard. |
food-week | GET | Weekly nutrition review. |
food-profile | GET | Dietary profile and goals. |
enrichContextonCascadebreath:exhale1/**
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}
7741/**
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}
501import 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;
381/**
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}
4911export 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};
851// 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};
1061// 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};
1191// 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};
1041// 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};
1401/**
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}
3601import 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;
3301/**
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
treeos ext star food
Post comments from the CLI: treeos ext comment food "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...