forge-openclaw-plugin 0.2.104 → 0.2.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -270,10 +270,12 @@ const healthLinkInputSchema = () => Type.Object({
270
270
  relationshipType: Type.Optional(Type.String({ minLength: 1 }))
271
271
  });
272
272
  const nutritionMealItemInputSchema = () => Type.Object({
273
+ foodId: Type.Optional(optionalNullableString()),
273
274
  name: Type.String({ minLength: 1 }),
274
275
  brand: optionalNullableString(),
275
276
  quantity: Type.Number({ minimum: 0 }),
276
277
  unit: optionalNullableString(),
278
+ grams: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
277
279
  caloriesKcal: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
278
280
  proteinG: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
279
281
  carbsG: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
@@ -304,16 +306,17 @@ const nutritionFoodLogSchema = () => Type.Object({
304
306
  mealLabel: optionalNullableString(),
305
307
  source: Type.Optional(Type.Union([
306
308
  Type.Literal("manual"),
309
+ Type.Literal("search"),
307
310
  Type.Literal("barcode"),
308
311
  Type.Literal("chatgpt"),
309
312
  Type.Literal("photo"),
310
- Type.Literal("import")
313
+ Type.Literal("saved_meal")
311
314
  ])),
312
315
  confirmationState: Type.Optional(Type.Union([
313
316
  Type.Literal("candidate"),
314
317
  Type.Literal("confirmed"),
315
- Type.Literal("corrected"),
316
- Type.Literal("rejected")
318
+ Type.Literal("needs_review"),
319
+ Type.Literal("discarded")
317
320
  ])),
318
321
  satietyScore: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
319
322
  hungerBefore: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
@@ -878,7 +881,7 @@ export function registerForgePluginTools(api, config) {
878
881
  api.registerTool({
879
882
  name: "forge_log_food",
880
883
  label: "Forge Log Food",
881
- description: "Create a confirmed or candidate food log with explicit meal items, calories, macros, quality tags, hunger, satiety, cravings, and context.",
884
+ description: "Create a confirmed or candidate food log. Search first and pass foodId when reusing a catalog food; for custom foods without foodId, caloriesKcal, proteinG, carbsG, and fatG are required.",
882
885
  parameters: nutritionFoodLogSchema(),
883
886
  async execute(_toolCallId, params) {
884
887
  const typed = params;
@@ -3416,6 +3416,7 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3416
3416
  "Ask whether the question is fat-loss pace, food intake, protein/fiber sufficiency, sport fuel, visual look, water retention, cravings, gut comfort, energy, or one meal reaction.",
3417
3417
  "Use forge_get_weight_loss_overview before asking the user to reconstruct recent food, weight, workouts, or subjective state from memory.",
3418
3418
  "Use forge_parse_food_log_with_chatgpt only for rough meal text or photo descriptions through Forge's configured openai-codex ChatGPT subscription connection, not a metered OpenAI Platform API path.",
3419
+ "Before forge_log_food, search foods or barcode lookup first; reuse a matching result with item.foodId, and create custom/no-foodId items only with researched calories, protein, carbohydrate, and fat.",
3419
3420
  "Use the dedicated nutrition tools for food logs, body check-ins, appearance check-ins, subjective food effects, gut check-ins, nutrition patterns, and N-of-1 experiments instead of generic batch CRUD.",
3420
3421
  "Ask for the one outcome metric that would make a nutrition experiment interpretable before turning repeated observations into a hypothesis."
3421
3422
  ]
@@ -4051,7 +4052,7 @@ function enrichConversationPlaybookWithRouteInfo(playbook) {
4051
4052
  ...buildPlaybookRouteInfo(playbook.focus)
4052
4053
  };
4053
4054
  }
4054
- const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
4055
+ export const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
4055
4056
  {
4056
4057
  toolName: "forge_get_user_directory",
4057
4058
  summary: "Read the live human/bot directory and directional relationship graph.",
@@ -4299,10 +4300,36 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
4299
4300
  notes: [
4300
4301
  "The API path is /api/v1/health/weight-loss and the UI route is /weight-loss.",
4301
4302
  "Use the dedicated nutrition logging tools for food, body, appearance, subjective, gut, and experiment mutations.",
4303
+ "Before logging a custom food, search the local/public nutrition catalog; if there is no match, research calories and macros from reliable internet nutrition sources before creating the custom item.",
4302
4304
  "Food parsing uses Forge's configured openai-codex ChatGPT subscription connection, not the metered OpenAI Platform API."
4303
4305
  ],
4304
4306
  example: '{"userIds":["user_operator"]}'
4305
4307
  },
4308
+ {
4309
+ toolName: "forge_search_foods | forge_search_nutrition_foods",
4310
+ summary: "Search Forge's local custom/cache database plus Open Food Facts and USDA-backed public nutrition sources before logging a food item.",
4311
+ whenToUse: "Use before forge_log_food whenever the user names a food, brand, meal component, or custom food that might already exist.",
4312
+ inputShape: "{ query: string, limit?: number, userIds?: string[] }",
4313
+ requiredFields: ["query"],
4314
+ notes: [
4315
+ "Reuse a returned food by passing its id as item.foodId to forge_log_food.",
4316
+ "The local results include custom foods previously added to Forge, so this prevents duplicate custom-food records.",
4317
+ "If no result is good enough, research nutrition facts on the internet before creating a custom item."
4318
+ ],
4319
+ example: '{"query":"Greek yogurt 2%","limit":5}'
4320
+ },
4321
+ {
4322
+ toolName: "forge_lookup_nutrition_barcode",
4323
+ summary: "Lookup a packaged food by barcode before logging it, using Forge's nutrition catalog adapters.",
4324
+ whenToUse: "Use when the user gives a barcode or a packaged-food photo/label includes one.",
4325
+ inputShape: "{ barcode: string, userIds?: string[] }",
4326
+ requiredFields: ["barcode"],
4327
+ notes: [
4328
+ "Reuse the returned food by passing its id as item.foodId to forge_log_food.",
4329
+ "If barcode lookup fails, search by name and brand before creating a custom food."
4330
+ ],
4331
+ example: '{"barcode":"737628064502"}'
4332
+ },
4306
4333
  {
4307
4334
  toolName: "forge_parse_food_log_with_chatgpt",
4308
4335
  summary: "Parse natural-language food text or a photo description into a candidate Forge nutrition log through the openai-codex ChatGPT subscription connection.",
@@ -4320,13 +4347,16 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
4320
4347
  toolName: "forge_log_food",
4321
4348
  summary: "Create a food log with explicit food items, calories, macros, quality tags, hunger, satiety, cravings, and context.",
4322
4349
  whenToUse: "Use for confirmed manual entries, search/barcode-selected foods, or corrected ChatGPT candidates.",
4323
- inputShape: "{ mealLabel?: string, loggedAt?: string, source?: string, confirmationState?: string, notes?: string, items: Array<{ name, quantity, unit?, caloriesKcal?, proteinG?, carbsG?, fatG?, fiberG?, tags? }>, userIds?: string[] }",
4350
+ inputShape: "{ mealLabel?: string, loggedAt?: string, source?: string, confirmationState?: string, notes?: string, items: Array<{ foodId?, name, quantity, unit?, grams?, caloriesKcal?, proteinG?, carbsG?, fatG?, fiberG?, tags? }>, userIds?: string[] }",
4324
4351
  requiredFields: ["items"],
4325
4352
  notes: [
4353
+ "Always call forge_search_foods/forge_search_nutrition_foods or barcode lookup first and pass item.foodId when there is a good match.",
4354
+ "If item.foodId is omitted, Forge creates or reuses a local custom food. Custom foods must include caloriesKcal, proteinG, carbsG, and fatG; name-only custom foods are rejected.",
4355
+ "If the user does not know those nutrition facts, look them up on the internet from a reliable source before calling forge_log_food, or log a candidate only after estimating all four required values.",
4326
4356
  "Prefer confirmed entries when the user gave concrete amounts.",
4327
4357
  "Use candidate entries for rough estimates that need later correction."
4328
4358
  ],
4329
- example: '{"mealLabel":"post-workout","items":[{"name":"Greek yogurt","quantity":250,"unit":"g","caloriesKcal":180,"proteinG":25}]}'
4359
+ example: '{"mealLabel":"post-workout","items":[{"foodId":"food_123","name":"Greek yogurt","quantity":250,"unit":"g"}]}'
4330
4360
  },
4331
4361
  {
4332
4362
  toolName: "forge_log_body_checkin | forge_log_appearance_checkin | forge_log_subjective_food_effect | forge_log_gut_checkin",
@@ -4710,7 +4740,7 @@ function buildAgentOnboardingPayload(request) {
4710
4740
  movement: "Forge Movement is the first-class mobility surface. It is a timeline of stays and trips: stays capture time spent in the same place, and trips capture travel between places. Use it for time-in-place questions, travel-history review, specific stay or trip edits, selected-span aggregates, known places, and links to other Forge records rather than pretending stays and trips are normal batch CRUD entities.",
4711
4741
  lifeForce: "Life Force is Forge's energy-budget and fatigue model. Read it through the dedicated life-force state and update it through focused profile, weekday-template, and fatigue-signal routes rather than generic entity CRUD.",
4712
4742
  workbench: "Workbench is Forge's graph-flow execution system. Treat flows, runs, published outputs, node results, and latest-node-output reads as a dedicated API family instead of a normal entity-batch surface.",
4713
- weightLoss: "Weight Loss is Forge's nutrition, body-composition, sport-fueling, appearance, gut-comfort, craving, and subjective-energy surface. Read it through the health overview route and use the dedicated nutrition tools for food logs, body check-ins, appearance check-ins, subjective effects, gut check-ins, and N-of-1 experiments instead of inventing batch CRUD records.",
4743
+ weightLoss: "Weight Loss is Forge's nutrition, body-composition, sport-fueling, appearance, gut-comfort, craving, and subjective-energy surface. Read it through the health overview route and use the dedicated nutrition tools for food logs, reusable searched/custom food catalog entries, body check-ins, appearance check-ins, subjective effects, gut check-ins, and N-of-1 experiments instead of inventing batch CRUD records.",
4714
4744
  psyche: "Forge Psyche is the reflective domain for values, patterns, behaviors, beliefs, modes, flashcards, and trigger reports. It is sensitive and should be handled deliberately."
4715
4745
  },
4716
4746
  psycheSubmoduleModel: {
@@ -37,6 +37,7 @@ const mealItemInputSchema = z.preprocess((value) => {
37
37
  id: z.string().trim().min(1).optional(),
38
38
  foodId: z.string().trim().min(1).nullable().optional(),
39
39
  name: z.string().trim().min(1),
40
+ brand: z.string().trim().nullable().optional(),
40
41
  quantity: z.coerce.number().positive().default(1),
41
42
  unit: z.string().trim().min(1).default("serving"),
42
43
  grams: optionalNumberSchema,
@@ -54,6 +55,29 @@ const mealItemInputSchema = z.preprocess((value) => {
54
55
  nutrients: z.record(z.string(), z.unknown()).default({}),
55
56
  confidence: z.coerce.number().min(0).max(1).default(0.65)
56
57
  }));
58
+ const requiredNutritionFields = [
59
+ "calories",
60
+ "proteinGrams",
61
+ "carbohydrateGrams",
62
+ "fatGrams"
63
+ ];
64
+ function hasNutritionValue(value) {
65
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
66
+ }
67
+ function missingRequiredNutritionFields(input) {
68
+ return requiredNutritionFields.filter((field) => !hasNutritionValue(input[field]));
69
+ }
70
+ function nutritionRequirementError(path, name) {
71
+ return new z.ZodError([
72
+ {
73
+ code: z.ZodIssueCode.custom,
74
+ path,
75
+ message: `Food item "${name}" must include calories, proteinGrams, ` +
76
+ "carbohydrateGrams, and fatGrams. Search Forge/public nutrition " +
77
+ "sources first, or add a custom food only after researching those facts."
78
+ }
79
+ ]);
80
+ }
57
81
  export const nutritionFoodSearchSchema = z.object({
58
82
  query: z.string().trim().min(1),
59
83
  limit: z.coerce.number().int().min(1).max(25).default(12)
@@ -575,6 +599,141 @@ function mapFood(row) {
575
599
  updatedAt: row.updated_at
576
600
  };
577
601
  }
602
+ function getFoodCatalogById(foodId) {
603
+ const row = getDatabase()
604
+ .prepare(`SELECT * FROM nutrition_food_catalog WHERE id = ?`)
605
+ .get(foodId);
606
+ return row ? mapFood(row) : null;
607
+ }
608
+ function consumedGramQuantity(input) {
609
+ if (hasNutritionValue(input.grams) && input.grams > 0) {
610
+ return input.grams;
611
+ }
612
+ const unit = normalizeCustomFoodToken(input.unit);
613
+ if (hasNutritionValue(input.quantity) &&
614
+ input.quantity > 0 &&
615
+ ["g", "gram", "grams"].includes(unit)) {
616
+ return input.quantity;
617
+ }
618
+ return null;
619
+ }
620
+ function catalogScaleFactor(input, food) {
621
+ const consumedGrams = consumedGramQuantity(input);
622
+ if (consumedGrams != null &&
623
+ hasNutritionValue(food.servingGrams) &&
624
+ food.servingGrams > 0) {
625
+ return consumedGrams / food.servingGrams;
626
+ }
627
+ const unit = normalizeCustomFoodToken(input.unit);
628
+ if (hasNutritionValue(input.quantity) &&
629
+ input.quantity > 0 &&
630
+ (!unit || ["serving", "servings"].includes(unit))) {
631
+ return input.quantity;
632
+ }
633
+ return 1;
634
+ }
635
+ function scaledCatalogNutrition(value, scaleFactor) {
636
+ return hasNutritionValue(value) ? round(value * scaleFactor, 2) : undefined;
637
+ }
638
+ function normalizeCustomFoodToken(value) {
639
+ return (value ?? "")
640
+ .trim()
641
+ .toLowerCase()
642
+ .normalize("NFKD")
643
+ .replace(/[\u0300-\u036f]/g, "")
644
+ .replace(/[^a-z0-9]+/g, "-")
645
+ .replace(/^-+|-+$/g, "");
646
+ }
647
+ function customFoodServingKey(input) {
648
+ if (hasNutritionValue(input.grams) && input.grams > 0) {
649
+ return `${round(input.grams, 1)}g`;
650
+ }
651
+ return `${round(input.quantity, 3)}-${normalizeCustomFoodToken(input.unit) || "serving"}`;
652
+ }
653
+ function customFoodSourceId(input) {
654
+ const nameKey = normalizeCustomFoodToken(input.name);
655
+ const brandKey = normalizeCustomFoodToken(input.brand ?? "");
656
+ return [nameKey, brandKey, customFoodServingKey(input)]
657
+ .filter(Boolean)
658
+ .join(":");
659
+ }
660
+ function cacheCustomFood(input) {
661
+ return cacheFood({
662
+ source: "custom",
663
+ sourceId: customFoodSourceId(input),
664
+ name: input.name,
665
+ brand: input.brand ?? "",
666
+ servingLabel: input.unit && input.unit !== "serving"
667
+ ? `${input.quantity} ${input.unit}`
668
+ : "1 serving",
669
+ servingGrams: input.grams ?? null,
670
+ calories: input.calories,
671
+ proteinGrams: input.proteinGrams,
672
+ carbohydrateGrams: input.carbohydrateGrams,
673
+ fatGrams: input.fatGrams,
674
+ fiberGrams: input.fiberGrams ?? null,
675
+ sugarGrams: input.sugarGrams ?? null,
676
+ sodiumMg: input.sodiumMg ?? null,
677
+ potassiumMg: input.potassiumMg ?? null,
678
+ tags: Array.from(new Set(["custom", ...input.tags])),
679
+ nutrients: {
680
+ ...input.nutrients,
681
+ customFood: true,
682
+ nutritionBasis: "agent_or_user_supplied"
683
+ },
684
+ confidence: input.confidence
685
+ });
686
+ }
687
+ function resolveMealItemForInsert(input, options) {
688
+ let item = input;
689
+ if (input.foodId) {
690
+ const food = getFoodCatalogById(input.foodId);
691
+ if (!food) {
692
+ throw new z.ZodError([
693
+ {
694
+ code: z.ZodIssueCode.custom,
695
+ path: ["items", "foodId"],
696
+ message: `Food catalog item ${input.foodId} was not found. Search foods again before logging.`
697
+ }
698
+ ]);
699
+ }
700
+ const scaleFactor = catalogScaleFactor(input, food);
701
+ item = {
702
+ ...input,
703
+ name: input.name || food.name,
704
+ calories: input.calories ?? scaledCatalogNutrition(food.calories, scaleFactor),
705
+ proteinGrams: input.proteinGrams ??
706
+ scaledCatalogNutrition(food.proteinGrams, scaleFactor),
707
+ carbohydrateGrams: input.carbohydrateGrams ??
708
+ scaledCatalogNutrition(food.carbohydrateGrams, scaleFactor),
709
+ fatGrams: input.fatGrams ?? scaledCatalogNutrition(food.fatGrams, scaleFactor),
710
+ fiberGrams: input.fiberGrams ??
711
+ scaledCatalogNutrition(food.fiberGrams, scaleFactor),
712
+ sugarGrams: input.sugarGrams ??
713
+ scaledCatalogNutrition(food.sugarGrams, scaleFactor),
714
+ sodiumMg: input.sodiumMg ?? scaledCatalogNutrition(food.sodiumMg, scaleFactor),
715
+ potassiumMg: input.potassiumMg ??
716
+ scaledCatalogNutrition(food.potassiumMg, scaleFactor),
717
+ caffeineMg: input.caffeineMg ??
718
+ scaledCatalogNutrition(food.caffeineMg, scaleFactor),
719
+ alcoholGrams: input.alcoholGrams ??
720
+ scaledCatalogNutrition(food.alcoholGrams, scaleFactor),
721
+ nutrients: Object.keys(input.nutrients).length > 0
722
+ ? input.nutrients
723
+ : food.nutrients,
724
+ confidence: input.confidence ?? food.confidence
725
+ };
726
+ }
727
+ const missing = missingRequiredNutritionFields(item);
728
+ if (missing.length > 0) {
729
+ throw nutritionRequirementError(["items"], item.name);
730
+ }
731
+ if (!item.foodId && options.cacheConfirmedCustomFood) {
732
+ const customFood = cacheCustomFood(item);
733
+ item = { ...item, foodId: customFood.id };
734
+ }
735
+ return item;
736
+ }
578
737
  function mapItem(row) {
579
738
  return {
580
739
  id: row.id,
@@ -685,9 +844,10 @@ function listFoodLogs(userId, limit = 120) {
685
844
  const itemsByLog = readMealItems(rows.map((row) => row.id));
686
845
  return rows.map((row) => mapFoodLog(row, itemsByLog.get(row.id) ?? []));
687
846
  }
688
- function insertMealItem(logId, input) {
847
+ function insertMealItem(logId, input, options) {
848
+ const item = resolveMealItemForInsert(input, options);
689
849
  const now = nowIso();
690
- const id = input.id ?? newId("meal_item");
850
+ const id = item.id ?? newId("meal_item");
691
851
  getDatabase()
692
852
  .prepare(`INSERT INTO nutrition_meal_items (
693
853
  id, log_id, food_id, name, quantity, unit, grams, calories,
@@ -695,7 +855,41 @@ function insertMealItem(logId, input) {
695
855
  sodium_mg, potassium_mg, caffeine_mg, alcohol_grams, tags_json,
696
856
  nutrients_json, confidence, created_at, updated_at
697
857
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
698
- .run(id, logId, input.foodId ?? null, input.name, input.quantity, input.unit, input.grams ?? null, input.calories ?? null, input.proteinGrams ?? null, input.carbohydrateGrams ?? null, input.fatGrams ?? null, input.fiberGrams ?? null, input.sugarGrams ?? null, input.sodiumMg ?? null, input.potassiumMg ?? null, input.caffeineMg ?? null, input.alcoholGrams ?? null, jsonString(input.tags), jsonString(input.nutrients), input.confidence, now, now);
858
+ .run(id, logId, item.foodId ?? null, item.name, item.quantity, item.unit, item.grams ?? null, item.calories ?? null, item.proteinGrams ?? null, item.carbohydrateGrams ?? null, item.fatGrams ?? null, item.fiberGrams ?? null, item.sugarGrams ?? null, item.sodiumMg ?? null, item.potassiumMg ?? null, item.caffeineMg ?? null, item.alcoholGrams ?? null, jsonString(item.tags), jsonString(item.nutrients), item.confidence, now, now);
859
+ }
860
+ function cacheConfirmedCustomMealItems(logId) {
861
+ const rows = readMealItems([logId]).get(logId) ?? [];
862
+ for (const row of rows) {
863
+ if (row.food_id) {
864
+ continue;
865
+ }
866
+ const item = resolveMealItemForInsert({
867
+ id: row.id,
868
+ foodId: null,
869
+ name: row.name,
870
+ quantity: row.quantity,
871
+ unit: row.unit,
872
+ grams: row.grams ?? undefined,
873
+ calories: row.calories ?? undefined,
874
+ proteinGrams: row.protein_grams ?? undefined,
875
+ carbohydrateGrams: row.carbohydrate_grams ?? undefined,
876
+ fatGrams: row.fat_grams ?? undefined,
877
+ fiberGrams: row.fiber_grams ?? undefined,
878
+ sugarGrams: row.sugar_grams ?? undefined,
879
+ sodiumMg: row.sodium_mg ?? undefined,
880
+ potassiumMg: row.potassium_mg ?? undefined,
881
+ caffeineMg: row.caffeine_mg ?? undefined,
882
+ alcoholGrams: row.alcohol_grams ?? undefined,
883
+ tags: parseJson(row.tags_json, []),
884
+ nutrients: parseJson(row.nutrients_json, {}),
885
+ confidence: row.confidence
886
+ }, { cacheConfirmedCustomFood: true });
887
+ getDatabase()
888
+ .prepare(`UPDATE nutrition_meal_items
889
+ SET food_id = ?, updated_at = ?
890
+ WHERE id = ?`)
891
+ .run(item.foodId ?? null, nowIso(), row.id);
892
+ }
699
893
  }
700
894
  export function createNutritionFoodLog(input) {
701
895
  const parsed = nutritionFoodLogCreateSchema.parse(input);
@@ -713,7 +907,9 @@ export function createNutritionFoodLog(input) {
713
907
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
714
908
  .run(id, userId, loggedAt, parsed.mealLabel, parsed.source, parsed.confirmationState, parsed.notes, parsed.placeId ?? null, parsed.stayId ?? null, parsed.workoutId ?? null, parsed.sleepId ?? null, logDayKey, jsonString(parsed.imageRefs), jsonString(parsed.parserProvenance), jsonString(parsed.links), now, now);
715
909
  for (const item of parsed.items) {
716
- insertMealItem(id, item);
910
+ insertMealItem(id, item, {
911
+ cacheConfirmedCustomFood: parsed.confirmationState === "confirmed"
912
+ });
717
913
  }
718
914
  });
719
915
  return getNutritionFoodLogById(id);
@@ -726,6 +922,7 @@ export function patchNutritionFoodLog(logId, input) {
726
922
  }
727
923
  const nextLoggedAt = parsed.loggedAt ?? existing.loggedAt;
728
924
  const nextDayKey = normalizeDayKey(parsed.dayKey, nextLoggedAt);
925
+ const nextConfirmationState = parsed.confirmationState ?? existing.confirmationState;
729
926
  const now = nowIso();
730
927
  runInTransaction(() => {
731
928
  getDatabase()
@@ -735,15 +932,20 @@ export function patchNutritionFoodLog(logId, input) {
735
932
  day_key = ?, image_refs_json = ?, parser_provenance_json = ?,
736
933
  links_json = ?, updated_at = ?
737
934
  WHERE id = ?`)
738
- .run(nextLoggedAt, parsed.mealLabel ?? existing.mealLabel, parsed.source ?? existing.source, parsed.confirmationState ?? existing.confirmationState, parsed.notes ?? existing.notes, parsed.placeId !== undefined ? parsed.placeId : existing.placeId, parsed.stayId !== undefined ? parsed.stayId : existing.stayId, parsed.workoutId !== undefined ? parsed.workoutId : existing.workoutId, parsed.sleepId !== undefined ? parsed.sleepId : existing.sleepId, nextDayKey, jsonString(parsed.imageRefs ?? existing.imageRefs), jsonString(parsed.parserProvenance ?? existing.parserProvenance), jsonString(parsed.links ?? existing.links), now, logId);
935
+ .run(nextLoggedAt, parsed.mealLabel ?? existing.mealLabel, parsed.source ?? existing.source, nextConfirmationState, parsed.notes ?? existing.notes, parsed.placeId !== undefined ? parsed.placeId : existing.placeId, parsed.stayId !== undefined ? parsed.stayId : existing.stayId, parsed.workoutId !== undefined ? parsed.workoutId : existing.workoutId, parsed.sleepId !== undefined ? parsed.sleepId : existing.sleepId, nextDayKey, jsonString(parsed.imageRefs ?? existing.imageRefs), jsonString(parsed.parserProvenance ?? existing.parserProvenance), jsonString(parsed.links ?? existing.links), now, logId);
739
936
  if (parsed.items) {
740
937
  getDatabase()
741
938
  .prepare(`DELETE FROM nutrition_meal_items WHERE log_id = ?`)
742
939
  .run(logId);
743
940
  for (const item of parsed.items) {
744
- insertMealItem(logId, item);
941
+ insertMealItem(logId, item, {
942
+ cacheConfirmedCustomFood: nextConfirmationState === "confirmed"
943
+ });
745
944
  }
746
945
  }
946
+ else if (nextConfirmationState === "confirmed") {
947
+ cacheConfirmedCustomMealItems(logId);
948
+ }
747
949
  });
748
950
  return getNutritionFoodLogById(logId);
749
951
  }
@@ -5286,11 +5286,14 @@ export function buildOpenApiDocument() {
5286
5286
  type: "object",
5287
5287
  additionalProperties: false,
5288
5288
  required: ["name", "quantity"],
5289
+ description: "Meal item input. Pass foodId from /foods/search or /foods/barcode when reusing a catalog food. If foodId is omitted, Forge treats the item as a custom food and requires calories, proteinGrams, carbohydrateGrams, and fatGrams.",
5289
5290
  properties: {
5291
+ foodId: nullable({ type: "string" }),
5290
5292
  name: { type: "string" },
5291
5293
  brand: nullable({ type: "string" }),
5292
5294
  quantity: { type: "number" },
5293
5295
  unit: nullable({ type: "string" }),
5296
+ grams: nullable({ type: "number" }),
5294
5297
  calories: nullable({ type: "number" }),
5295
5298
  proteinGrams: nullable({ type: "number" }),
5296
5299
  carbohydrateGrams: nullable({ type: "number" }),
@@ -5926,6 +5929,7 @@ export function buildOpenApiDocument() {
5926
5929
  post: {
5927
5930
  tags: ["Health"],
5928
5931
  summary: "Search nutrition foods across local and public catalogs",
5932
+ description: "Searches Forge's local nutrition_food_catalog first, including custom foods, then public Open Food Facts and USDA-backed sources. Reuse returned ids as NutritionMealItemInput.foodId before creating a new custom food.",
5929
5933
  requestBody: {
5930
5934
  content: {
5931
5935
  "application/json": {
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.104",
5
+ "version": "0.2.105",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.104",
3
+ "version": "0.2.105",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -208,6 +208,13 @@ Health rule:
208
208
  `forge_log_appearance_checkin`, `forge_log_subjective_food_effect`,
209
209
  `forge_log_gut_checkin`, `forge_get_nutrition_patterns`,
210
210
  `forge_start_nutrition_experiment`, and `forge_update_nutrition_experiment`.
211
+ Before logging food, search Forge's nutrition catalog first. If a result matches,
212
+ pass that result's `id` as `item.foodId` to `forge_log_food` so Forge reuses the
213
+ local/custom food database instead of creating a duplicate. If no result matches
214
+ and you are creating a custom food, look up nutrition facts on the internet or
215
+ another reliable public nutrition source before logging it. Custom/no-`foodId`
216
+ items must include at least calories plus protein, carbohydrate, and fat
217
+ (`caloriesKcal`, `proteinG`, `carbsG`, `fatG`); never save a name-only food.
211
218
  `forge_parse_food_log_with_chatgpt` must use Forge's configured `openai-codex`
212
219
  ChatGPT subscription connection, not a metered OpenAI Platform API path.
213
220
  - In `forge_get_agent_onboarding.entityRouteModel.readModelOnlySurfaces`, operator,
@@ -702,7 +709,7 @@ Use the health tools when the request is about sleep or sports review:
702
709
  - `forge_get_training_load_overview` to inspect cardiovascular load, HR zone balance, zone-time buckets, smart training modes, acute/chronic stress, high-intensity pressure, VO2max context, next-workout guidance, and training target fit
703
710
  - `forge_get_weight_loss_overview` to inspect calorie balance, protein/fiber targets, body trend, food quality, training fuel, subjective energy, gut comfort, aesthetic look, hypotheses, and experiments
704
711
  - `forge_parse_food_log_with_chatgpt` to convert rough meal text or a photo description into a candidate food log through the configured `openai-codex` ChatGPT subscription connection
705
- - `forge_log_food`, `forge_log_body_checkin`, `forge_log_appearance_checkin`, `forge_log_subjective_food_effect`, and `forge_log_gut_checkin` to preserve the user's food, body-composition, visual-look, energy/craving/performance, and gut-health evidence
712
+ - `forge_log_food`, `forge_log_body_checkin`, `forge_log_appearance_checkin`, `forge_log_subjective_food_effect`, and `forge_log_gut_checkin` to preserve the user's food, body-composition, visual-look, energy/craving/performance, and gut-health evidence. For `forge_log_food`, search first and reuse `item.foodId`; custom/no-`foodId` items require calories plus protein, carbohydrate, and fat, researched before logging when the user does not provide them.
706
713
  - `forge_get_nutrition_patterns`, `forge_start_nutrition_experiment`, and `forge_update_nutrition_experiment` to turn repeated food/body observations into testable N-of-1 hypotheses
707
714
  - `forge_update_sleep_session` to add sleep-quality notes, tags, or links back to Forge entities after review
708
715
  - `forge_update_workout_session` to add subjective effort, mood, meaning, tags, or links on one workout after review
@@ -415,7 +415,10 @@ still knowing the exact write/read family before it acts.
415
415
  hypotheses, or nutrition experiments. Use `forge_parse_food_log_with_chatgpt`
416
416
  for rough meal text/photo descriptions through the configured `openai-codex`
417
417
  ChatGPT subscription connection, then use `forge_log_food` and the body,
418
- appearance, subjective, gut, and experiment tools for durable evidence.
418
+ appearance, subjective, gut, and experiment tools for durable evidence. Search
419
+ foods first and reuse returned `foodId` values. If a custom food is needed,
420
+ research calories plus protein, carbohydrate, and fat from reliable internet
421
+ nutrition sources before logging; name-only custom foods are invalid.
419
422
  - `movement`: specialized domain surface. Use the dedicated movement routes for day,
420
423
  month, all-time, timeline, places, trip detail, selection aggregates, manual
421
424
  overlays, and repair actions.
@@ -1598,7 +1601,11 @@ Arc:
1598
1601
  4. Use `forge_log_food`, `forge_log_body_checkin`,
1599
1602
  `forge_log_appearance_checkin`, `forge_log_subjective_food_effect`, and
1600
1603
  `forge_log_gut_checkin` to preserve the user's actual evidence.
1601
- 5. Use `forge_get_nutrition_patterns`, `forge_start_nutrition_experiment`, and
1604
+ 5. For `forge_log_food`, call `forge_search_foods` or barcode lookup first. Reuse
1605
+ a matching result through `item.foodId`. If there is no match, create a custom
1606
+ food only after researching calories, protein, carbohydrate, and fat; include
1607
+ `caloriesKcal`, `proteinG`, `carbsG`, and `fatG` in the item.
1608
+ 6. Use `forge_get_nutrition_patterns`, `forge_start_nutrition_experiment`, and
1602
1609
  `forge_update_nutrition_experiment` when repeated observations should become
1603
1610
  an N-of-1 test instead of vague advice.
1604
1611
 
@@ -1607,6 +1614,8 @@ Helpful follow-up lanes:
1607
1614
  - whether the decision is weight trend, protein/fiber sufficiency, sport fuel,
1608
1615
  visual look, water retention, gut comfort, cravings, or energy
1609
1616
  - whether a meal should be confirmed precisely or logged as a candidate estimate
1617
+ - whether a searched/catalog food can be reused by `foodId` or whether a researched
1618
+ custom food with calories and macros is needed
1610
1619
  - which outcome metric should define a nutrition experiment before interpreting it
1611
1620
 
1612
1621
  Route note:
@@ -1614,7 +1623,9 @@ Route note:
1614
1623
  - `weight_loss` is a health read model plus dedicated nutrition write workflow.
1615
1624
  Use `/api/v1/health/weight-loss` or `forge_get_weight_loss_overview` for the
1616
1625
  overview. Do not invent generic batch entities for food logs or body check-ins
1617
- when the dedicated tools exist.
1626
+ when the dedicated tools exist. Food search reads Forge's local custom/cache
1627
+ database plus public nutrition catalogs. `forge_log_food` rejects custom items
1628
+ without calories, protein, carbohydrate, and fat.
1618
1629
 
1619
1630
  Ready to review when:
1620
1631