forge-openclaw-plugin 0.2.99 → 0.2.100

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.
Files changed (149) hide show
  1. package/dist/assets/activity-copy-Bj4h9OcF.js +1 -0
  2. package/dist/assets/activity-page-5oyCFOns.js +1 -0
  3. package/dist/assets/ai-surface-workspace-qgk_B57-.js +1 -0
  4. package/dist/assets/atlas-panel-rfH2qOez.js +1 -0
  5. package/dist/assets/{board-Ju0h0SeG.js → board-BkDRaMp6.js} +1 -1
  6. package/dist/assets/calendar-display-preferences-Cid-2RnL.js +1 -0
  7. package/dist/assets/calendar-page-Bo2iua-a.js +1 -0
  8. package/dist/assets/calendar-rules-DA1g3QUk.js +1 -0
  9. package/dist/assets/calendar-ui-Cy1XRwzV.js +1 -0
  10. package/dist/assets/calendar-week-toolbar-DU1Q4RYj.js +1 -0
  11. package/dist/assets/charts-P7EVhIog.js +36 -0
  12. package/dist/assets/companion-sync-lab-page-CosNknOK.js +1 -0
  13. package/dist/assets/daily-metrics-dashboard-LjuGAB3f.js +1 -0
  14. package/dist/assets/date-keys-Cj1G3TOn.js +1 -0
  15. package/dist/assets/entity-links-DwpxhW2H.js +1 -0
  16. package/dist/assets/entity-note-count-link-BmGDB572.js +1 -0
  17. package/dist/assets/entity-notes-surface-DgEgicaE.js +1 -0
  18. package/dist/assets/execution-board-CDRXQB85.js +1 -0
  19. package/dist/assets/faceted-token-search-CE1YauRd.js +1 -0
  20. package/dist/assets/flagship-signal-deck-DDds90Gl.js +1 -0
  21. package/dist/assets/floating-action-menu-CJkI2iFy.js +1 -0
  22. package/dist/assets/forms-BFlTgZ3W.js +1 -0
  23. package/dist/assets/goal-detail-page-cJvHaLMQ.js +1 -0
  24. package/dist/assets/goals-page-f_39hvUV.js +1 -0
  25. package/dist/assets/graph-BZV40eAE.css +1 -0
  26. package/dist/assets/graph-D6JLqDbD.js +318 -0
  27. package/dist/assets/habits-page-DKb96_mj.js +1 -0
  28. package/dist/assets/health-link-options-Cpx8w7uM.js +1 -0
  29. package/dist/assets/index-BHTUu_4M.js +19 -0
  30. package/dist/assets/index-CZbuZQjw.css +1 -0
  31. package/dist/assets/insight-flow-dialog-pzAzyayN.js +1 -0
  32. package/dist/assets/insights-page-Dc9oFltJ.js +8 -0
  33. package/dist/assets/kanban-page-JAxerYh6.js +1 -0
  34. package/dist/assets/knowledge-graph-page-UQ3skqEi.js +1 -0
  35. package/dist/assets/life-force-page-BGDbQuVh.js +1 -0
  36. package/dist/assets/life-force-workspace-B1fYSXRC.js +1 -0
  37. package/dist/assets/maps-B-YMMjus.css +1 -0
  38. package/dist/assets/maps-ClgJoCjz.js +803 -0
  39. package/dist/assets/metric-tile-DX6TclqM.js +1 -0
  40. package/dist/assets/{motion-DRPJkN3a.js → motion-BeD44FeG.js} +1 -1
  41. package/dist/assets/movement-page-6HP6nGJx.js +1 -0
  42. package/dist/assets/note-markdown-DiW2-5d3.js +3 -0
  43. package/dist/assets/note-tags-input-DDLXf54U.js +1 -0
  44. package/dist/assets/notes-page-BuguDjhz.js +1 -0
  45. package/dist/assets/open-in-graph-button-Cg5VrKsC.js +1 -0
  46. package/dist/assets/orbit-map-GD05-0oS.js +1 -0
  47. package/dist/assets/overview-page-DuOs2OCB.js +1 -0
  48. package/dist/assets/page-hero-CQWo1Mm_.js +1 -0
  49. package/dist/assets/pill-cluster-BJogDRDJ.js +1 -0
  50. package/dist/assets/preference-entity-handoff-button-D4WAs9pC.js +1 -0
  51. package/dist/assets/preferences-page-BaJTMU1I.js +1 -0
  52. package/dist/assets/project-collections-DvaX20q_.js +1 -0
  53. package/dist/assets/project-detail-page-drPIFZGb.js +1 -0
  54. package/dist/assets/project-management-hierarchy-page-BUbRXvny.js +1 -0
  55. package/dist/assets/project-management-section-nav-C2Ud8Zdd.js +1 -0
  56. package/dist/assets/projects-page-BGzEZUtg.js +1 -0
  57. package/dist/assets/psyche-behaviors-page-Dmm_Io9D.js +5 -0
  58. package/dist/assets/psyche-flashcards-page-BgNKJ6QJ.js +1 -0
  59. package/dist/assets/psyche-goal-map-page-DXJs98Vr.js +1 -0
  60. package/dist/assets/psyche-graph-CFgs_Bqc.js +1 -0
  61. package/dist/assets/psyche-metrics-page-zYTJDbyZ.js +1 -0
  62. package/dist/assets/psyche-mode-guide-page-XPgRfCOf.js +1 -0
  63. package/dist/assets/psyche-modes-page-B-GA8oRF.js +1 -0
  64. package/dist/assets/psyche-page--r6a3e1t.js +1 -0
  65. package/dist/assets/psyche-patterns-page-BM5-3bMm.js +5 -0
  66. package/dist/assets/psyche-questionnaire-builder-page-CJshQ-mg.js +1 -0
  67. package/dist/assets/psyche-questionnaire-detail-page-USmR5G5A.js +1 -0
  68. package/dist/assets/psyche-questionnaire-run-detail-page-D7iBCmTi.js +1 -0
  69. package/dist/assets/psyche-questionnaire-run-page-Cpil-kDh.js +1 -0
  70. package/dist/assets/psyche-questionnaires-page-C-_y3VwS.js +1 -0
  71. package/dist/assets/psyche-report-detail-page--dkSPRaj.js +3 -0
  72. package/dist/assets/psyche-reports-page-CUaOXmIN.js +1 -0
  73. package/dist/assets/psyche-schemas-HFmg37Wj.js +1 -0
  74. package/dist/assets/psyche-schemas-beliefs-page-BX6xaap3.js +9 -0
  75. package/dist/assets/psyche-screen-time-page-CAAI4mD7.js +1 -0
  76. package/dist/assets/psyche-self-observation-page-BZ6FLuwa.js +1 -0
  77. package/dist/assets/psyche-values-page-yEV6MGt8.js +5 -0
  78. package/dist/assets/query-cache-IQ8W-LNC.js +1 -0
  79. package/dist/assets/report-chain-fields-fZ8Xd4H6.js +1 -0
  80. package/dist/assets/rewards-page-C2HQjIAf.js +1 -0
  81. package/dist/assets/scheduling-rules-editor-BHOpHOrV.js +1 -0
  82. package/dist/assets/schema-badge-DyKbxb51.js +1 -0
  83. package/dist/assets/schema-visuals-D6nxjbYC.js +1 -0
  84. package/dist/assets/select-menu-BX-pZNqL.js +1 -0
  85. package/dist/assets/settings-agents-page-VuYXTiyc.js +6 -0
  86. package/dist/assets/settings-bin-page-BNzvYaOk.js +1 -0
  87. package/dist/assets/settings-calendar-page-CjSFB53S.js +5 -0
  88. package/dist/assets/settings-data-page-CGSlryuI.js +1 -0
  89. package/dist/assets/settings-logs-page-BTK5fine.js +1 -0
  90. package/dist/assets/settings-mobile-page-CRaObOGo.js +1 -0
  91. package/dist/assets/settings-models-page-DFshpYF8.js +1 -0
  92. package/dist/assets/settings-page-BP81Mb5R.js +1 -0
  93. package/dist/assets/settings-rewards-page-CDJ1PH2G.js +1 -0
  94. package/dist/assets/settings-section-nav-CCFm27r2.js +1 -0
  95. package/dist/assets/settings-users-page-TdUocFPa.js +1 -0
  96. package/dist/assets/settings-wiki-page-B2zX0QQG.js +1 -0
  97. package/dist/assets/sleep-page-cI1GMVzk.js +1 -0
  98. package/dist/assets/sports-page-06LTqp0V.js +1 -0
  99. package/dist/assets/state-B-4sS1xO.js +1 -0
  100. package/dist/assets/strategies-page-DXP9Kx8s.js +1 -0
  101. package/dist/assets/strategy-detail-page-D6mx_Mik.js +1 -0
  102. package/dist/assets/strategy-dialog-BvzomTaF.js +1 -0
  103. package/dist/assets/{table-DewbFlTh.js → table-WfAPUppN.js} +1 -1
  104. package/dist/assets/task-detail-page-BIWIggdp.js +1 -0
  105. package/dist/assets/timebox-planning-dialog-CaCnoslG.js +1 -0
  106. package/dist/assets/today-page-DO2mRPT2.js +1 -0
  107. package/dist/assets/training-load-page-CyZ0mlEr.js +1 -0
  108. package/dist/assets/{ui-C2IvSrAz.js → ui-C13Nbgas.js} +4 -4
  109. package/dist/assets/use-psyche-focus-target-C1C_XjYG.js +1 -0
  110. package/dist/assets/vendor-CRS-psbw.css +1 -0
  111. package/dist/assets/vendor-DHkYh85p.js +1052 -0
  112. package/dist/assets/vitals-page-BQvEjTc6.js +1 -0
  113. package/dist/assets/weekly-review-page-Tp6Q9CRj.js +1 -0
  114. package/dist/assets/weight-loss-page-BBzlhLVV.js +1 -0
  115. package/dist/assets/wiki-article-markdown-DQYohmW2.js +4 -0
  116. package/dist/assets/wiki-editor-page-Dem_3eZv.js +26 -0
  117. package/dist/assets/wiki-ingest-history-page-BxoOcCoJ.js +1 -0
  118. package/dist/assets/wiki-ingest-modal-DhguKk3J.js +1 -0
  119. package/dist/assets/wiki-page-BLRxVXkl.js +1 -0
  120. package/dist/assets/workbench-flow-page-DqMkCCTy.js +5 -0
  121. package/dist/assets/workbench-page-BWd02wPw.js +1 -0
  122. package/dist/assets/workout-detail-page-BD8u7GyL.js +2 -0
  123. package/dist/index.html +148 -9
  124. package/dist/openclaw/tools.js +340 -0
  125. package/dist/server/server/migrations/065_weight_loss_nutrition_insights.sql +236 -0
  126. package/dist/server/server/migrations/066_watch_action_receipts.sql +20 -0
  127. package/dist/server/server/src/app.js +266 -13
  128. package/dist/server/server/src/health-weight-loss.js +1378 -0
  129. package/dist/server/server/src/health.js +188 -35
  130. package/dist/server/server/src/openapi.js +449 -0
  131. package/dist/server/server/src/services/context.js +6 -7
  132. package/dist/server/server/src/services/doctor.js +39 -4
  133. package/dist/server/server/src/services/gamification.js +146 -34
  134. package/dist/server/server/src/watch-mobile.js +564 -4
  135. package/dist/server/server/src/web.js +18 -5
  136. package/dist/server/src/components/ui/info-tooltip.js +48 -3
  137. package/dist/server/src/lib/api.js +131 -0
  138. package/dist/server/src/lib/weight-loss-types.js +1 -0
  139. package/openclaw.plugin.json +14 -1
  140. package/package.json +1 -1
  141. package/server/migrations/065_weight_loss_nutrition_insights.sql +236 -0
  142. package/server/migrations/066_watch_action_receipts.sql +20 -0
  143. package/skills/forge-openclaw/SKILL.md +26 -5
  144. package/skills/forge-openclaw/entity_conversation_playbooks.md +134 -5
  145. package/skills/forge-openclaw/psyche_entity_playbooks.md +45 -0
  146. package/dist/assets/index-Cn5Wpwau.css +0 -1
  147. package/dist/assets/index-CwvGs8n4.js +0 -91
  148. package/dist/assets/vendor-B-Lq_OG3.css +0 -1
  149. package/dist/assets/vendor-DL2K5ayT.js +0 -2186
