wellness-nourish 0.6.1 → 0.6.3

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.3] - 2026-05-20
10
+
11
+ ### Added
12
+
13
+ - **`nourish_goal_progress` workflow tool.** Reads local intake + hydration + configured goals and returns daily/weekly progress for `today`, `yesterday`, `last_7_days`, or `last_30_days`. Per-day shape includes `kcal/protein_g/carb_g/fat_g/water_ml` (consumed, goal, pct, delta_to_goal), plus an `on_target` flag (every configured macro within ±10% of goal). Multi-day periods also return totals, per-day averages, `days_on_target`, and `days_with_data`. Output includes locale-aware `recommendations[]` (pt-BR if profile language is Portuguese, else en) — concrete next actions like "Hidratação 600 ml abaixo da meta hoje — beba 2 copo(s) de água nas próximas horas". Read-only: no logging side effects, no `explicit_user_intent` required. Registered in agent manifest `RECOMMENDED_FIRST_CALLS` right after `nourish_connection_status` so agents reach for it before reasoning about meal suggestions. Tool count: 41 → 42.
14
+
15
+ ## [0.6.2] - 2026-05-19
16
+
17
+ ### Added
18
+
19
+ - **30 commonly-missing Brazilian regional foods added to the TACO subset.** The curated TACO dataset grew from 105 to 135 entries with regional specialties that agents frequently encountered as misses. New coverage: cassava family (`farinha de mandioca`, `polvilho azedo`, `biscoito de polvilho`), corn-based desserts (`pamonha`, `curau`, `canjica branca`), regional bean dishes (`feijão tropeiro`, `baião de dois`), jerked-beef preparations (`carne seca`, `paçoca de carne seca`, `arroz carreteiro`, `escondidinho de carne seca`), Bahian/Afro-Brazilian dishes (`acarajé`, `vatapá`, `caruru`, `bobó de camarão`, `moqueca baiana`), Amazonian (`tucupi`, `cupuaçu`, `açaí na tigela`), drinks (`guaraná`, `mate gelado`, `chocolate quente`, `caldo de cana`), and classic desserts (`beijinho`, `quindim`, `pudim de leite`, `goiabada cascão`, `cocada branca`, `paçoca de amendoim`). Each entry ships with realistic per-100g macros, common-serving size, pt-BR aliases, and TACO-style row id (700-range is reserved for regional entries not in the original 597-row publication). Source attribution: TACO 4 (NEPA/UNICAMP) primary, IBGE POF 2017-18 + USDA SR Legacy cross-reference for the entries that were never published in the original table.
20
+
21
+ ### Fixed
22
+
23
+ - **`refrigerante, cola` no longer claims `guaraná` as an alias.** Guaraná is a distinct soda (now its own entry, taco_id 588). Searching "guaraná" used to incorrectly return cola.
24
+ - **`açaí, polpa congelada` no longer claims `açaí na tigela` / `acai bowl` as aliases.** The complete bowl with toppings is a new dedicated entry (taco_id 392) with realistic macros; the polpa entry returns to representing just the frozen pulp.
25
+
9
26
  ## [0.6.1] - 2026-05-11
10
27
 
11
28
  ### Fixed
package/README.md CHANGED
@@ -86,6 +86,16 @@ Run Streamable HTTP locally:
86
86
  node dist/index.js --http
87
87
  ```
88
88
 
89
+ ### ChatGPT App / MCP Apps UI
90
+
91
+ Nourish also exposes a compact MCP Apps-compatible dashboard for ChatGPT and other compatible hosts:
92
+
93
+ - Tool: `nourish_chatgpt_dashboard`
94
+ - UI resource: `ui://widget/nourish-dashboard-v1.html`
95
+ - MIME type: `text/html;profile=mcp-app`
96
+
97
+ The dashboard shows the daily nutrition summary, hydration progress, profile gaps and next-meal coaching, and it can call `nourish_estimate_meal` from the embedded UI for preview-only estimates. It does not write intake, water or goals; mutating tools still require explicit user confirmation through the normal MCP tools.
98
+
89
99
  Optional environment:
90
100
 
91
101
  ```bash
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerNourishChatGptApp(server: McpServer): void;
@@ -0,0 +1,657 @@
1
+ import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server";
2
+ import { z } from "zod";
3
+ import { buildNutritionCoach } from "../services/coach.js";
4
+ import { makeError, makeResponse } from "../services/format.js";
5
+ import { getGoals } from "../services/goals-store.js";
6
+ import { localDate } from "../services/local-date.js";
7
+ import { getPersonalNutritionMemory } from "../services/personal-memory.js";
8
+ import { buildProfileSummary, getProfile, missingCriticalFields, } from "../services/profile-store.js";
9
+ import { buildDailySummary } from "../services/summary.js";
10
+ const NOURISH_DASHBOARD_URI = "ui://widget/nourish-dashboard-v1.html";
11
+ const ChatGptDashboardInputSchema = z.object({
12
+ date: z
13
+ .string()
14
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
15
+ .optional()
16
+ .describe("Local date to summarize as YYYY-MM-DD. Defaults to today."),
17
+ locale: z.enum(["en", "pt-BR"]).default("en"),
18
+ focus: z.enum(["balanced", "protein", "calories", "hydration", "training"]).optional(),
19
+ response_format: z.enum(["json", "markdown"]).default("json"),
20
+ });
21
+ export function registerNourishChatGptApp(server) {
22
+ registerAppResource(server, "nourish-dashboard", NOURISH_DASHBOARD_URI, {
23
+ description: "Interactive Nourish dashboard for ChatGPT and MCP Apps hosts.",
24
+ _meta: {
25
+ ui: {
26
+ prefersBorder: true,
27
+ csp: {
28
+ connectDomains: [],
29
+ resourceDomains: [],
30
+ },
31
+ },
32
+ },
33
+ }, async () => ({
34
+ contents: [
35
+ {
36
+ uri: NOURISH_DASHBOARD_URI,
37
+ mimeType: RESOURCE_MIME_TYPE,
38
+ text: dashboardHtml(),
39
+ _meta: {
40
+ ui: {
41
+ prefersBorder: true,
42
+ csp: {
43
+ connectDomains: [],
44
+ resourceDomains: [],
45
+ },
46
+ },
47
+ },
48
+ },
49
+ ],
50
+ }));
51
+ registerAppTool(server, "nourish_chatgpt_dashboard", {
52
+ title: "Open Nourish dashboard",
53
+ description: "Open an interactive ChatGPT/MCP Apps dashboard for today's nutrition summary, safe meal estimation, and next-meal coaching. Read-only; logging still requires explicit user confirmation through existing tools.",
54
+ inputSchema: ChatGptDashboardInputSchema.shape,
55
+ annotations: {
56
+ readOnlyHint: true,
57
+ destructiveHint: false,
58
+ idempotentHint: true,
59
+ openWorldHint: false,
60
+ },
61
+ _meta: {
62
+ ui: {
63
+ resourceUri: NOURISH_DASHBOARD_URI,
64
+ visibility: ["model", "app"],
65
+ },
66
+ "openai/outputTemplate": NOURISH_DASHBOARD_URI,
67
+ "openai/toolInvocation/invoking": "Opening Nourish dashboard",
68
+ "openai/toolInvocation/invoked": "Nourish dashboard ready",
69
+ },
70
+ }, async (input) => {
71
+ try {
72
+ const params = ChatGptDashboardInputSchema.parse(input);
73
+ const date = params.date ?? localDate();
74
+ const [summary, coach, goals, profile, memory] = await Promise.all([
75
+ buildDailySummary(date),
76
+ buildNutritionCoach({
77
+ mode: "daily_coach",
78
+ date,
79
+ locale: params.locale,
80
+ focus: params.focus,
81
+ }),
82
+ getGoals(),
83
+ getProfile(),
84
+ getPersonalNutritionMemory(),
85
+ ]);
86
+ const payload = {
87
+ ok: true,
88
+ app: {
89
+ name: "Nourish ChatGPT App",
90
+ version: "v1",
91
+ resource_uri: NOURISH_DASHBOARD_URI,
92
+ },
93
+ date,
94
+ locale: params.locale,
95
+ summary: {
96
+ entry_count: summary.entry_count,
97
+ total_nutrients: {
98
+ calories_kcal: summary.total_nutrients.calories_kcal ?? 0,
99
+ protein_g: summary.total_nutrients.protein_g ?? 0,
100
+ carbohydrates_g: summary.total_nutrients.carbohydrates_g ?? 0,
101
+ fat_g: summary.total_nutrients.fat_g ?? 0,
102
+ fiber_g: summary.total_nutrients.fiber_g ?? 0,
103
+ },
104
+ hydration: {
105
+ total_ml: summary.hydration.total_ml,
106
+ goal_ml: summary.hydration.goal_ml,
107
+ progress_percent: summary.hydration.progress_percent,
108
+ },
109
+ goal_progress: summary.goal_progress,
110
+ confidence: summary.confidence,
111
+ source_coverage: summary.source_coverage,
112
+ by_meal: summary.by_meal,
113
+ },
114
+ goals,
115
+ profile: {
116
+ summary: buildProfileSummary(profile),
117
+ missing_critical: missingCriticalFields(profile),
118
+ },
119
+ memory: {
120
+ remembered_meal_count: memory.remembered_meals.length,
121
+ },
122
+ coach: {
123
+ focus: coach.focus,
124
+ gaps: coach.gaps,
125
+ suggested_next_meal: coach.suggested_next_meal,
126
+ next_actions: coach.next_actions,
127
+ warnings: coach.warnings,
128
+ requires_confirmation_to_log: coach.requires_confirmation_to_log,
129
+ },
130
+ quick_actions: [
131
+ {
132
+ label: "Estimate a meal",
133
+ tool: "nourish_estimate_meal",
134
+ read_only: true,
135
+ },
136
+ {
137
+ label: "Review today",
138
+ tool: "nourish_daily_summary",
139
+ read_only: true,
140
+ },
141
+ {
142
+ label: "Log intake",
143
+ tool: "nourish_log_intake",
144
+ requires_explicit_user_intent: true,
145
+ },
146
+ ],
147
+ privacy: {
148
+ local_first: true,
149
+ writes_disabled_in_widget: true,
150
+ note: "This widget estimates and previews only. Persisting intake still requires explicit user confirmation.",
151
+ },
152
+ };
153
+ const markdown = [
154
+ "# Nourish dashboard",
155
+ "",
156
+ `- date: ${date}`,
157
+ `- calories: ${payload.summary.total_nutrients.calories_kcal}`,
158
+ `- protein_g: ${payload.summary.total_nutrients.protein_g}`,
159
+ `- hydration_ml: ${payload.summary.hydration.total_ml}`,
160
+ `- next_meal: ${payload.coach.suggested_next_meal.text}`,
161
+ ].join("\n");
162
+ return toolResponse(makeResponse(payload, params.response_format, markdown));
163
+ }
164
+ catch (error) {
165
+ return toolResponse(makeError("NOURISH_CHATGPT_APP_ERROR", error instanceof Error ? error.message : "Unknown dashboard error."));
166
+ }
167
+ });
168
+ }
169
+ function toolResponse(response) {
170
+ return response;
171
+ }
172
+ function dashboardHtml() {
173
+ return String.raw `<!doctype html>
174
+ <html lang="en">
175
+ <head>
176
+ <meta charset="utf-8">
177
+ <meta name="viewport" content="width=device-width, initial-scale=1">
178
+ <title>Nourish</title>
179
+ <style>
180
+ :root {
181
+ color-scheme: light dark;
182
+ --bg: #f7faf9;
183
+ --panel: #ffffff;
184
+ --panel-2: #eef6f2;
185
+ --text: #17211d;
186
+ --muted: #68746f;
187
+ --border: #d8e2de;
188
+ --green: #0f8f65;
189
+ --blue: #2563eb;
190
+ --amber: #b7791f;
191
+ --danger: #b42318;
192
+ --shadow: 0 16px 40px rgba(18, 35, 28, 0.1);
193
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
194
+ }
195
+
196
+ @media (prefers-color-scheme: dark) {
197
+ :root {
198
+ --bg: #111816;
199
+ --panel: #17221f;
200
+ --panel-2: #1d2d28;
201
+ --text: #edf7f2;
202
+ --muted: #a6b8b0;
203
+ --border: #2f463f;
204
+ --shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
205
+ }
206
+ }
207
+
208
+ * { box-sizing: border-box; }
209
+ body {
210
+ margin: 0;
211
+ background: var(--bg);
212
+ color: var(--text);
213
+ font-size: 14px;
214
+ line-height: 1.45;
215
+ }
216
+ button, input, select { font: inherit; }
217
+ .shell {
218
+ max-width: 860px;
219
+ margin: 0 auto;
220
+ padding: 18px;
221
+ }
222
+ .topbar {
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: space-between;
226
+ gap: 16px;
227
+ margin-bottom: 14px;
228
+ }
229
+ .brand {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 10px;
233
+ min-width: 0;
234
+ }
235
+ .mark {
236
+ width: 34px;
237
+ height: 34px;
238
+ display: grid;
239
+ place-items: center;
240
+ border-radius: 8px;
241
+ color: #fff;
242
+ background: linear-gradient(135deg, var(--green), var(--blue));
243
+ font-weight: 800;
244
+ letter-spacing: 0;
245
+ }
246
+ h1 {
247
+ margin: 0;
248
+ font-size: 18px;
249
+ line-height: 1.2;
250
+ font-weight: 720;
251
+ letter-spacing: 0;
252
+ }
253
+ .date {
254
+ color: var(--muted);
255
+ font-size: 12px;
256
+ margin-top: 2px;
257
+ }
258
+ .status {
259
+ white-space: nowrap;
260
+ border: 1px solid var(--border);
261
+ border-radius: 999px;
262
+ padding: 6px 10px;
263
+ color: var(--muted);
264
+ background: var(--panel);
265
+ font-size: 12px;
266
+ }
267
+ .grid {
268
+ display: grid;
269
+ grid-template-columns: 1.1fr 0.9fr;
270
+ gap: 14px;
271
+ }
272
+ .panel {
273
+ background: var(--panel);
274
+ border: 1px solid var(--border);
275
+ border-radius: 8px;
276
+ box-shadow: var(--shadow);
277
+ padding: 14px;
278
+ min-width: 0;
279
+ }
280
+ .metrics {
281
+ display: grid;
282
+ grid-template-columns: repeat(4, minmax(0, 1fr));
283
+ gap: 8px;
284
+ margin-top: 12px;
285
+ }
286
+ .metric {
287
+ background: var(--panel-2);
288
+ border-radius: 8px;
289
+ padding: 10px;
290
+ min-height: 74px;
291
+ }
292
+ .metric strong {
293
+ display: block;
294
+ font-size: 20px;
295
+ line-height: 1.1;
296
+ margin-bottom: 6px;
297
+ }
298
+ .metric span, .label, .small {
299
+ color: var(--muted);
300
+ font-size: 12px;
301
+ }
302
+ .bars {
303
+ display: grid;
304
+ gap: 10px;
305
+ margin-top: 12px;
306
+ }
307
+ .bar-row {
308
+ display: grid;
309
+ grid-template-columns: 86px minmax(0, 1fr) 44px;
310
+ align-items: center;
311
+ gap: 8px;
312
+ }
313
+ .bar-track {
314
+ height: 8px;
315
+ background: var(--panel-2);
316
+ border-radius: 999px;
317
+ overflow: hidden;
318
+ }
319
+ .bar-fill {
320
+ height: 100%;
321
+ width: 0%;
322
+ background: var(--green);
323
+ border-radius: inherit;
324
+ }
325
+ .next {
326
+ border-left: 4px solid var(--green);
327
+ padding-left: 12px;
328
+ }
329
+ .next h2, .panel h2 {
330
+ margin: 0 0 8px;
331
+ font-size: 14px;
332
+ line-height: 1.2;
333
+ font-weight: 720;
334
+ }
335
+ .meal {
336
+ margin: 0;
337
+ font-size: 16px;
338
+ font-weight: 680;
339
+ }
340
+ .reason {
341
+ color: var(--muted);
342
+ margin: 8px 0 0;
343
+ }
344
+ .actions {
345
+ display: grid;
346
+ gap: 8px;
347
+ margin-top: 12px;
348
+ }
349
+ .action {
350
+ border: 1px solid var(--border);
351
+ border-radius: 8px;
352
+ padding: 9px 10px;
353
+ background: transparent;
354
+ text-align: left;
355
+ }
356
+ .estimate {
357
+ display: grid;
358
+ gap: 8px;
359
+ margin-top: 12px;
360
+ }
361
+ .estimate-row {
362
+ display: grid;
363
+ grid-template-columns: minmax(0, 1fr) auto;
364
+ gap: 8px;
365
+ }
366
+ input {
367
+ width: 100%;
368
+ border: 1px solid var(--border);
369
+ border-radius: 8px;
370
+ padding: 10px 11px;
371
+ background: var(--panel);
372
+ color: var(--text);
373
+ }
374
+ button {
375
+ border: 0;
376
+ border-radius: 8px;
377
+ padding: 10px 12px;
378
+ background: var(--text);
379
+ color: var(--panel);
380
+ cursor: pointer;
381
+ font-weight: 650;
382
+ }
383
+ button:disabled {
384
+ opacity: 0.55;
385
+ cursor: wait;
386
+ }
387
+ .estimate-result {
388
+ min-height: 48px;
389
+ border: 1px dashed var(--border);
390
+ border-radius: 8px;
391
+ padding: 10px;
392
+ color: var(--muted);
393
+ background: color-mix(in srgb, var(--panel-2), transparent 40%);
394
+ }
395
+ .warning {
396
+ color: var(--amber);
397
+ margin-top: 8px;
398
+ }
399
+ .error { color: var(--danger); }
400
+
401
+ @media (max-width: 720px) {
402
+ .shell { padding: 12px; }
403
+ .topbar { align-items: flex-start; }
404
+ .grid { grid-template-columns: 1fr; }
405
+ .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
406
+ .bar-row { grid-template-columns: 78px minmax(0, 1fr) 42px; }
407
+ .status { white-space: normal; text-align: right; }
408
+ }
409
+ </style>
410
+ </head>
411
+ <body>
412
+ <main class="shell" id="nourish-app-root">
413
+ <div class="topbar">
414
+ <div class="brand">
415
+ <div class="mark" aria-hidden="true">N</div>
416
+ <div>
417
+ <h1>Nourish</h1>
418
+ <div class="date" id="date">Waiting for ChatGPT...</div>
419
+ </div>
420
+ </div>
421
+ <div class="status" id="status">Preview only</div>
422
+ </div>
423
+ <div class="grid">
424
+ <section class="panel">
425
+ <h2>Today</h2>
426
+ <div class="metrics" id="metrics"></div>
427
+ <div class="bars" id="bars"></div>
428
+ </section>
429
+ <section class="panel next">
430
+ <h2>Next meal</h2>
431
+ <p class="meal" id="next-meal">No suggestion yet</p>
432
+ <p class="reason" id="next-reason"></p>
433
+ <div class="actions" id="actions"></div>
434
+ </section>
435
+ <section class="panel">
436
+ <h2>Estimate</h2>
437
+ <form class="estimate" id="estimate-form">
438
+ <div class="estimate-row">
439
+ <input id="meal-input" autocomplete="off" placeholder="2 eggs, banana, black coffee">
440
+ <button id="estimate-button" type="submit">Estimate</button>
441
+ </div>
442
+ <div class="estimate-result" id="estimate-result">Meal estimates stay in preview until you ask to log them.</div>
443
+ </form>
444
+ </section>
445
+ <section class="panel">
446
+ <h2>Profile</h2>
447
+ <div id="profile-summary" class="small">No profile loaded yet</div>
448
+ <div id="warnings"></div>
449
+ </section>
450
+ </div>
451
+ </main>
452
+ <script>
453
+ const state = {
454
+ dashboard: null,
455
+ pending: new Map(),
456
+ nextId: 1,
457
+ connected: false,
458
+ host: null
459
+ };
460
+
461
+ function number(value, suffix = "") {
462
+ const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
463
+ return String(Math.round(safe)) + suffix;
464
+ }
465
+
466
+ function percent(value) {
467
+ const safe = Number.isFinite(Number(value)) ? Math.max(0, Math.min(100, Number(value))) : 0;
468
+ return String(Math.round(safe));
469
+ }
470
+
471
+ function setStatus(text) {
472
+ document.getElementById("status").textContent = text;
473
+ }
474
+
475
+ function render(data) {
476
+ state.dashboard = data;
477
+ document.getElementById("date").textContent = data.date || "Today";
478
+ setStatus(data.privacy?.writes_disabled_in_widget ? "Preview only" : "Connected");
479
+
480
+ const totals = data.summary?.total_nutrients || {};
481
+ const hydration = data.summary?.hydration || {};
482
+ document.getElementById("metrics").innerHTML = [
483
+ metric("Calories", number(totals.calories_kcal)),
484
+ metric("Protein", number(totals.protein_g, "g")),
485
+ metric("Fiber", number(totals.fiber_g, "g")),
486
+ metric("Water", number(hydration.total_ml, "ml"))
487
+ ].join("");
488
+
489
+ const progress = data.summary?.goal_progress || {};
490
+ document.getElementById("bars").innerHTML = [
491
+ bar("Calories", progress.calories_kcal?.percent),
492
+ bar("Protein", progress.protein_g?.percent),
493
+ bar("Hydration", hydration.progress_percent ?? progress.hydration_ml?.percent)
494
+ ].filter(Boolean).join("") || '<div class="small">Set goals to see progress bars.</div>';
495
+
496
+ const suggestion = data.coach?.suggested_next_meal || {};
497
+ document.getElementById("next-meal").textContent = suggestion.text || "No suggestion yet";
498
+ document.getElementById("next-reason").textContent = suggestion.reason || "";
499
+ document.getElementById("actions").innerHTML = (data.coach?.next_actions || [])
500
+ .slice(0, 3)
501
+ .map((item) => '<div class="action">' + escapeHtml(item) + '</div>')
502
+ .join("");
503
+
504
+ document.getElementById("profile-summary").textContent = data.profile?.summary || "Empty profile";
505
+ const warnings = [
506
+ ...(data.profile?.missing_critical || []).map((item) => "Missing profile: " + item),
507
+ ...(data.coach?.warnings || [])
508
+ ].slice(0, 4);
509
+ document.getElementById("warnings").innerHTML = warnings
510
+ .map((item) => '<div class="warning">' + escapeHtml(item) + '</div>')
511
+ .join("");
512
+ }
513
+
514
+ function metric(label, value) {
515
+ return '<div class="metric"><strong>' + escapeHtml(value) + '</strong><span>' + escapeHtml(label) + '</span></div>';
516
+ }
517
+
518
+ function bar(label, value) {
519
+ if (value === undefined || value === null) return "";
520
+ const width = percent(value);
521
+ return '<div class="bar-row"><span class="label">' + escapeHtml(label) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div><span class="small">' + width + '%</span></div>';
522
+ }
523
+
524
+ function sendMessage(message) {
525
+ window.parent.postMessage(message, "*");
526
+ }
527
+
528
+ function sendNotification(method, params = {}) {
529
+ sendMessage({
530
+ jsonrpc: "2.0",
531
+ method,
532
+ params
533
+ });
534
+ }
535
+
536
+ function sendRequest(method, params = {}, timeoutMs = 20000) {
537
+ const id = state.nextId++;
538
+ const message = {
539
+ jsonrpc: "2.0",
540
+ id,
541
+ method,
542
+ params
543
+ };
544
+ sendMessage(message);
545
+ return new Promise((resolve, reject) => {
546
+ state.pending.set(id, { resolve, reject });
547
+ setTimeout(() => {
548
+ if (!state.pending.has(id)) return;
549
+ state.pending.delete(id);
550
+ reject(new Error("Tool call timed out."));
551
+ }, timeoutMs);
552
+ });
553
+ }
554
+
555
+ function callTool(name, args) {
556
+ return sendRequest("tools/call", { name, arguments: args });
557
+ }
558
+
559
+ function handleMessage(event) {
560
+ if (event.source !== window.parent) return;
561
+ const message = event.data;
562
+ if (!message || message.jsonrpc !== "2.0") return;
563
+
564
+ if (message.id !== undefined && state.pending.has(message.id)) {
565
+ const pending = state.pending.get(message.id);
566
+ state.pending.delete(message.id);
567
+ if (message.error) pending.reject(new Error(message.error.message || "Tool call failed."));
568
+ else pending.resolve(message.result);
569
+ return;
570
+ }
571
+
572
+ if (message.method === "ui/notifications/tool-result") {
573
+ const data = message.params?.structuredContent || parseToolText(message.params);
574
+ if (data?.ok) render(data);
575
+ }
576
+ }
577
+
578
+ async function connectApp() {
579
+ setStatus("Connecting");
580
+ try {
581
+ state.host = await sendRequest("ui/initialize", {
582
+ appInfo: { name: "Nourish", version: "1.0.0" },
583
+ appCapabilities: {
584
+ tools: { listChanged: true },
585
+ availableDisplayModes: ["inline", "fullscreen"]
586
+ },
587
+ protocolVersion: "2026-01-26"
588
+ }, 5000);
589
+ state.connected = true;
590
+ sendNotification("ui/notifications/initialized");
591
+ sendSizeChanged();
592
+ setStatus("Preview only");
593
+ } catch {
594
+ setStatus("Waiting for host");
595
+ }
596
+ }
597
+
598
+ function sendSizeChanged() {
599
+ const width = Math.ceil(document.documentElement.getBoundingClientRect().width);
600
+ const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
601
+ sendNotification("ui/notifications/size-changed", { width, height });
602
+ }
603
+
604
+ async function submitEstimate(event) {
605
+ event.preventDefault();
606
+ const input = document.getElementById("meal-input");
607
+ const button = document.getElementById("estimate-button");
608
+ const result = document.getElementById("estimate-result");
609
+ const text = input.value.trim();
610
+ if (!text) return;
611
+ button.disabled = true;
612
+ result.textContent = "Estimating...";
613
+ try {
614
+ const response = await callTool("nourish_estimate_meal", {
615
+ text,
616
+ response_format: "json",
617
+ locale: state.dashboard?.locale || "en"
618
+ });
619
+ const estimate = response?.structuredContent || parseToolText(response);
620
+ const totals = estimate?.total_nutrients || {};
621
+ result.innerHTML = [
622
+ '<strong>' + escapeHtml(number(totals.calories_kcal)) + ' kcal</strong>',
623
+ '<span class="small"> protein ' + escapeHtml(number(totals.protein_g, "g")) + ' - confidence ' + escapeHtml(String(estimate?.confidence ?? "unknown")) + '</span>',
624
+ estimate?.unresolved?.length ? '<div class="warning">Unresolved: ' + escapeHtml(estimate.unresolved.join(", ")) + '</div>' : ''
625
+ ].join("<br>");
626
+ } catch (error) {
627
+ result.innerHTML = '<span class="error">' + escapeHtml(error.message || "Estimate failed.") + '</span>';
628
+ } finally {
629
+ button.disabled = false;
630
+ }
631
+ }
632
+
633
+ function parseToolText(response) {
634
+ const text = (response?.content || [])
635
+ .filter((entry) => entry.type === "text")
636
+ .map((entry) => entry.text)
637
+ .join("\\n");
638
+ try { return JSON.parse(text); } catch { return null; }
639
+ }
640
+
641
+ function escapeHtml(value) {
642
+ return String(value)
643
+ .replace(/&/g, "&amp;")
644
+ .replace(/</g, "&lt;")
645
+ .replace(/>/g, "&gt;")
646
+ .replace(/"/g, "&quot;");
647
+ }
648
+
649
+ window.addEventListener("message", handleMessage, { passive: true });
650
+ document.getElementById("estimate-form").addEventListener("submit", submitEstimate);
651
+ window.addEventListener("resize", sendSizeChanged, { passive: true });
652
+ connectApp();
653
+ </script>
654
+ </body>
655
+ </html>`;
656
+ }
657
+ //# sourceMappingURL=nourish-chatgpt-app.js.map