@@ -0,0 +1,1378 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { getDatabase, runInTransaction } from "./db.js";
4
+ import { getSettings } from "./repositories/settings.js";
5
+ import { getDefaultUser, resolveUserForMutation } from "./repositories/users.js";
6
+ const optionalNumberSchema = z
7
+ .union([z.coerce.number().finite(), z.null()])
8
+ .optional();
9
+ const scoreSchema = z
10
+ .union([z.coerce.number().int().min(0).max(10), z.null()])
11
+ .optional();
12
+ const tagsSchema = z.array(z.string().trim().min(1)).default([]);
13
+ const linksSchema = z
14
+ .array(z.object({
15
+ entityType: z.string().trim().min(1),
16
+ entityId: z.string().trim().min(1),
17
+ relationshipType: z.string().trim().min(1).default("context")
18
+ }))
19
+ .default([]);
20
+ const mealItemInputSchema = z.preprocess((value) => {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ return value;
23
+ }
24
+ const record = value;
25
+ return {
26
+ ...record,
27
+ calories: record.calories ?? record.caloriesKcal,
28
+ proteinGrams: record.proteinGrams ?? record.proteinG,
29
+ carbohydrateGrams: record.carbohydrateGrams ?? record.carbsG,
30
+ fatGrams: record.fatGrams ?? record.fatG,
31
+ fiberGrams: record.fiberGrams ?? record.fiberG,
32
+ sugarGrams: record.sugarGrams ?? record.sugarG,
33
+ alcoholGrams: record.alcoholGrams ?? record.alcoholG
34
+ };
35
+ }, z.object({
36
+ id: z.string().trim().min(1).optional(),
37
+ foodId: z.string().trim().min(1).nullable().optional(),
38
+ name: z.string().trim().min(1),
39
+ quantity: z.coerce.number().positive().default(1),
40
+ unit: z.string().trim().min(1).default("serving"),
41
+ grams: optionalNumberSchema,
42
+ calories: optionalNumberSchema,
43
+ proteinGrams: optionalNumberSchema,
44
+ carbohydrateGrams: optionalNumberSchema,
45
+ fatGrams: optionalNumberSchema,
46
+ fiberGrams: optionalNumberSchema,
47
+ sugarGrams: optionalNumberSchema,
48
+ sodiumMg: optionalNumberSchema,
49
+ potassiumMg: optionalNumberSchema,
50
+ caffeineMg: optionalNumberSchema,
51
+ alcoholGrams: optionalNumberSchema,
52
+ tags: tagsSchema,
53
+ nutrients: z.record(z.string(), z.unknown()).default({}),
54
+ confidence: z.coerce.number().min(0).max(1).default(0.65)
55
+ }));
56
+ export const nutritionFoodSearchSchema = z.object({
57
+ query: z.string().trim().min(1),
58
+ limit: z.coerce.number().int().min(1).max(25).default(12)
59
+ });
60
+ export const nutritionBarcodeLookupSchema = z.object({
61
+ barcode: z.string().trim().min(3),
62
+ limit: z.coerce.number().int().min(1).max(10).default(5)
63
+ });
64
+ export const nutritionTargetUpdateSchema = z.object({
65
+ userId: z.string().trim().min(1).optional(),
66
+ calorieTarget: optionalNumberSchema,
67
+ proteinGramsTarget: optionalNumberSchema,
68
+ fiberGramsTarget: optionalNumberSchema,
69
+ carbohydrateGramsTarget: optionalNumberSchema,
70
+ fatGramsTarget: optionalNumberSchema,
71
+ weightGoalKg: optionalNumberSchema,
72
+ weeklyRateGoalKg: optionalNumberSchema,
73
+ dietStyle: z.string().trim().default(""),
74
+ bodyGoal: z.string().trim().default(""),
75
+ notes: z.string().trim().default("")
76
+ });
77
+ export const nutritionFoodLogCreateSchema = z.object({
78
+ userId: z.string().trim().min(1).optional(),
79
+ loggedAt: z.string().datetime().optional(),
80
+ mealLabel: z.string().trim().default(""),
81
+ source: z
82
+ .enum(["manual", "search", "barcode", "chatgpt", "photo", "saved_meal"])
83
+ .default("manual"),
84
+ confirmationState: z
85
+ .enum(["candidate", "confirmed", "needs_review", "discarded"])
86
+ .default("confirmed"),
87
+ notes: z.string().trim().default(""),
88
+ placeId: z.string().trim().min(1).nullable().optional(),
89
+ stayId: z.string().trim().min(1).nullable().optional(),
90
+ workoutId: z.string().trim().min(1).nullable().optional(),
91
+ sleepId: z.string().trim().min(1).nullable().optional(),
92
+ imageRefs: z.array(z.string().trim().min(1)).default([]),
93
+ parserProvenance: z.record(z.string(), z.unknown()).default({}),
94
+ links: linksSchema,
95
+ items: z.array(mealItemInputSchema).min(1)
96
+ });
97
+ export const nutritionFoodLogPatchSchema = nutritionFoodLogCreateSchema
98
+ .omit({ userId: true })
99
+ .partial()
100
+ .extend({
101
+ items: z.array(mealItemInputSchema).optional()
102
+ });
103
+ export const nutritionBodyCheckinCreateSchema = z.object({
104
+ userId: z.string().trim().min(1).optional(),
105
+ checkedAt: z.string().datetime().optional(),
106
+ weightKg: optionalNumberSchema,
107
+ waistCm: optionalNumberSchema,
108
+ hipCm: optionalNumberSchema,
109
+ neckCm: optionalNumberSchema,
110
+ chestCm: optionalNumberSchema,
111
+ armCm: optionalNumberSchema,
112
+ thighCm: optionalNumberSchema,
113
+ bodyFatPercent: optionalNumberSchema,
114
+ clothingFitScore: scoreSchema,
115
+ notes: z.string().trim().default("")
116
+ });
117
+ export const nutritionAppearanceCheckinCreateSchema = z.object({
118
+ userId: z.string().trim().min(1).optional(),
119
+ checkedAt: z.string().datetime().optional(),
120
+ photoRefs: z.array(z.string().trim().min(1)).default([]),
121
+ facePuffiness: scoreSchema,
122
+ leanness: scoreSchema,
123
+ muscularity: scoreSchema,
124
+ posture: scoreSchema,
125
+ bloatingLook: scoreSchema,
126
+ confidenceScore: scoreSchema,
127
+ notes: z.string().trim().default("")
128
+ });
129
+ export const nutritionSubjectiveCheckinCreateSchema = z.object({
130
+ userId: z.string().trim().min(1).optional(),
131
+ checkedAt: z.string().datetime().optional(),
132
+ mealLogId: z.string().trim().min(1).nullable().optional(),
133
+ timeRelation: z
134
+ .enum(["before_meal", "with_meal", "after_2h", "end_of_day", "unspecified"])
135
+ .default("unspecified"),
136
+ hunger: scoreSchema,
137
+ fullness: scoreSchema,
138
+ cravings: scoreSchema,
139
+ mood: scoreSchema,
140
+ energy: scoreSchema,
141
+ focus: scoreSchema,
142
+ stress: scoreSchema,
143
+ sleepiness: scoreSchema,
144
+ crashScore: scoreSchema,
145
+ notes: z.string().trim().default("")
146
+ });
147
+ export const nutritionGutCheckinCreateSchema = z.object({
148
+ userId: z.string().trim().min(1).optional(),
149
+ checkedAt: z.string().datetime().optional(),
150
+ mealLogId: z.string().trim().min(1).nullable().optional(),
151
+ bristolStoolType: z
152
+ .union([z.coerce.number().int().min(1).max(7), z.null()])
153
+ .optional(),
154
+ stoolFrequency: optionalNumberSchema,
155
+ bloating: scoreSchema,
156
+ gas: scoreSchema,
157
+ reflux: scoreSchema,
158
+ abdominalPain: scoreSchema,
159
+ urgency: scoreSchema,
160
+ nausea: scoreSchema,
161
+ constipation: scoreSchema,
162
+ diarrhea: scoreSchema,
163
+ triggerTags: tagsSchema,
164
+ notes: z.string().trim().default("")
165
+ });
166
+ export const nutritionExperimentCreateSchema = z.object({
167
+ userId: z.string().trim().min(1).optional(),
168
+ hypothesisId: z.string().trim().min(1).nullable().optional(),
169
+ title: z.string().trim().min(1),
170
+ status: z.enum(["planned", "running", "complete", "paused"]).default("planned"),
171
+ baselineStart: z.string().trim().nullable().optional(),
172
+ baselineEnd: z.string().trim().nullable().optional(),
173
+ interventionStart: z.string().trim().nullable().optional(),
174
+ interventionEnd: z.string().trim().nullable().optional(),
175
+ trackedOutcomes: tagsSchema,
176
+ protocol: z.record(z.string(), z.unknown()).default({}),
177
+ adherence: z.record(z.string(), z.unknown()).default({}),
178
+ resultSummary: z.string().trim().default("")
179
+ });
180
+ export const nutritionExperimentPatchSchema = nutritionExperimentCreateSchema.omit({ userId: true }).partial();
181
+ export const nutritionParseRequestSchema = z.object({
182
+ text: z.string().trim().min(1),
183
+ mealTime: z.string().datetime().optional(),
184
+ imageRefs: z.array(z.string().trim().min(1)).default([]),
185
+ userId: z.string().trim().min(1).optional(),
186
+ connectionId: z.string().trim().min(1).optional(),
187
+ commitCandidate: z.boolean().default(true)
188
+ });
189
+ function nowIso() {
190
+ return new Date().toISOString();
191
+ }
192
+ function newId(prefix) {
193
+ return `${prefix}_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
194
+ }
195
+ function dayKey(value) {
196
+ return value.slice(0, 10);
197
+ }
198
+ function jsonString(value) {
199
+ return JSON.stringify(value ?? null);
200
+ }
201
+ function parseJson(value, fallback) {
202
+ try {
203
+ const parsed = JSON.parse(value);
204
+ return parsed ?? fallback;
205
+ }
206
+ catch {
207
+ return fallback;
208
+ }
209
+ }
210
+ function n(value) {
211
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
212
+ }
213
+ function round(value, digits = 1) {
214
+ const factor = 10 ** digits;
215
+ return Math.round(value * factor) / factor;
216
+ }
217
+ function average(values) {
218
+ const real = values.filter((value) => typeof value === "number" && Number.isFinite(value));
219
+ if (real.length === 0) {
220
+ return null;
221
+ }
222
+ return real.reduce((sum, value) => sum + value, 0) / real.length;
223
+ }
224
+ function resolveWriteUser(userId) {
225
+ return resolveUserForMutation(userId ?? null).id;
226
+ }
227
+ function resolveReadUser(userIds) {
228
+ return userIds?.[0] ?? getDefaultUser().id;
229
+ }
230
+ function mapFood(row) {
231
+ return {
232
+ id: row.id,
233
+ source: row.source,
234
+ sourceId: row.source_id,
235
+ barcode: row.barcode,
236
+ name: row.name,
237
+ brand: row.brand,
238
+ servingLabel: row.serving_label,
239
+ servingGrams: row.serving_grams,
240
+ calories: row.calories,
241
+ proteinGrams: row.protein_grams,
242
+ carbohydrateGrams: row.carbohydrate_grams,
243
+ fatGrams: row.fat_grams,
244
+ fiberGrams: row.fiber_grams,
245
+ sugarGrams: row.sugar_grams,
246
+ sodiumMg: row.sodium_mg,
247
+ potassiumMg: row.potassium_mg,
248
+ caffeineMg: row.caffeine_mg,
249
+ alcoholGrams: row.alcohol_grams,
250
+ novaGroup: row.nova_group,
251
+ nutriScore: row.nutri_score,
252
+ tags: parseJson(row.tags_json, []),
253
+ nutrients: parseJson(row.nutrients_json, {}),
254
+ confidence: row.confidence,
255
+ createdAt: row.created_at,
256
+ updatedAt: row.updated_at
257
+ };
258
+ }
259
+ function mapItem(row) {
260
+ return {
261
+ id: row.id,
262
+ logId: row.log_id,
263
+ foodId: row.food_id,
264
+ name: row.name,
265
+ quantity: row.quantity,
266
+ unit: row.unit,
267
+ grams: row.grams,
268
+ calories: row.calories,
269
+ proteinGrams: row.protein_grams,
270
+ carbohydrateGrams: row.carbohydrate_grams,
271
+ fatGrams: row.fat_grams,
272
+ fiberGrams: row.fiber_grams,
273
+ sugarGrams: row.sugar_grams,
274
+ sodiumMg: row.sodium_mg,
275
+ potassiumMg: row.potassium_mg,
276
+ caffeineMg: row.caffeine_mg,
277
+ alcoholGrams: row.alcohol_grams,
278
+ tags: parseJson(row.tags_json, []),
279
+ nutrients: parseJson(row.nutrients_json, {}),
280
+ confidence: row.confidence,
281
+ createdAt: row.created_at,
282
+ updatedAt: row.updated_at
283
+ };
284
+ }
285
+ function mapFoodLog(row, items = []) {
286
+ return {
287
+ id: row.id,
288
+ userId: row.user_id,
289
+ loggedAt: row.logged_at,
290
+ mealLabel: row.meal_label,
291
+ source: row.source,
292
+ confirmationState: row.confirmation_state,
293
+ notes: row.notes,
294
+ placeId: row.place_id,
295
+ stayId: row.stay_id,
296
+ workoutId: row.workout_id,
297
+ sleepId: row.sleep_id,
298
+ dayKey: row.day_key,
299
+ imageRefs: parseJson(row.image_refs_json, []),
300
+ parserProvenance: parseJson(row.parser_provenance_json, {}),
301
+ links: parseJson(row.links_json, []),
302
+ items: items.map(mapItem),
303
+ totals: sumItems(items.map(mapItem)),
304
+ createdAt: row.created_at,
305
+ updatedAt: row.updated_at
306
+ };
307
+ }
308
+ function mapTarget(row, userId) {
309
+ return {
310
+ id: row?.id ?? null,
311
+ userId,
312
+ calorieTarget: row?.calorie_target ?? 2200,
313
+ proteinGramsTarget: row?.protein_grams_target ?? 140,
314
+ fiberGramsTarget: row?.fiber_grams_target ?? 30,
315
+ carbohydrateGramsTarget: row?.carbohydrate_grams_target ?? null,
316
+ fatGramsTarget: row?.fat_grams_target ?? null,
317
+ weightGoalKg: row?.weight_goal_kg ?? null,
318
+ weeklyRateGoalKg: row?.weekly_rate_goal_kg ?? -0.35,
319
+ dietStyle: row?.diet_style ?? "",
320
+ bodyGoal: row?.body_goal ?? "",
321
+ notes: row?.notes ?? "",
322
+ createdAt: row?.created_at ?? null,
323
+ updatedAt: row?.updated_at ?? null
324
+ };
325
+ }
326
+ function sumItems(items) {
327
+ return {
328
+ calories: round(items.reduce((sum, item) => sum + n(item.calories), 0), 0),
329
+ proteinGrams: round(items.reduce((sum, item) => sum + n(item.proteinGrams), 0), 1),
330
+ carbohydrateGrams: round(items.reduce((sum, item) => sum + n(item.carbohydrateGrams), 0), 1),
331
+ fatGrams: round(items.reduce((sum, item) => sum + n(item.fatGrams), 0), 1),
332
+ fiberGrams: round(items.reduce((sum, item) => sum + n(item.fiberGrams), 0), 1),
333
+ sugarGrams: round(items.reduce((sum, item) => sum + n(item.sugarGrams), 0), 1),
334
+ sodiumMg: round(items.reduce((sum, item) => sum + n(item.sodiumMg), 0), 0),
335
+ potassiumMg: round(items.reduce((sum, item) => sum + n(item.potassiumMg), 0), 0),
336
+ caffeineMg: round(items.reduce((sum, item) => sum + n(item.caffeineMg), 0), 0),
337
+ alcoholGrams: round(items.reduce((sum, item) => sum + n(item.alcoholGrams), 0), 1)
338
+ };
339
+ }
340
+ function readMealItems(logIds) {
341
+ if (logIds.length === 0) {
342
+ return new Map();
343
+ }
344
+ const placeholders = logIds.map(() => "?").join(",");
345
+ const rows = getDatabase()
346
+ .prepare(`SELECT *
347
+ FROM nutrition_meal_items
348
+ WHERE log_id IN (${placeholders})
349
+ ORDER BY created_at ASC`)
350
+ .all(...logIds);
351
+ const byLog = new Map();
352
+ for (const row of rows) {
353
+ byLog.set(row.log_id, [...(byLog.get(row.log_id) ?? []), row]);
354
+ }
355
+ return byLog;
356
+ }
357
+ function listFoodLogs(userId, limit = 120) {
358
+ const rows = getDatabase()
359
+ .prepare(`SELECT *
360
+ FROM nutrition_food_logs
361
+ WHERE user_id = ?
362
+ AND confirmation_state != 'discarded'
363
+ ORDER BY logged_at DESC
364
+ LIMIT ?`)
365
+ .all(userId, limit);
366
+ const itemsByLog = readMealItems(rows.map((row) => row.id));
367
+ return rows.map((row) => mapFoodLog(row, itemsByLog.get(row.id) ?? []));
368
+ }
369
+ function insertMealItem(logId, input) {
370
+ const now = nowIso();
371
+ const id = input.id ?? newId("meal_item");
372
+ getDatabase()
373
+ .prepare(`INSERT INTO nutrition_meal_items (
374
+ id, log_id, food_id, name, quantity, unit, grams, calories,
375
+ protein_grams, carbohydrate_grams, fat_grams, fiber_grams, sugar_grams,
376
+ sodium_mg, potassium_mg, caffeine_mg, alcohol_grams, tags_json,
377
+ nutrients_json, confidence, created_at, updated_at
378
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
379
+ .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);
380
+ }
381
+ export function createNutritionFoodLog(input) {
382
+ const parsed = nutritionFoodLogCreateSchema.parse(input);
383
+ const userId = resolveWriteUser(parsed.userId);
384
+ const loggedAt = parsed.loggedAt ?? nowIso();
385
+ const id = newId("meal");
386
+ const now = nowIso();
387
+ runInTransaction(() => {
388
+ getDatabase()
389
+ .prepare(`INSERT INTO nutrition_food_logs (
390
+ id, user_id, logged_at, meal_label, source, confirmation_state, notes,
391
+ place_id, stay_id, workout_id, sleep_id, day_key, image_refs_json,
392
+ parser_provenance_json, links_json, created_at, updated_at
393
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
394
+ .run(id, userId, loggedAt, parsed.mealLabel, parsed.source, parsed.confirmationState, parsed.notes, parsed.placeId ?? null, parsed.stayId ?? null, parsed.workoutId ?? null, parsed.sleepId ?? null, dayKey(loggedAt), jsonString(parsed.imageRefs), jsonString(parsed.parserProvenance), jsonString(parsed.links), now, now);
395
+ for (const item of parsed.items) {
396
+ insertMealItem(id, item);
397
+ }
398
+ });
399
+ return getNutritionFoodLogById(id);
400
+ }
401
+ export function patchNutritionFoodLog(logId, input) {
402
+ const parsed = nutritionFoodLogPatchSchema.parse(input);
403
+ const existing = getNutritionFoodLogById(logId);
404
+ if (!existing) {
405
+ return null;
406
+ }
407
+ const nextLoggedAt = parsed.loggedAt ?? existing.loggedAt;
408
+ const now = nowIso();
409
+ runInTransaction(() => {
410
+ getDatabase()
411
+ .prepare(`UPDATE nutrition_food_logs
412
+ SET logged_at = ?, meal_label = ?, source = ?, confirmation_state = ?,
413
+ notes = ?, place_id = ?, stay_id = ?, workout_id = ?, sleep_id = ?,
414
+ day_key = ?, image_refs_json = ?, parser_provenance_json = ?,
415
+ links_json = ?, updated_at = ?
416
+ WHERE id = ?`)
417
+ .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, dayKey(nextLoggedAt), jsonString(parsed.imageRefs ?? existing.imageRefs), jsonString(parsed.parserProvenance ?? existing.parserProvenance), jsonString(parsed.links ?? existing.links), now, logId);
418
+ if (parsed.items) {
419
+ getDatabase()
420
+ .prepare(`DELETE FROM nutrition_meal_items WHERE log_id = ?`)
421
+ .run(logId);
422
+ for (const item of parsed.items) {
423
+ insertMealItem(logId, item);
424
+ }
425
+ }
426
+ });
427
+ return getNutritionFoodLogById(logId);
428
+ }
429
+ export function deleteNutritionFoodLog(logId) {
430
+ const existing = getNutritionFoodLogById(logId);
431
+ if (!existing) {
432
+ return null;
433
+ }
434
+ getDatabase()
435
+ .prepare(`UPDATE nutrition_food_logs
436
+ SET confirmation_state = 'discarded', updated_at = ?
437
+ WHERE id = ?`)
438
+ .run(nowIso(), logId);
439
+ return existing;
440
+ }
441
+ export function getNutritionFoodLogById(logId) {
442
+ const row = getDatabase()
443
+ .prepare(`SELECT * FROM nutrition_food_logs WHERE id = ?`)
444
+ .get(logId);
445
+ if (!row) {
446
+ return null;
447
+ }
448
+ const itemsByLog = readMealItems([logId]);
449
+ return mapFoodLog(row, itemsByLog.get(logId) ?? []);
450
+ }
451
+ export function updateNutritionTarget(input) {
452
+ const parsed = nutritionTargetUpdateSchema.parse(input);
453
+ const userId = resolveWriteUser(parsed.userId);
454
+ const existing = getDatabase()
455
+ .prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
456
+ .get(userId);
457
+ const id = existing?.id ?? newId("nutrition_target");
458
+ const now = nowIso();
459
+ getDatabase()
460
+ .prepare(`INSERT INTO nutrition_targets (
461
+ id, user_id, calorie_target, protein_grams_target, fiber_grams_target,
462
+ carbohydrate_grams_target, fat_grams_target, weight_goal_kg,
463
+ weekly_rate_goal_kg, diet_style, body_goal, notes, created_at, updated_at
464
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
465
+ ON CONFLICT(user_id) DO UPDATE SET
466
+ calorie_target = excluded.calorie_target,
467
+ protein_grams_target = excluded.protein_grams_target,
468
+ fiber_grams_target = excluded.fiber_grams_target,
469
+ carbohydrate_grams_target = excluded.carbohydrate_grams_target,
470
+ fat_grams_target = excluded.fat_grams_target,
471
+ weight_goal_kg = excluded.weight_goal_kg,
472
+ weekly_rate_goal_kg = excluded.weekly_rate_goal_kg,
473
+ diet_style = excluded.diet_style,
474
+ body_goal = excluded.body_goal,
475
+ notes = excluded.notes,
476
+ updated_at = excluded.updated_at`)
477
+ .run(id, userId, parsed.calorieTarget ?? null, parsed.proteinGramsTarget ?? null, parsed.fiberGramsTarget ?? null, parsed.carbohydrateGramsTarget ?? null, parsed.fatGramsTarget ?? null, parsed.weightGoalKg ?? null, parsed.weeklyRateGoalKg ?? null, parsed.dietStyle, parsed.bodyGoal, parsed.notes, existing?.created_at ?? now, now);
478
+ return mapTarget(getDatabase()
479
+ .prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
480
+ .get(userId), userId);
481
+ }
482
+ export function createNutritionBodyCheckin(input) {
483
+ const parsed = nutritionBodyCheckinCreateSchema.parse(input);
484
+ const userId = resolveWriteUser(parsed.userId);
485
+ const checkedAt = parsed.checkedAt ?? nowIso();
486
+ const id = newId("body");
487
+ const now = nowIso();
488
+ getDatabase()
489
+ .prepare(`INSERT INTO nutrition_body_checkins (
490
+ id, user_id, checked_at, weight_kg, waist_cm, hip_cm, neck_cm, chest_cm,
491
+ arm_cm, thigh_cm, body_fat_percent, clothing_fit_score, notes,
492
+ created_at, updated_at
493
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
494
+ .run(id, userId, checkedAt, parsed.weightKg ?? null, parsed.waistCm ?? null, parsed.hipCm ?? null, parsed.neckCm ?? null, parsed.chestCm ?? null, parsed.armCm ?? null, parsed.thighCm ?? null, parsed.bodyFatPercent ?? null, parsed.clothingFitScore ?? null, parsed.notes, now, now);
495
+ return listBodyCheckins(userId, 1)[0];
496
+ }
497
+ export function createNutritionAppearanceCheckin(input) {
498
+ const parsed = nutritionAppearanceCheckinCreateSchema.parse(input);
499
+ const userId = resolveWriteUser(parsed.userId);
500
+ const checkedAt = parsed.checkedAt ?? nowIso();
501
+ const id = newId("appearance");
502
+ const now = nowIso();
503
+ getDatabase()
504
+ .prepare(`INSERT INTO nutrition_appearance_checkins (
505
+ id, user_id, checked_at, photo_refs_json, face_puffiness, leanness,
506
+ muscularity, posture, bloating_look, confidence_score, notes,
507
+ created_at, updated_at
508
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
509
+ .run(id, userId, checkedAt, jsonString(parsed.photoRefs), parsed.facePuffiness ?? null, parsed.leanness ?? null, parsed.muscularity ?? null, parsed.posture ?? null, parsed.bloatingLook ?? null, parsed.confidenceScore ?? null, parsed.notes, now, now);
510
+ return listAppearanceCheckins(userId, 1)[0];
511
+ }
512
+ export function createNutritionSubjectiveCheckin(input) {
513
+ const parsed = nutritionSubjectiveCheckinCreateSchema.parse(input);
514
+ const userId = resolveWriteUser(parsed.userId);
515
+ const checkedAt = parsed.checkedAt ?? nowIso();
516
+ const id = newId("subjective");
517
+ const now = nowIso();
518
+ getDatabase()
519
+ .prepare(`INSERT INTO nutrition_subjective_checkins (
520
+ id, user_id, checked_at, meal_log_id, time_relation, hunger, fullness,
521
+ cravings, mood, energy, focus, stress, sleepiness, crash_score, notes,
522
+ created_at, updated_at
523
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
524
+ .run(id, userId, checkedAt, parsed.mealLogId ?? null, parsed.timeRelation, parsed.hunger ?? null, parsed.fullness ?? null, parsed.cravings ?? null, parsed.mood ?? null, parsed.energy ?? null, parsed.focus ?? null, parsed.stress ?? null, parsed.sleepiness ?? null, parsed.crashScore ?? null, parsed.notes, now, now);
525
+ return listSubjectiveCheckins(userId, 1)[0];
526
+ }
527
+ export function createNutritionGutCheckin(input) {
528
+ const parsed = nutritionGutCheckinCreateSchema.parse(input);
529
+ const userId = resolveWriteUser(parsed.userId);
530
+ const checkedAt = parsed.checkedAt ?? nowIso();
531
+ const id = newId("gut");
532
+ const now = nowIso();
533
+ getDatabase()
534
+ .prepare(`INSERT INTO nutrition_gut_checkins (
535
+ id, user_id, checked_at, meal_log_id, bristol_stool_type,
536
+ stool_frequency, bloating, gas, reflux, abdominal_pain, urgency, nausea,
537
+ constipation, diarrhea, trigger_tags_json, notes, created_at, updated_at
538
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
539
+ .run(id, userId, checkedAt, parsed.mealLogId ?? null, parsed.bristolStoolType ?? null, parsed.stoolFrequency ?? null, parsed.bloating ?? null, parsed.gas ?? null, parsed.reflux ?? null, parsed.abdominalPain ?? null, parsed.urgency ?? null, parsed.nausea ?? null, parsed.constipation ?? null, parsed.diarrhea ?? null, jsonString(parsed.triggerTags), parsed.notes, now, now);
540
+ return listGutCheckins(userId, 1)[0];
541
+ }
542
+ export function createNutritionExperiment(input) {
543
+ const parsed = nutritionExperimentCreateSchema.parse(input);
544
+ const userId = resolveWriteUser(parsed.userId);
545
+ const id = newId("nutrition_experiment");
546
+ const now = nowIso();
547
+ getDatabase()
548
+ .prepare(`INSERT INTO nutrition_experiments (
549
+ id, user_id, hypothesis_id, title, status, baseline_start, baseline_end,
550
+ intervention_start, intervention_end, tracked_outcomes_json,
551
+ protocol_json, adherence_json, result_summary, created_at, updated_at
552
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
553
+ .run(id, userId, parsed.hypothesisId ?? null, parsed.title, parsed.status, parsed.baselineStart ?? null, parsed.baselineEnd ?? null, parsed.interventionStart ?? null, parsed.interventionEnd ?? null, jsonString(parsed.trackedOutcomes), jsonString(parsed.protocol), jsonString(parsed.adherence), parsed.resultSummary, now, now);
554
+ return listExperiments(userId, 1)[0];
555
+ }
556
+ export function patchNutritionExperiment(experimentId, input) {
557
+ const parsed = nutritionExperimentPatchSchema.parse(input);
558
+ const existing = getDatabase()
559
+ .prepare(`SELECT * FROM nutrition_experiments WHERE id = ?`)
560
+ .get(experimentId);
561
+ if (!existing) {
562
+ return null;
563
+ }
564
+ getDatabase()
565
+ .prepare(`UPDATE nutrition_experiments
566
+ SET hypothesis_id = ?, title = ?, status = ?, baseline_start = ?,
567
+ baseline_end = ?, intervention_start = ?, intervention_end = ?,
568
+ tracked_outcomes_json = ?, protocol_json = ?, adherence_json = ?,
569
+ result_summary = ?, updated_at = ?
570
+ WHERE id = ?`)
571
+ .run(parsed.hypothesisId !== undefined
572
+ ? parsed.hypothesisId
573
+ : existing.hypothesis_id, parsed.title ?? existing.title, parsed.status ?? existing.status, parsed.baselineStart !== undefined
574
+ ? parsed.baselineStart
575
+ : existing.baseline_start, parsed.baselineEnd !== undefined ? parsed.baselineEnd : existing.baseline_end, parsed.interventionStart !== undefined
576
+ ? parsed.interventionStart
577
+ : existing.intervention_start, parsed.interventionEnd !== undefined
578
+ ? parsed.interventionEnd
579
+ : existing.intervention_end, parsed.trackedOutcomes
580
+ ? jsonString(parsed.trackedOutcomes)
581
+ : existing.tracked_outcomes_json, parsed.protocol ? jsonString(parsed.protocol) : existing.protocol_json, parsed.adherence ? jsonString(parsed.adherence) : existing.adherence_json, parsed.resultSummary ?? existing.result_summary, nowIso(), experimentId);
582
+ return getDatabase()
583
+ .prepare(`SELECT * FROM nutrition_experiments WHERE id = ?`)
584
+ .get(experimentId);
585
+ }
586
+ function listBodyCheckins(userId, limit = 30) {
587
+ return getDatabase()
588
+ .prepare(`SELECT * FROM nutrition_body_checkins
589
+ WHERE user_id = ?
590
+ ORDER BY checked_at DESC
591
+ LIMIT ?`)
592
+ .all(userId, limit).map((row) => ({
593
+ id: row.id,
594
+ userId: row.user_id,
595
+ checkedAt: row.checked_at,
596
+ weightKg: row.weight_kg,
597
+ waistCm: row.waist_cm,
598
+ hipCm: row.hip_cm,
599
+ neckCm: row.neck_cm,
600
+ chestCm: row.chest_cm,
601
+ armCm: row.arm_cm,
602
+ thighCm: row.thigh_cm,
603
+ bodyFatPercent: row.body_fat_percent,
604
+ clothingFitScore: row.clothing_fit_score,
605
+ notes: row.notes,
606
+ createdAt: row.created_at,
607
+ updatedAt: row.updated_at
608
+ }));
609
+ }
610
+ function listAppearanceCheckins(userId, limit = 20) {
611
+ return getDatabase()
612
+ .prepare(`SELECT * FROM nutrition_appearance_checkins
613
+ WHERE user_id = ?
614
+ ORDER BY checked_at DESC
615
+ LIMIT ?`)
616
+ .all(userId, limit).map((row) => ({
617
+ id: row.id,
618
+ userId: row.user_id,
619
+ checkedAt: row.checked_at,
620
+ photoRefs: parseJson(row.photo_refs_json, []),
621
+ facePuffiness: row.face_puffiness,
622
+ leanness: row.leanness,
623
+ muscularity: row.muscularity,
624
+ posture: row.posture,
625
+ bloatingLook: row.bloating_look,
626
+ confidenceScore: row.confidence_score,
627
+ notes: row.notes,
628
+ createdAt: row.created_at,
629
+ updatedAt: row.updated_at
630
+ }));
631
+ }
632
+ function listSubjectiveCheckins(userId, limit = 40) {
633
+ return getDatabase()
634
+ .prepare(`SELECT * FROM nutrition_subjective_checkins
635
+ WHERE user_id = ?
636
+ ORDER BY checked_at DESC
637
+ LIMIT ?`)
638
+ .all(userId, limit).map((row) => ({
639
+ id: row.id,
640
+ userId: row.user_id,
641
+ checkedAt: row.checked_at,
642
+ mealLogId: row.meal_log_id,
643
+ timeRelation: row.time_relation,
644
+ hunger: row.hunger,
645
+ fullness: row.fullness,
646
+ cravings: row.cravings,
647
+ mood: row.mood,
648
+ energy: row.energy,
649
+ focus: row.focus,
650
+ stress: row.stress,
651
+ sleepiness: row.sleepiness,
652
+ crashScore: row.crash_score,
653
+ notes: row.notes,
654
+ createdAt: row.created_at,
655
+ updatedAt: row.updated_at
656
+ }));
657
+ }
658
+ function listGutCheckins(userId, limit = 40) {
659
+ return getDatabase()
660
+ .prepare(`SELECT * FROM nutrition_gut_checkins
661
+ WHERE user_id = ?
662
+ ORDER BY checked_at DESC
663
+ LIMIT ?`)
664
+ .all(userId, limit).map((row) => ({
665
+ id: row.id,
666
+ userId: row.user_id,
667
+ checkedAt: row.checked_at,
668
+ mealLogId: row.meal_log_id,
669
+ bristolStoolType: row.bristol_stool_type,
670
+ stoolFrequency: row.stool_frequency,
671
+ bloating: row.bloating,
672
+ gas: row.gas,
673
+ reflux: row.reflux,
674
+ abdominalPain: row.abdominal_pain,
675
+ urgency: row.urgency,
676
+ nausea: row.nausea,
677
+ constipation: row.constipation,
678
+ diarrhea: row.diarrhea,
679
+ triggerTags: parseJson(row.trigger_tags_json, []),
680
+ notes: row.notes,
681
+ createdAt: row.created_at,
682
+ updatedAt: row.updated_at
683
+ }));
684
+ }
685
+ function listHypotheses(userId, limit = 20) {
686
+ return getDatabase()
687
+ .prepare(`SELECT * FROM nutrition_hypotheses
688
+ WHERE user_id = ?
689
+ ORDER BY confidence DESC, evidence_count DESC, updated_at DESC
690
+ LIMIT ?`)
691
+ .all(userId, limit).map((row) => ({
692
+ id: row.id,
693
+ userId: row.user_id,
694
+ title: row.title,
695
+ summary: row.summary,
696
+ status: row.status,
697
+ confidence: row.confidence,
698
+ evidenceCount: row.evidence_count,
699
+ signalKey: row.signal_key,
700
+ outcomeKey: row.outcome_key,
701
+ lagWindow: row.lag_window,
702
+ evidence: parseJson(row.evidence_json, {}),
703
+ confounders: parseJson(row.confounders_json, []),
704
+ suggestedAction: row.suggested_action,
705
+ createdAt: row.created_at,
706
+ updatedAt: row.updated_at
707
+ }));
708
+ }
709
+ function listExperiments(userId, limit = 20) {
710
+ return getDatabase()
711
+ .prepare(`SELECT * FROM nutrition_experiments
712
+ WHERE user_id = ?
713
+ ORDER BY updated_at DESC
714
+ LIMIT ?`)
715
+ .all(userId, limit).map((row) => ({
716
+ id: row.id,
717
+ userId: row.user_id,
718
+ hypothesisId: row.hypothesis_id,
719
+ title: row.title,
720
+ status: row.status,
721
+ baselineStart: row.baseline_start,
722
+ baselineEnd: row.baseline_end,
723
+ interventionStart: row.intervention_start,
724
+ interventionEnd: row.intervention_end,
725
+ trackedOutcomes: parseJson(row.tracked_outcomes_json, []),
726
+ protocol: parseJson(row.protocol_json, {}),
727
+ adherence: parseJson(row.adherence_json, {}),
728
+ resultSummary: row.result_summary,
729
+ createdAt: row.created_at,
730
+ updatedAt: row.updated_at
731
+ }));
732
+ }
733
+ function buildTodayLedger(logs, target) {
734
+ const today = new Date().toISOString().slice(0, 10);
735
+ const todayLogs = logs.filter((log) => log.dayKey === today);
736
+ const totals = todayLogs.reduce((acc, log) => ({
737
+ calories: acc.calories + log.totals.calories,
738
+ proteinGrams: acc.proteinGrams + log.totals.proteinGrams,
739
+ carbohydrateGrams: acc.carbohydrateGrams + log.totals.carbohydrateGrams,
740
+ fatGrams: acc.fatGrams + log.totals.fatGrams,
741
+ fiberGrams: acc.fiberGrams + log.totals.fiberGrams,
742
+ sodiumMg: acc.sodiumMg + log.totals.sodiumMg,
743
+ caffeineMg: acc.caffeineMg + log.totals.caffeineMg,
744
+ alcoholGrams: acc.alcoholGrams + log.totals.alcoholGrams
745
+ }), {
746
+ calories: 0,
747
+ proteinGrams: 0,
748
+ carbohydrateGrams: 0,
749
+ fatGrams: 0,
750
+ fiberGrams: 0,
751
+ sodiumMg: 0,
752
+ caffeineMg: 0,
753
+ alcoholGrams: 0
754
+ });
755
+ return {
756
+ dateKey: today,
757
+ meals: todayLogs,
758
+ totals,
759
+ targetCalories: target.calorieTarget,
760
+ calorieDelta: round(totals.calories - n(target.calorieTarget), 0),
761
+ proteinCoverage: n(target.proteinGramsTarget) > 0
762
+ ? round(totals.proteinGrams / n(target.proteinGramsTarget), 2)
763
+ : null,
764
+ fiberCoverage: n(target.fiberGramsTarget) > 0
765
+ ? round(totals.fiberGrams / n(target.fiberGramsTarget), 2)
766
+ : null,
767
+ unconfirmedCount: todayLogs.filter((log) => log.confirmationState !== "confirmed").length
768
+ };
769
+ }
770
+ function buildWeightTrend(body) {
771
+ const withWeight = body
772
+ .filter((entry) => typeof entry.weightKg === "number")
773
+ .slice()
774
+ .reverse();
775
+ const latest = withWeight.at(-1) ?? null;
776
+ const previous = withWeight.length > 1 ? withWeight.at(-2) : null;
777
+ const first = withWeight[0] ?? null;
778
+ const latestWeight = latest?.weightKg ?? null;
779
+ const deltaFromPrevious = latestWeight != null && previous?.weightKg != null
780
+ ? round(latestWeight - previous.weightKg, 2)
781
+ : null;
782
+ const deltaFromFirst = latestWeight != null && first?.weightKg != null
783
+ ? round(latestWeight - first.weightKg, 2)
784
+ : null;
785
+ return {
786
+ latestWeightKg: latestWeight,
787
+ latestCheckedAt: latest?.checkedAt ?? null,
788
+ deltaFromPreviousKg: deltaFromPrevious,
789
+ deltaFromFirstKg: deltaFromFirst,
790
+ trendWeightKg: withWeight.length > 0
791
+ ? round(average(withWeight.slice(-7).map((entry) => entry.weightKg)) ?? 0, 2)
792
+ : null,
793
+ weeklyRateKg: withWeight.length >= 2 && first && latest
794
+ ? round(((latest.weightKg - first.weightKg) /
795
+ Math.max(1, (new Date(latest.checkedAt).getTime() -
796
+ new Date(first.checkedAt).getTime()) /
797
+ (7 * 24 * 60 * 60 * 1000))), 2)
798
+ : null,
799
+ waistToHeightRatio: null
800
+ };
801
+ }
802
+ function buildFoodQuality(logs) {
803
+ const items = logs.flatMap((log) => log.items);
804
+ const totals = sumItems(items);
805
+ const tagCounts = new Map();
806
+ for (const item of items) {
807
+ for (const tag of item.tags) {
808
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
809
+ }
810
+ }
811
+ const total = Math.max(1, items.length);
812
+ const calorieBase = Math.max(1, totals.calories / 1000);
813
+ const highProteinShare = round((tagCounts.get("high_protein") ?? 0) / total, 2);
814
+ const highFiberShare = round((tagCounts.get("high_fiber") ?? 0) / total, 2);
815
+ const ultraProcessedShare = round(((tagCounts.get("ultra_processed") ?? 0) +
816
+ (tagCounts.get("nova_4") ?? 0)) /
817
+ total, 2);
818
+ return {
819
+ itemCount: items.length,
820
+ qualityScore: items.length > 0
821
+ ? round(Math.max(0, Math.min(10, 4 +
822
+ highProteinShare * 2 +
823
+ highFiberShare * 2 -
824
+ ultraProcessedShare * 2)), 1)
825
+ : null,
826
+ proteinPer1000Kcal: items.length > 0 ? round(totals.proteinGrams / calorieBase, 1) : null,
827
+ fiberPer1000Kcal: items.length > 0 ? round(totals.fiberGrams / calorieBase, 1) : null,
828
+ highProteinShare,
829
+ highFiberShare,
830
+ ultraProcessedShare,
831
+ lateMealCount: logs.filter((log) => {
832
+ const hour = new Date(log.loggedAt).getHours();
833
+ return hour >= 21 || hour < 4;
834
+ }).length,
835
+ topTags: Array.from(tagCounts.entries())
836
+ .sort((a, b) => b[1] - a[1])
837
+ .slice(0, 8)
838
+ .map(([tag, count]) => ({ tag, count }))
839
+ };
840
+ }
841
+ function buildSubjectiveSummary(checkins) {
842
+ return {
843
+ checkinCount: checkins.length,
844
+ averageEnergy: average(checkins.map((entry) => entry.energy)),
845
+ averageFocus: average(checkins.map((entry) => entry.focus)),
846
+ averageCravings: average(checkins.map((entry) => entry.cravings)),
847
+ averageCrash: average(checkins.map((entry) => entry.crashScore)),
848
+ recent: checkins.slice(0, 8)
849
+ };
850
+ }
851
+ function buildGutSummary(checkins) {
852
+ const averageBloating = average(checkins.map((entry) => entry.bloating));
853
+ const averageReflux = average(checkins.map((entry) => entry.reflux));
854
+ const averageAbdominalPain = average(checkins.map((entry) => entry.abdominalPain));
855
+ const discomfortAverage = average([
856
+ averageBloating,
857
+ averageReflux,
858
+ averageAbdominalPain
859
+ ]);
860
+ return {
861
+ checkinCount: checkins.length,
862
+ averageBloating,
863
+ averageReflux,
864
+ averageAbdominalPain,
865
+ gutComfortScore: discomfortAverage == null ? null : round(Math.max(0, 10 - discomfortAverage), 1),
866
+ bristolDistribution: [1, 2, 3, 4, 5, 6, 7].map((type) => ({
867
+ type,
868
+ count: checkins.filter((entry) => entry.bristolStoolType === type).length
869
+ })),
870
+ recent: checkins.slice(0, 8)
871
+ };
872
+ }
873
+ function buildGeneratedHypotheses(logs, subjective, gut, appearance) {
874
+ const cards = [];
875
+ const lateMeals = logs.filter((log) => new Date(log.loggedAt).getHours() >= 21);
876
+ const lowEnergy = subjective.filter((entry) => typeof entry.energy === "number" && entry.energy <= 4);
877
+ const gutSymptoms = gut.filter((entry) => n(entry.bloating) >= 6 || n(entry.reflux) >= 6 || n(entry.abdominalPain) >= 6);
878
+ const puffiness = appearance.filter((entry) => typeof entry.facePuffiness === "number" && entry.facePuffiness >= 6);
879
+ if (lateMeals.length >= 2 && puffiness.length >= 1) {
880
+ cards.push({
881
+ id: "generated_late_meal_puffiness",
882
+ title: "Late meals may be affecting next-day look",
883
+ summary: "Forge sees repeated late eating plus recent face-puffiness check-ins. Track sodium, alcohol, and bedtime for a cleaner read.",
884
+ status: "candidate",
885
+ confidence: 0.35,
886
+ evidenceCount: lateMeals.length + puffiness.length,
887
+ signalKey: "late_meal",
888
+ outcomeKey: "face_puffiness",
889
+ lagWindow: "next_morning",
890
+ confounders: ["sleep", "sodium", "alcohol", "stress"],
891
+ suggestedAction: "Run a 7-day experiment: log dinner time, sodium-heavy foods, and morning face puffiness."
892
+ });
893
+ }
894
+ if (lowEnergy.length >= 2 && logs.length >= 3) {
895
+ cards.push({
896
+ id: "generated_post_meal_energy",
897
+ title: "Food and afternoon energy need tighter logging",
898
+ summary: "Low-energy check-ins exist near logged meals. Add 2-hour post-meal energy scores to identify stable lunches versus crash meals.",
899
+ status: "candidate",
900
+ confidence: 0.3,
901
+ evidenceCount: lowEnergy.length,
902
+ signalKey: "meal_composition",
903
+ outcomeKey: "energy",
904
+ lagWindow: "2h_post_meal",
905
+ confounders: ["sleep", "caffeine", "training", "work stress"],
906
+ suggestedAction: "For the next five lunches, log protein/fiber and a 2-hour energy score."
907
+ });
908
+ }
909
+ if (gutSymptoms.length >= 2) {
910
+ cards.push({
911
+ id: "generated_gut_trigger_window",
912
+ title: "Gut trigger window is ready for an n-of-1 test",
913
+ summary: "Bloating, reflux, or abdominal pain has repeated enough to start tagging dairy, gluten, high-FODMAP, spice, fat, alcohol, and fiber jumps.",
914
+ status: "candidate",
915
+ confidence: 0.32,
916
+ evidenceCount: gutSymptoms.length,
917
+ signalKey: "food_trigger_tags",
918
+ outcomeKey: "gut_symptoms",
919
+ lagWindow: "6h_to_48h",
920
+ confounders: ["stress", "sleep", "travel", "training load"],
921
+ suggestedAction: "Choose one suspected trigger and compare two baseline weeks with one intervention week."
922
+ });
923
+ }
924
+ return cards;
925
+ }
926
+ export function getWeightLossViewData(userIds) {
927
+ const generatedAt = new Date().toISOString();
928
+ const userId = resolveReadUser(userIds);
929
+ const targetRow = getDatabase()
930
+ .prepare(`SELECT * FROM nutrition_targets WHERE user_id = ?`)
931
+ .get(userId);
932
+ const target = mapTarget(targetRow, userId);
933
+ const logs = listFoodLogs(userId);
934
+ const body = listBodyCheckins(userId);
935
+ const appearance = listAppearanceCheckins(userId);
936
+ const subjective = listSubjectiveCheckins(userId);
937
+ const gut = listGutCheckins(userId);
938
+ const storedHypotheses = listHypotheses(userId);
939
+ const generatedHypotheses = buildGeneratedHypotheses(logs, subjective, gut, appearance);
940
+ const experiments = listExperiments(userId);
941
+ const todayLedger = buildTodayLedger(logs, target);
942
+ const recentLogs = logs.slice(0, 14);
943
+ const recentTotals = sumItems(recentLogs.flatMap((log) => log.items));
944
+ const trackedDays = new Set(logs.map((log) => log.dayKey)).size;
945
+ const averageCalories = trackedDays > 0 ? round(recentTotals.calories / trackedDays, 0) : 0;
946
+ const inferredTdee = target.calorieTarget != null
947
+ ? round(target.calorieTarget + Math.abs(n(target.weeklyRateGoalKg)) * 1100, 0)
948
+ : null;
949
+ return {
950
+ generatedAt,
951
+ userId,
952
+ target,
953
+ summary: {
954
+ loggedMealCount: logs.length,
955
+ trackedDays,
956
+ todayCalories: todayLedger.totals.calories,
957
+ targetCalories: target.calorieTarget,
958
+ todayCalorieDelta: todayLedger.calorieDelta,
959
+ averageCalories,
960
+ inferredTdee,
961
+ proteinCoverage: todayLedger.proteinCoverage,
962
+ fiberCoverage: todayLedger.fiberCoverage,
963
+ unconfirmedCount: logs.filter((log) => log.confirmationState !== "confirmed")
964
+ .length,
965
+ hypothesisCount: storedHypotheses.length + generatedHypotheses.length,
966
+ dataQualityScore: round(Math.min(1, trackedDays / 7 +
967
+ Math.min(0.2, body.length * 0.04) +
968
+ Math.min(0.2, subjective.length * 0.02) +
969
+ Math.min(0.2, gut.length * 0.02)), 2)
970
+ },
971
+ todayLedger,
972
+ recentMeals: logs.slice(0, 30),
973
+ energyModel: {
974
+ activeEnergyCalories: null,
975
+ restingEnergyCalories: null,
976
+ wearableConfidence: "directional",
977
+ inferredTdee,
978
+ estimatedTdeeKcal: inferredTdee,
979
+ activeBurnKcal: null,
980
+ movementCaloriesKcal: null,
981
+ averageCalorieIntake: averageCalories,
982
+ currentDeficitEstimate: inferredTdee != null ? round(averageCalories - inferredTdee, 0) : null,
983
+ estimatedDailyEnergyBalanceKcal: inferredTdee != null ? round(averageCalories - inferredTdee, 0) : null
984
+ },
985
+ weightTrend: buildWeightTrend(body),
986
+ bodyCheckins: body,
987
+ appearanceCheckins: appearance,
988
+ foodQuality: buildFoodQuality(logs),
989
+ trainingFuel: {
990
+ linkedWorkoutMealCount: logs.filter((log) => log.workoutId).length,
991
+ preWorkoutFuelCount: logs.filter((log) => log.items.some((item) => item.tags.includes("pre_workout"))).length,
992
+ postWorkoutProteinCount: logs.filter((log) => log.items.some((item) => item.tags.includes("post_workout"))).length,
993
+ fuelingScore: logs.length > 0 ? 5 : null,
994
+ recentTrainingLoad: null,
995
+ carbsPerTrainingLoad: null,
996
+ lowEnergyAvailabilityFlag: averageCalories > 0 && inferredTdee != null
997
+ ? averageCalories < inferredTdee - 750
998
+ : false
999
+ },
1000
+ subjective: buildSubjectiveSummary(subjective),
1001
+ gut: buildGutSummary(gut),
1002
+ hypotheses: [...storedHypotheses, ...generatedHypotheses],
1003
+ experiments,
1004
+ dataQuality: {
1005
+ sourceConfidence: logs.length === 0
1006
+ ? "empty"
1007
+ : logs.some((log) => log.confirmationState !== "confirmed")
1008
+ ? "mixed"
1009
+ : "confirmed",
1010
+ missingHighValueCheckins: [
1011
+ logs.length === 0 ? "food log" : null,
1012
+ body.length === 0 ? "body measurement" : null,
1013
+ subjective.length < 3 ? "post-meal energy" : null,
1014
+ gut.length < 3 ? "gut symptom" : null
1015
+ ].filter((value) => Boolean(value)),
1016
+ notes: "AI, photo, and barcode estimates stay provisional until confirmed by the user."
1017
+ }
1018
+ };
1019
+ }
1020
+ function cacheFood(input) {
1021
+ const now = nowIso();
1022
+ const existing = getDatabase()
1023
+ .prepare(`SELECT id FROM nutrition_food_catalog WHERE source = ? AND source_id = ?`)
1024
+ .get(input.source, input.sourceId);
1025
+ const id = existing?.id ?? newId("food");
1026
+ getDatabase()
1027
+ .prepare(`INSERT INTO nutrition_food_catalog (
1028
+ id, source, source_id, barcode, name, brand, serving_label,
1029
+ serving_grams, calories, protein_grams, carbohydrate_grams, fat_grams,
1030
+ fiber_grams, sugar_grams, sodium_mg, potassium_mg, nova_group,
1031
+ nutri_score, tags_json, nutrients_json, confidence, created_at, updated_at
1032
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1033
+ ON CONFLICT(source, source_id) DO UPDATE SET
1034
+ barcode = excluded.barcode,
1035
+ name = excluded.name,
1036
+ brand = excluded.brand,
1037
+ serving_label = excluded.serving_label,
1038
+ serving_grams = excluded.serving_grams,
1039
+ calories = excluded.calories,
1040
+ protein_grams = excluded.protein_grams,
1041
+ carbohydrate_grams = excluded.carbohydrate_grams,
1042
+ fat_grams = excluded.fat_grams,
1043
+ fiber_grams = excluded.fiber_grams,
1044
+ sugar_grams = excluded.sugar_grams,
1045
+ sodium_mg = excluded.sodium_mg,
1046
+ potassium_mg = excluded.potassium_mg,
1047
+ nova_group = excluded.nova_group,
1048
+ nutri_score = excluded.nutri_score,
1049
+ tags_json = excluded.tags_json,
1050
+ nutrients_json = excluded.nutrients_json,
1051
+ confidence = excluded.confidence,
1052
+ updated_at = excluded.updated_at`)
1053
+ .run(id, input.source, input.sourceId, input.barcode ?? null, input.name, input.brand ?? "", input.servingLabel ?? "", input.servingGrams ?? 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.novaGroup ?? null, input.nutriScore ?? null, jsonString(input.tags ?? []), jsonString(input.nutrients ?? {}), input.confidence ?? 0.65, now, now);
1054
+ return mapFood(getDatabase()
1055
+ .prepare(`SELECT * FROM nutrition_food_catalog WHERE id = ?`)
1056
+ .get(id));
1057
+ }
1058
+ function localFoodSearch(query, limit) {
1059
+ return getDatabase()
1060
+ .prepare(`SELECT *
1061
+ FROM nutrition_food_catalog
1062
+ WHERE name LIKE ? OR brand LIKE ? OR barcode = ?
1063
+ ORDER BY updated_at DESC
1064
+ LIMIT ?`)
1065
+ .all(`%${query}%`, `%${query}%`, query, limit).map(mapFood);
1066
+ }
1067
+ function readOffNutrient(product, key) {
1068
+ const nutriments = product.nutriments;
1069
+ const value = nutriments?.[key];
1070
+ return typeof value === "number" ? value : null;
1071
+ }
1072
+ function mapOpenFoodFactsProduct(product) {
1073
+ const code = typeof product.code === "string" ? product.code : "";
1074
+ const name = typeof product.product_name === "string" && product.product_name.trim()
1075
+ ? product.product_name.trim()
1076
+ : typeof product.generic_name === "string"
1077
+ ? product.generic_name.trim()
1078
+ : "";
1079
+ if (!code || !name) {
1080
+ return null;
1081
+ }
1082
+ const nova = typeof product.nova_group === "number" ? product.nova_group : null;
1083
+ const tags = [
1084
+ nova === 4 ? "ultra_processed" : null,
1085
+ nova ? `nova_${nova}` : null
1086
+ ].filter((tag) => Boolean(tag));
1087
+ return cacheFood({
1088
+ source: "open_food_facts",
1089
+ sourceId: code,
1090
+ barcode: code,
1091
+ name,
1092
+ brand: typeof product.brands === "string" ? product.brands.split(",")[0].trim() : "",
1093
+ servingLabel: typeof product.serving_size === "string" ? product.serving_size : "",
1094
+ calories: readOffNutrient(product, "energy-kcal_100g"),
1095
+ proteinGrams: readOffNutrient(product, "proteins_100g"),
1096
+ carbohydrateGrams: readOffNutrient(product, "carbohydrates_100g"),
1097
+ fatGrams: readOffNutrient(product, "fat_100g"),
1098
+ fiberGrams: readOffNutrient(product, "fiber_100g"),
1099
+ sugarGrams: readOffNutrient(product, "sugars_100g"),
1100
+ sodiumMg: readOffNutrient(product, "sodium_100g") != null
1101
+ ? readOffNutrient(product, "sodium_100g") * 1000
1102
+ : null,
1103
+ novaGroup: nova,
1104
+ nutriScore: typeof product.nutriscore_grade === "string"
1105
+ ? product.nutriscore_grade
1106
+ : null,
1107
+ tags,
1108
+ nutrients: product.nutriments ?? {},
1109
+ confidence: 0.72
1110
+ });
1111
+ }
1112
+ async function searchOpenFoodFacts(query, limit) {
1113
+ const url = new URL("https://world.openfoodfacts.org/cgi/search.pl");
1114
+ url.searchParams.set("search_terms", query);
1115
+ url.searchParams.set("search_simple", "1");
1116
+ url.searchParams.set("action", "process");
1117
+ url.searchParams.set("json", "1");
1118
+ url.searchParams.set("page_size", String(Math.min(limit, 20)));
1119
+ const response = await fetch(url, {
1120
+ headers: { accept: "application/json" }
1121
+ });
1122
+ if (!response.ok) {
1123
+ return [];
1124
+ }
1125
+ const payload = (await response.json());
1126
+ return (payload.products ?? [])
1127
+ .map(mapOpenFoodFactsProduct)
1128
+ .filter((food) => Boolean(food));
1129
+ }
1130
+ async function lookupOpenFoodFactsBarcode(barcode) {
1131
+ const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json`, { headers: { accept: "application/json" } });
1132
+ if (!response.ok) {
1133
+ return [];
1134
+ }
1135
+ const payload = (await response.json());
1136
+ const food = payload.product ? mapOpenFoodFactsProduct(payload.product) : null;
1137
+ return food ? [food] : [];
1138
+ }
1139
+ function mapFdcFood(food) {
1140
+ const sourceId = typeof food.fdcId === "number" ? String(food.fdcId) : String(food.fdcId ?? "");
1141
+ const name = typeof food.description === "string" ? food.description.trim() : "";
1142
+ if (!sourceId || !name) {
1143
+ return null;
1144
+ }
1145
+ const nutrients = Array.isArray(food.foodNutrients)
1146
+ ? food.foodNutrients
1147
+ : [];
1148
+ const findNutrient = (names) => {
1149
+ const entry = nutrients.find((nutrient) => names.some((namePart) => String(nutrient.nutrientName ?? "")
1150
+ .toLowerCase()
1151
+ .includes(namePart)));
1152
+ return typeof entry?.value === "number" ? entry.value : null;
1153
+ };
1154
+ return cacheFood({
1155
+ source: "usda_fdc",
1156
+ sourceId,
1157
+ name,
1158
+ brand: typeof food.brandOwner === "string"
1159
+ ? food.brandOwner
1160
+ : typeof food.brandName === "string"
1161
+ ? food.brandName
1162
+ : "",
1163
+ calories: findNutrient(["energy"]),
1164
+ proteinGrams: findNutrient(["protein"]),
1165
+ carbohydrateGrams: findNutrient(["carbohydrate"]),
1166
+ fatGrams: findNutrient(["total lipid", "total fat"]),
1167
+ fiberGrams: findNutrient(["fiber"]),
1168
+ sugarGrams: findNutrient(["sugars"]),
1169
+ sodiumMg: findNutrient(["sodium"]),
1170
+ potassiumMg: findNutrient(["potassium"]),
1171
+ nutrients: { foodNutrients: nutrients },
1172
+ confidence: 0.78
1173
+ });
1174
+ }
1175
+ async function searchFoodDataCentral(query, limit) {
1176
+ const apiKey = process.env.FDC_API_KEY?.trim() || "DEMO_KEY";
1177
+ const response = await fetch("https://api.nal.usda.gov/fdc/v1/foods/search", {
1178
+ method: "POST",
1179
+ headers: {
1180
+ accept: "application/json",
1181
+ "content-type": "application/json"
1182
+ },
1183
+ body: JSON.stringify({
1184
+ api_key: apiKey,
1185
+ query,
1186
+ pageSize: Math.min(limit, 15)
1187
+ })
1188
+ });
1189
+ if (!response.ok) {
1190
+ return [];
1191
+ }
1192
+ const payload = (await response.json());
1193
+ return (payload.foods ?? [])
1194
+ .map(mapFdcFood)
1195
+ .filter((food) => Boolean(food));
1196
+ }
1197
+ export async function searchNutritionFoods(input) {
1198
+ const parsed = nutritionFoodSearchSchema.parse(input);
1199
+ const local = localFoodSearch(parsed.query, parsed.limit);
1200
+ const seen = new Set(local.map((food) => `${food.source}:${food.sourceId}`));
1201
+ const external = local.length >= parsed.limit
1202
+ ? []
1203
+ : [
1204
+ ...(await searchOpenFoodFacts(parsed.query, parsed.limit - local.length).catch(() => [])),
1205
+ ...(await searchFoodDataCentral(parsed.query, parsed.limit - local.length).catch(() => []))
1206
+ ].filter((food) => {
1207
+ const key = `${food.source}:${food.sourceId}`;
1208
+ if (seen.has(key)) {
1209
+ return false;
1210
+ }
1211
+ seen.add(key);
1212
+ return true;
1213
+ });
1214
+ return {
1215
+ foods: [...local, ...external].slice(0, parsed.limit),
1216
+ sources: ["local_cache", "open_food_facts", "usda_fdc"]
1217
+ };
1218
+ }
1219
+ export async function lookupNutritionBarcode(input) {
1220
+ const parsed = nutritionBarcodeLookupSchema.parse(input);
1221
+ const local = localFoodSearch(parsed.barcode, parsed.limit);
1222
+ const barcodeMatches = local.filter((food) => food.barcode === parsed.barcode);
1223
+ if (barcodeMatches.length > 0) {
1224
+ const foods = barcodeMatches.slice(0, parsed.limit);
1225
+ return { food: foods[0] ?? null, foods };
1226
+ }
1227
+ const foods = (await lookupOpenFoodFactsBarcode(parsed.barcode).catch(() => [])).slice(0, parsed.limit);
1228
+ return { food: foods[0] ?? null, foods };
1229
+ }
1230
+ function extractJsonObject(text) {
1231
+ const trimmed = text.trim();
1232
+ if (trimmed.startsWith("{")) {
1233
+ return trimmed;
1234
+ }
1235
+ const start = trimmed.indexOf("{");
1236
+ const end = trimmed.lastIndexOf("}");
1237
+ if (start >= 0 && end > start) {
1238
+ return trimmed.slice(start, end + 1);
1239
+ }
1240
+ return trimmed;
1241
+ }
1242
+ function getNutritionCodexProfile(connectionId) {
1243
+ const settings = getSettings();
1244
+ const selectedConnectionId = connectionId?.trim() ||
1245
+ settings.modelSettings.forgeAgent.basicChat.connectionId ||
1246
+ settings.modelSettings.forgeAgent.wiki.connectionId ||
1247
+ "";
1248
+ if (!selectedConnectionId) {
1249
+ return null;
1250
+ }
1251
+ const row = getDatabase()
1252
+ .prepare(`SELECT provider, auth_mode, base_url, model, secret_id, enabled
1253
+ FROM ai_model_connections
1254
+ WHERE id = ?`)
1255
+ .get(selectedConnectionId);
1256
+ if (!row ||
1257
+ row.enabled !== 1 ||
1258
+ row.provider !== "openai-codex" ||
1259
+ row.auth_mode !== "oauth" ||
1260
+ !row.secret_id) {
1261
+ return null;
1262
+ }
1263
+ return {
1264
+ provider: "openai-codex",
1265
+ baseUrl: row.base_url || "https://chatgpt.com/backend-api",
1266
+ model: row.model,
1267
+ systemPrompt: "",
1268
+ secretId: row.secret_id,
1269
+ metadata: {}
1270
+ };
1271
+ }
1272
+ const parsedMealItemSchema = z.object({
1273
+ name: z.string().trim().min(1),
1274
+ quantity: z.coerce.number().positive().default(1),
1275
+ unit: z.string().trim().min(1).default("serving"),
1276
+ grams: optionalNumberSchema,
1277
+ calories: optionalNumberSchema,
1278
+ proteinGrams: optionalNumberSchema,
1279
+ carbohydrateGrams: optionalNumberSchema,
1280
+ fatGrams: optionalNumberSchema,
1281
+ fiberGrams: optionalNumberSchema,
1282
+ sugarGrams: optionalNumberSchema,
1283
+ sodiumMg: optionalNumberSchema,
1284
+ tags: tagsSchema,
1285
+ confidence: z.coerce.number().min(0).max(1).default(0.45)
1286
+ });
1287
+ const parsedMealSchema = z.object({
1288
+ mealLabel: z.string().trim().default(""),
1289
+ loggedAt: z.string().datetime().optional(),
1290
+ items: z.array(parsedMealItemSchema).min(1),
1291
+ uncertaintyReasons: z.array(z.string()).default([]),
1292
+ clarificationQuestions: z.array(z.string()).default([]),
1293
+ tags: tagsSchema
1294
+ });
1295
+ export async function parseNutritionFoodLogWithChatGpt(input, llm) {
1296
+ const parsed = nutritionParseRequestSchema.parse(input);
1297
+ const profile = getNutritionCodexProfile(parsed.connectionId);
1298
+ if (!profile) {
1299
+ throw new Error("Connect an OpenAI Codex OAuth model in Settings -> Models before using ChatGPT food parsing.");
1300
+ }
1301
+ const userId = resolveWriteUser(parsed.userId);
1302
+ const context = getWeightLossViewData([userId]);
1303
+ const prompt = `Parse this food log into strict JSON only.
1304
+
1305
+ Input:
1306
+ ${parsed.text}
1307
+
1308
+ Known targets:
1309
+ ${JSON.stringify(context.target)}
1310
+
1311
+ Recent meals:
1312
+ ${JSON.stringify(context.recentMeals.slice(0, 8).map((meal) => ({
1313
+ mealLabel: meal.mealLabel,
1314
+ items: meal.items.map((item) => item.name)
1315
+ })))}
1316
+
1317
+ Return this JSON shape:
1318
+ {
1319
+ "mealLabel": "short label",
1320
+ "loggedAt": "ISO time if known",
1321
+ "items": [
1322
+ {
1323
+ "name": "food name",
1324
+ "quantity": 1,
1325
+ "unit": "serving|g|ml|piece|cup|tbsp|custom",
1326
+ "grams": null,
1327
+ "calories": null,
1328
+ "proteinGrams": null,
1329
+ "carbohydrateGrams": null,
1330
+ "fatGrams": null,
1331
+ "fiberGrams": null,
1332
+ "sugarGrams": null,
1333
+ "sodiumMg": null,
1334
+ "tags": ["high_protein", "late_meal", "spicy", "dairy", "high_fodmap_candidate"],
1335
+ "confidence": 0.0
1336
+ }
1337
+ ],
1338
+ "uncertaintyReasons": [],
1339
+ "clarificationQuestions": [],
1340
+ "tags": []
1341
+ }
1342
+
1343
+ Use null for unknown nutrients. Prefer conservative estimates and mark uncertainty.`;
1344
+ const result = await llm.runTextPrompt(profile, {
1345
+ systemPrompt: "You are Forge's nutrition parser. Return strict JSON only. Never claim precision when food quantity is unclear.",
1346
+ prompt
1347
+ });
1348
+ const parsedResult = parsedMealSchema.parse(JSON.parse(extractJsonObject(result.outputText)));
1349
+ const candidate = {
1350
+ userId,
1351
+ loggedAt: parsedResult.loggedAt ?? parsed.mealTime ?? nowIso(),
1352
+ mealLabel: parsedResult.mealLabel || "ChatGPT parsed meal",
1353
+ source: parsed.imageRefs.length > 0 ? "photo" : "chatgpt",
1354
+ confirmationState: "candidate",
1355
+ notes: parsedResult.uncertaintyReasons.join("; "),
1356
+ imageRefs: parsed.imageRefs,
1357
+ parserProvenance: {
1358
+ provider: "openai-codex",
1359
+ model: profile.model,
1360
+ uncertaintyReasons: parsedResult.uncertaintyReasons,
1361
+ clarificationQuestions: parsedResult.clarificationQuestions,
1362
+ rawText: parsed.text
1363
+ },
1364
+ links: [],
1365
+ items: parsedResult.items.map((item) => ({
1366
+ ...item,
1367
+ nutrients: {},
1368
+ tags: Array.from(new Set([...item.tags, ...parsedResult.tags]))
1369
+ }))
1370
+ };
1371
+ const log = parsed.commitCandidate ? createNutritionFoodLog(candidate) : null;
1372
+ return {
1373
+ candidate,
1374
+ log,
1375
+ clarificationQuestions: parsedResult.clarificationQuestions,
1376
+ uncertaintyReasons: parsedResult.uncertaintyReasons
1377
+ };
1378
+ }