Files
helios/docs/superpowers/plans/2026-08-18-m3-chat-brief.md
marcuspaico eab89ffcd3
All checks were successful
CI / check (push) Successful in 1m6s
docs: M3 chat + daily brief implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:44:04 -07:00

1060 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Helios M3 — Chat + Daily Brief Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Grounded AI chat over the user's lab data via typed bounded tools, plus a daily brief generated at a user-chosen hour — the Illume-feel core, running on the user's own LLM endpoint.
**Architecture:** New `chat_threads`/`chat_messages`/`briefs` tables. `chatWithTools` joins `chatJSON` in `lib/llm.ts`: an OpenAI-compatible function-calling loop with injectable fetch and a round cap. `lib/health-tools.ts` defines the tool schemas and a bounded executor over SQLite (only relevant slices ever reach the LLM — spec mandate). Chat routes persist full conversations including a tool trace. Brief generation is a pure-logic scheduler check (`shouldGenerateBrief`) driven by a 5-minute interval in `index.ts`, plus a manual generate endpoint.
**Tech Stack:** Existing only (Bun, Hono, drizzle, Zod, React). No new deps.
**Spec:** docs/superpowers/specs/2026-08-18-helios-design.md (AI layer; chat_threads/chat_messages/briefs in the data model). Sleep/metrics/meals tools arrive with their milestones — M3 tools cover labs, the only data that exists.
## Global Constraints
- TS strict; `bun run typecheck`, `bun test server`, `bun run build:web` green at every commit. Conventional Commits.
- LLM calls only via `lib/llm.ts` to the user-configured endpoint; tools return bounded slices (hard row caps in this plan), never a full DB dump.
- Repo transaction convention: `db.transaction((tx) => ...)` callbacks are SYNCHRONOUS with `.run()` — no awaits inside (bun-sqlite commits at first await).
- Fetch mocks in tests are cast `as unknown as typeof fetch`; `.rejects` assertions are always awaited.
- Chat/brief UI carries the honest privacy wording: chat context goes to the configured LLM endpoint; only a local endpoint (Ollama) means zero third parties.
- Not-medical-advice framing in the chat system prompt and brief prompt.
---
### Task 1: Schema migration — chat + briefs
**Files:**
- Modify: `server/src/db/schema.ts`
- Create: `server/drizzle/0002_*` (generated)
- Test: `server/test/db-chat.test.ts`
**Interfaces:**
- Produces tables:
- `chatThreads`: `id` text PK, `title` text notNull, `createdAt` integer notNull.
- `chatMessages`: `id` integer PK autoincrement, `threadId` text notNull, `role` text notNull ('user' | 'assistant'), `content` text notNull, `toolTrace` text (JSON, nullable), `createdAt` integer notNull.
- `briefs`: `id` integer PK autoincrement, `date` text notNull (YYYY-MM-DD, local), `content` text notNull, `createdAt` integer notNull.
- [ ] **Step 1: Write the failing test**
`server/test/db-chat.test.ts`:
```ts
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { briefs, chatMessages, chatThreads } from "../src/db/schema";
describe("chat schema", () => {
test("thread + message + brief round-trip", async () => {
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
await db.insert(chatThreads).values({ id: "t1", title: "LDL question", createdAt: 1 });
await db.insert(chatMessages).values({ threadId: "t1", role: "user", content: "how is my LDL?", toolTrace: null, createdAt: 2 });
await db.insert(briefs).values({ date: "2026-08-18", content: "All good.", createdAt: 3 });
expect((await db.select().from(chatMessages))[0].content).toBe("how is my LDL?");
expect((await db.select().from(briefs))[0].date).toBe("2026-08-18");
});
});
```
- [ ] **Step 2: Run to verify it fails** — `bun test server/test/db-chat.test.ts` → FAIL.
- [ ] **Step 3: Implement**
Append to `server/src/db/schema.ts`:
```ts
export const chatThreads = sqliteTable("chat_threads", {
id: text("id").primaryKey(),
title: text("title").notNull(),
createdAt: integer("created_at").notNull(),
});
export const chatMessages = sqliteTable("chat_messages", {
id: integer("id").primaryKey({ autoIncrement: true }),
threadId: text("thread_id").notNull(),
role: text("role").notNull(), // user | assistant
content: text("content").notNull(),
toolTrace: text("tool_trace"),
createdAt: integer("created_at").notNull(),
});
export const briefs = sqliteTable("briefs", {
id: integer("id").primaryKey({ autoIncrement: true }),
date: text("date").notNull(), // YYYY-MM-DD local
content: text("content").notNull(),
createdAt: integer("created_at").notNull(),
});
```
Run `cd server && bunx drizzle-kit generate --name chat_briefs`, commit generated files.
- [ ] **Step 4: Run to verify it passes** — PASS.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(db): chat_threads, chat_messages, briefs tables"
```
---
### Task 2: `chatWithTools` — OpenAI function-calling loop
**Files:**
- Modify: `server/src/lib/llm.ts`
- Test: `server/test/llm-tools.test.ts`
**Interfaces:**
- Produces (consumed by Tasks 4–5):
```ts
export interface ToolDef { name: string; description: string; parameters: Record<string, unknown> }
export interface ToolTraceEntry { name: string; args: unknown; result: string }
export async function chatWithTools(deps: LlmDeps, opts: {
system: string;
messages: { role: "user" | "assistant"; content: string }[];
tools: ToolDef[];
executeTool: (name: string, args: unknown) => Promise<string>;
maxRounds?: number; // default 6
}): Promise<{ content: string; toolTrace: ToolTraceEntry[] }>
```
Behavior: POST `${base}/chat/completions` with `tools: [{type:"function", function: def}]` and NO `response_format`. While the reply contains `tool_calls`: append the assistant message verbatim, execute each call (`JSON.parse` its `function.arguments`, tolerating `{}` for empty), append one `{role:"tool", tool_call_id, content: result}` per call, record the trace, and loop. A reply without tool_calls returns its content. `maxRounds` exceeded → throw `llm_error: tool loop exceeded`. Executor throws → the error message string becomes the tool result (`"tool_error: <msg>"`), loop continues (the model sees the failure). Non-2xx / empty → `llm_error` as in `chatJSON`.
- [ ] **Step 1: Write the failing test**
`server/test/llm-tools.test.ts`:
```ts
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
import { chatWithTools } from "../src/lib/llm";
function deps(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
return { db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl };
}
const TOOLS = [{ name: "get_data", description: "get data", parameters: { type: "object", properties: {} } }];
function reply(payload: unknown) {
return new Response(JSON.stringify({ choices: [{ message: payload }] }), { status: 200 });
}
describe("chatWithTools", () => {
test("executes tool calls then returns final content with trace", async () => {
const bodies: any[] = [];
let call = 0;
const mock = (async (_: any, init: any) => {
bodies.push(JSON.parse(String(init.body)));
call++;
if (call === 1) {
return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_data", arguments: '{"marker":"ldl"}' } }] });
}
return reply({ role: "assistant", content: "Your LDL is fine." });
}) as unknown as typeof fetch;
const out = await chatWithTools(deps(mock), {
system: "sys",
messages: [{ role: "user", content: "how is my LDL?" }],
tools: TOOLS,
executeTool: async (name, args) => `data for ${name}: ${JSON.stringify(args)}`,
});
expect(out.content).toBe("Your LDL is fine.");
expect(out.toolTrace).toEqual([{ name: "get_data", args: { marker: "ldl" }, result: 'data for get_data: {"marker":"ldl"}' }]);
// second request carries the assistant tool_calls message and the tool result
const msgs = bodies[1].messages;
expect(msgs.at(-1).role).toBe("tool");
expect(msgs.at(-1).tool_call_id).toBe("c1");
expect(bodies[0].tools[0].function.name).toBe("get_data");
expect(bodies[0].response_format).toBeUndefined();
});
test("executor errors are surfaced to the model, not thrown", async () => {
let call = 0;
const mock = (async () => {
call++;
if (call === 1) return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_data", arguments: "{}" } }] });
return reply({ role: "assistant", content: "Sorry, data unavailable." });
}) as unknown as typeof fetch;
const out = await chatWithTools(deps(mock), {
system: "s", messages: [{ role: "user", content: "u" }], tools: TOOLS,
executeTool: async () => { throw new Error("db exploded"); },
});
expect(out.content).toBe("Sorry, data unavailable.");
expect(out.toolTrace[0].result).toContain("tool_error: db exploded");
});
test("round cap throws llm_error", async () => {
const mock = (async () =>
reply({ role: "assistant", content: null, tool_calls: [{ id: "c", type: "function", function: { name: "get_data", arguments: "{}" } }] })
) as unknown as typeof fetch;
await expect(
chatWithTools(deps(mock), { system: "s", messages: [{ role: "user", content: "u" }], tools: TOOLS, executeTool: async () => "x", maxRounds: 3 }),
).rejects.toThrow(/tool loop exceeded/);
});
});
```
- [ ] **Step 2: Run to verify it fails** — FAIL.
- [ ] **Step 3: Implement**
Append to `server/src/lib/llm.ts`:
```ts
export interface ToolDef { name: string; description: string; parameters: Record<string, unknown> }
export interface ToolTraceEntry { name: string; args: unknown; result: string }
interface ToolCall { id: string; type: "function"; function: { name: string; arguments: string } }
interface ChatChoiceMessage { role: string; content: string | null; tool_calls?: ToolCall[] }
export async function chatWithTools(deps: LlmDeps, opts: {
system: string;
messages: { role: "user" | "assistant"; content: string }[];
tools: ToolDef[];
executeTool: (name: string, args: unknown) => Promise<string>;
maxRounds?: number;
}): Promise<{ content: string; toolTrace: ToolTraceEntry[] }> {
const f = deps.fetchImpl ?? fetch;
const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url;
const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model;
const apiKey = await getSetting(deps.db, "llm_key", deps.key);
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const messages: unknown[] = [{ role: "system", content: opts.system }, ...opts.messages];
const toolTrace: ToolTraceEntry[] = [];
const maxRounds = opts.maxRounds ?? 6;
for (let round = 0; round < maxRounds; round++) {
const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages,
tools: opts.tools.map((t) => ({ type: "function", function: t })),
}),
});
if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`);
const data = (await res.json()) as { choices?: { message?: ChatChoiceMessage }[] };
const msg = data.choices?.[0]?.message;
if (!msg) throw new Error("llm_error: empty completion");
if (!msg.tool_calls?.length) {
return { content: msg.content ?? "", toolTrace };
}
messages.push(msg);
for (const call of msg.tool_calls) {
let args: unknown = {};
try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } catch { args = {}; }
let result: string;
try {
result = await opts.executeTool(call.function.name, args);
} catch (e) {
result = `tool_error: ${e instanceof Error ? e.message : "failed"}`;
}
toolTrace.push({ name: call.function.name, args, result });
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
}
throw new Error("llm_error: tool loop exceeded");
}
```
- [ ] **Step 4: Run tests** — `bun test server/test/llm-tools.test.ts` then full suite → PASS.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(llm): chatWithTools function-calling loop with trace and round cap"
```
---
### Task 3: Health tools — bounded lab data access
**Files:**
- Create: `server/src/lib/health-tools.ts`
- Test: `server/test/health-tools.test.ts`
**Interfaces:**
- Produces (consumed by Tasks 4–5):
```ts
export const HEALTH_TOOLS: ToolDef[]; // 5 tools below
export function makeToolExecutor(db: Db): (name: string, args: unknown) => Promise<string>;
```
Tools (all results are compact JSON strings; every list hard-capped):
- `list_lab_draws` (no args): up to 24 draws `{id, collectedAt, labName, markerCount, flaggedCount}` newest first.
- `get_lab_draw` (`{drawId: string}`): all markers of one draw `{panel, name, value, unit, referenceRange, flagged, valueCanonical, canonicalUnit}` — capped at 120 rows.
- `get_marker_history` (`{markerKey: string}`): registry display/canonicalUnit + up to 50 `{collectedAt, value}` points ascending; unknown key → `{"error":"unknown marker key"}` (a string result, not a throw).
- `list_flagged_biomarkers` (no args): flagged rows across the latest 5 draws, capped 60, `{collectedAt, panel, name, value, unit, referenceRange}`.
- `list_known_marker_keys` (no args): all registry `{key, display, panel}` (bounded by registry size).
Unknown tool name → throw `Error("unknown tool: <name>")` (chatWithTools turns it into tool_error).
- [ ] **Step 1: Write the failing test**
`server/test/health-tools.test.ts`:
```ts
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { biomarkers, labDraws } from "../src/db/schema";
import { HEALTH_TOOLS, makeToolExecutor } from "../src/lib/health-tools";
async function seeded() {
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
await db.insert(labDraws).values([
{ id: "d1", collectedAt: "2025-06-01", labName: "A", draftId: null, createdAt: 1 },
{ id: "d2", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 2 },
]);
await db.insert(biomarkers).values([
{ drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "90", valueNum: 90, unit: "mg/dL", referenceRange: null, flagged: 0, valueCanonical: 4.996, canonicalUnit: "mmol/L" },
{ drawId: "d2", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" },
]);
return makeToolExecutor(db);
}
describe("health tools", () => {
test("tool defs are well-formed", () => {
expect(HEALTH_TOOLS.map((t) => t.name).sort()).toEqual([
"get_lab_draw", "get_marker_history", "list_flagged_biomarkers", "list_known_marker_keys", "list_lab_draws",
]);
for (const t of HEALTH_TOOLS) expect(t.parameters).toHaveProperty("type", "object");
});
test("list_lab_draws newest first with counts", async () => {
const exec = await seeded();
const out = JSON.parse(await exec("list_lab_draws", {}));
expect(out[0].id).toBe("d2");
expect(out[0].flaggedCount).toBe(1);
});
test("get_lab_draw and marker history", async () => {
const exec = await seeded();
const draw = JSON.parse(await exec("get_lab_draw", { drawId: "d2" }));
expect(draw.markers[0].name).toBe("Glucose");
const hist = JSON.parse(await exec("get_marker_history", { markerKey: "glucose" }));
expect(hist.display).toBe("Glucose");
expect(hist.points.map((p: any) => p.value)).toEqual([4.996, 5.551]);
const unknown = JSON.parse(await exec("get_marker_history", { markerKey: "nope" }));
expect(unknown.error).toContain("unknown");
});
test("flagged list and registry keys; unknown tool throws", async () => {
const exec = await seeded();
const flagged = JSON.parse(await exec("list_flagged_biomarkers", {}));
expect(flagged).toHaveLength(1);
expect(flagged[0].name).toBe("Glucose");
const keys = JSON.parse(await exec("list_known_marker_keys", {}));
expect(keys.find((k: any) => k.key === "glucose")).toBeTruthy();
await expect(exec("bogus_tool", {})).rejects.toThrow(/unknown tool/);
});
});
```
- [ ] **Step 2: Run to verify it fails** — FAIL.
- [ ] **Step 3: Implement**
`server/src/lib/health-tools.ts`:
```ts
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import type { Db } from "../db";
import { biomarkers, labDraws } from "../db/schema";
import { ANALYTES, getAnalyte } from "./analytes";
import type { ToolDef } from "./llm";
// Bounded slices only: these caps are the spec's "never a full dump" guarantee.
const MAX_DRAWS = 24;
const MAX_MARKERS = 120;
const MAX_POINTS = 50;
const MAX_FLAGGED = 60;
const FLAGGED_DRAWS = 5;
export const HEALTH_TOOLS: ToolDef[] = [
{ name: "list_lab_draws", description: "List the user's blood-test draws, newest first (id, date, lab, marker/flag counts).", parameters: { type: "object", properties: {} } },
{ name: "get_lab_draw", description: "Get every marker of one draw by draw id.", parameters: { type: "object", properties: { drawId: { type: "string" } }, required: ["drawId"] } },
{ name: "get_marker_history", description: "History of one marker across all draws in its canonical unit. Use list_known_marker_keys for valid keys.", parameters: { type: "object", properties: { markerKey: { type: "string" } }, required: ["markerKey"] } },
{ name: "list_flagged_biomarkers", description: "Out-of-range results from the most recent draws.", parameters: { type: "object", properties: {} } },
{ name: "list_known_marker_keys", description: "All marker keys the system can chart, with display names and panels.", parameters: { type: "object", properties: {} } },
];
const DrawIdArgs = z.object({ drawId: z.string() });
const MarkerArgs = z.object({ markerKey: z.string() });
export function makeToolExecutor(db: Db) {
return async (name: string, args: unknown): Promise<string> => {
switch (name) {
case "list_lab_draws": {
const draws = await db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)).limit(MAX_DRAWS);
const rows = await db.select().from(biomarkers);
return JSON.stringify(draws.map((d) => ({
id: d.id, collectedAt: d.collectedAt, labName: d.labName,
markerCount: rows.filter((r) => r.drawId === d.id).length,
flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length,
})));
}
case "get_lab_draw": {
const { drawId } = DrawIdArgs.parse(args);
const d = (await db.select().from(labDraws).where(eq(labDraws.id, drawId))).at(0);
if (!d) return JSON.stringify({ error: "unknown draw id" });
const rows = (await db.select().from(biomarkers).where(eq(biomarkers.drawId, drawId))).slice(0, MAX_MARKERS);
return JSON.stringify({
collectedAt: d.collectedAt, labName: d.labName,
markers: rows.map((r) => ({
panel: r.panel, name: r.name, value: r.value, unit: r.unit,
referenceRange: r.referenceRange, flagged: r.flagged === 1,
valueCanonical: r.valueCanonical, canonicalUnit: r.canonicalUnit,
})),
});
}
case "get_marker_history": {
const { markerKey } = MarkerArgs.parse(args);
const analyte = getAnalyte(markerKey);
if (!analyte) return JSON.stringify({ error: "unknown marker key" });
const draws = await db.select().from(labDraws);
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
const rows = await db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key));
const points = rows
.filter((r) => r.valueCanonical !== null)
.map((r) => ({ collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! }))
.sort((a, b) => a.collectedAt.localeCompare(b.collectedAt))
.slice(-MAX_POINTS);
return JSON.stringify({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points });
}
case "list_flagged_biomarkers": {
const draws = await db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)).limit(FLAGGED_DRAWS);
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
const ids = new Set(draws.map((d) => d.id));
const rows = await db.select().from(biomarkers).where(eq(biomarkers.flagged, 1));
return JSON.stringify(rows
.filter((r) => ids.has(r.drawId))
.slice(0, MAX_FLAGGED)
.map((r) => ({ collectedAt: dates.get(r.drawId), panel: r.panel, name: r.name, value: r.value, unit: r.unit, referenceRange: r.referenceRange })));
}
case "list_known_marker_keys":
return JSON.stringify(ANALYTES.map((a) => ({ key: a.key, display: a.display, panel: a.panel })));
default:
throw new Error(`unknown tool: ${name}`);
}
};
}
```
- [ ] **Step 4: Run tests** — PASS. (`zod` is already a server dependency.)
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(chat): bounded lab-data tools for the LLM"
```
---
### Task 4: Chat routes
**Files:**
- Create: `server/src/routes/chat.ts`
- Modify: `server/src/app.ts` (mount), `shared/src/types.ts`
- Test: `server/test/chat.test.ts`
**Interfaces:**
- Produces (consumed by web):
- `GET /api/chat/threads` → `{ threads: { id, title, createdAt }[] }` newest first.
- `POST /api/chat/threads` (empty body) → 201 `{ id }` (title "New chat").
- `GET /api/chat/threads/:id` → `{ id, title, messages: { role, content, createdAt }[] }` ascending; 404 unknown.
- `POST /api/chat/threads/:id/messages` body `ChatMessageBody = { content: string (1..4000) }` → runs `chatWithTools` with `HEALTH_TOOLS`; persists the user message, then the assistant message with its toolTrace JSON; first user message retitles the thread to the first 60 chars; returns `{ reply: string }`. LLM failure → 502 `{ error }` with the user message still persisted.
- shared Zod: `ChatMessageBody`.
- System prompt (exact, in `chat.ts`):
```
You are Helios, a private health-data assistant running on the user's own server. Answer questions about the user's blood-test results using the provided tools — always fetch data before making claims about it, and cite dates and values from tool results. You are not a doctor and this is not medical advice; for concerning results, suggest discussing with a clinician. Be concise and concrete.
```
- [ ] **Step 1: Add shared schema**
```ts
export const ChatMessageBody = z.object({ content: z.string().min(1).max(4000) });
export type ChatMessageBody = z.infer<typeof ChatMessageBody>;
```
- [ ] **Step 2: Write the failing test**
`server/test/chat.test.ts`:
```ts
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
function reply(payload: unknown) {
return new Response(JSON.stringify({ choices: [{ message: payload }] }), { status: 200 });
}
async function authed(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
return { app, cookie };
}
describe("chat", () => {
test("thread lifecycle: create, message with tool round, retitle, history", async () => {
let call = 0;
const mock = (async () => {
call++;
if (call === 1) return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "list_lab_draws", arguments: "{}" } }] });
return reply({ role: "assistant", content: "You have no draws yet." });
}) as unknown as typeof fetch;
const { app, cookie } = await authed(mock);
const h = { cookie, "content-type": "application/json" };
const created = await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" });
expect(created.status).toBe(201);
const { id } = await created.json();
const sent = await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "do I have any lab results?" }) });
expect(sent.status).toBe(200);
expect((await sent.json()).reply).toBe("You have no draws yet.");
const thread = await (await app.request(`/api/chat/threads/${id}`, { headers: { cookie } })).json();
expect(thread.title).toBe("do I have any lab results?");
expect(thread.messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
const list = await (await app.request("/api/chat/threads", { headers: { cookie } })).json();
expect(list.threads[0].id).toBe(id);
});
test("LLM failure → 502, user message persisted", async () => {
const mock = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
const { app, cookie } = await authed(mock);
const h = { cookie, "content-type": "application/json" };
const { id } = await (await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" })).json();
const sent = await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "hi" }) });
expect(sent.status).toBe(502);
const thread = await (await app.request(`/api/chat/threads/${id}`, { headers: { cookie } })).json();
expect(thread.messages).toHaveLength(1);
expect(thread.messages[0].role).toBe("user");
});
test("404 unknown thread; 400 bad body", async () => {
const { app, cookie } = await authed((async () => reply({ role: "assistant", content: "x" })) as unknown as typeof fetch);
const h = { cookie, "content-type": "application/json" };
expect((await app.request("/api/chat/threads/nope", { headers: { cookie } })).status).toBe(404);
const { id } = await (await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" })).json();
expect((await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "" }) })).status).toBe(400);
});
});
```
- [ ] **Step 3: Run to verify it fails, then implement**
`server/src/routes/chat.ts`:
```ts
import { asc, desc, eq } from "drizzle-orm";
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { ChatMessageBody } from "@helios/shared";
import type { Db } from "../db";
import { chatMessages, chatThreads } from "../db/schema";
import { HEALTH_TOOLS, makeToolExecutor } from "../lib/health-tools";
import { chatWithTools } from "../lib/llm";
const SYSTEM = `You are Helios, a private health-data assistant running on the user's own server. Answer questions about the user's blood-test results using the provided tools — always fetch data before making claims about it, and cite dates and values from tool results. You are not a doctor and this is not medical advice; for concerning results, suggest discussing with a clinician. Be concise and concrete.`;
const MAX_CONTEXT_MESSAGES = 30;
export type ChatDeps = { db: Db; key: Buffer; llmFetch?: typeof fetch };
export function chatRoutes(deps: ChatDeps) {
const app = new Hono();
app.get("/chat/threads", async (c) => {
const threads = await deps.db.select().from(chatThreads).orderBy(desc(chatThreads.createdAt));
return c.json({ threads });
});
app.post("/chat/threads", async (c) => {
const id = randomUUID();
await deps.db.insert(chatThreads).values({ id, title: "New chat", createdAt: Date.now() });
return c.json({ id }, 201);
});
app.get("/chat/threads/:id", async (c) => {
const t = (await deps.db.select().from(chatThreads).where(eq(chatThreads.id, c.req.param("id")))).at(0);
if (!t) return c.json({ error: "not found" }, 404);
const messages = await deps.db.select().from(chatMessages)
.where(eq(chatMessages.threadId, t.id)).orderBy(asc(chatMessages.createdAt));
return c.json({ id: t.id, title: t.title, messages: messages.map((m) => ({ role: m.role, content: m.content, createdAt: m.createdAt })) });
});
app.post("/chat/threads/:id/messages", async (c) => {
const t = (await deps.db.select().from(chatThreads).where(eq(chatThreads.id, c.req.param("id")))).at(0);
if (!t) return c.json({ error: "not found" }, 404);
const body = ChatMessageBody.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: "content must be 1-4000 chars" }, 400);
const history = await deps.db.select().from(chatMessages)
.where(eq(chatMessages.threadId, t.id)).orderBy(asc(chatMessages.createdAt));
await deps.db.insert(chatMessages).values({ threadId: t.id, role: "user", content: body.data.content, toolTrace: null, createdAt: Date.now() });
if (history.length === 0) {
await deps.db.update(chatThreads).set({ title: body.data.content.slice(0, 60) }).where(eq(chatThreads.id, t.id));
}
try {
const out = await chatWithTools(
{ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch },
{
system: SYSTEM,
messages: [
...history.slice(-MAX_CONTEXT_MESSAGES).map((m) => ({ role: m.role as "user" | "assistant", content: m.content })),
{ role: "user", content: body.data.content },
],
tools: HEALTH_TOOLS,
executeTool: makeToolExecutor(deps.db),
},
);
await deps.db.insert(chatMessages).values({
threadId: t.id, role: "assistant", content: out.content,
toolTrace: out.toolTrace.length ? JSON.stringify(out.toolTrace) : null, createdAt: Date.now(),
});
return c.json({ reply: out.content });
} catch (e) {
return c.json({ error: e instanceof Error ? e.message : "chat failed" }, 502);
}
});
return app;
}
```
In `server/src/app.ts`: `import { chatRoutes } from "./routes/chat";` and mount `app.route("/api", chatRoutes(deps));` beside the other mounts.
- [ ] **Step 4: Run tests** — full suite PASS.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat(chat): threads, grounded messages with tool trace persistence"
```
---
### Task 5: Daily brief — generator, schedule check, routes
**Files:**
- Create: `server/src/lib/brief.ts`, `server/src/routes/brief.ts`
- Modify: `server/src/app.ts` (mount), `server/src/index.ts` (interval), `server/src/routes/settings.ts` + `shared/src/types.ts` (briefHour setting)
- Test: `server/test/brief.test.ts`
**Interfaces:**
- Produces:
- `generateBrief(deps: LlmDeps & { db: Db }): Promise<string>` — assembles: draw list (via the Task 3 executor's `list_lab_draws`), flagged biomarkers, and marker history for each flagged analyte key (max 6 keys); prompts via `chatJSON` for `{ "brief": string }` (markdown); inserts into `briefs` with today's local date; returns the content. Prompt (exact):
```
You write a short daily health brief from the user's own lab data. Ground every statement in the data provided. Structure: 1-2 sentence status summary, then "Worth attention" bullets for out-of-range values with their dates and trends, then one practical, non-prescriptive suggestion. You are not a doctor; do not diagnose or prescribe. If there is no data, say so and suggest uploading lab results. Return JSON: {"brief": "<markdown>"}.
```
- `shouldGenerateBrief(now: Date, briefHour: number, lastBriefDate: string | null): boolean` — pure: true when `now.getHours() >= briefHour` and `lastBriefDate !== localDate(now)`. Exported `localDate(d: Date): string` (YYYY-MM-DD, local).
- Routes: `GET /api/brief/latest` → `{ brief: { date, content, createdAt } | null }`; `POST /api/brief/generate` → 201 `{ content }` or 502 on llm_error.
- Settings: `brief_hour` (default 7) joins the settings API — `SettingsResponse` gains `briefHour: number`, `SettingsUpdate` gains `briefHour: z.number().int().min(0).max(23).optional()`; stored as string in the settings table. Update the settings test's defaults expectation to include `briefHour: 7`.
- `index.ts`: `setInterval` every 5 min → if `llm_key` set and `shouldGenerateBrief(new Date(), briefHour, latestBriefDate)` → `generateBrief` (catch + `console.error` — scheduler must never crash the server).
- [ ] **Step 1: Write the failing test**
`server/test/brief.test.ts`:
```ts
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { briefs, labDraws, biomarkers } from "../src/db/schema";
import { loadOrCreateKey } from "../src/lib/crypto";
import { localDate, shouldGenerateBrief } from "../src/lib/brief";
describe("shouldGenerateBrief", () => {
const at = (h: number) => new Date(2026, 7, 18, h, 30); // local Aug 18 2026
test("before the hour → false; after → true; already generated today → false", () => {
expect(shouldGenerateBrief(at(6), 7, null)).toBe(false);
expect(shouldGenerateBrief(at(8), 7, null)).toBe(true);
expect(shouldGenerateBrief(at(8), 7, localDate(at(8)))).toBe(false);
expect(shouldGenerateBrief(at(8), 7, "2026-08-17")).toBe(true);
});
});
describe("brief routes", () => {
function reply(content: string) {
return new Response(JSON.stringify({ choices: [{ message: { content } }] }), { status: 200 });
}
async function authed(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
return { app, db, cookie };
}
test("generate stores and latest returns it; prompt carries flagged data", async () => {
let userPrompt = "";
const mock = (async (_: any, init: any) => {
userPrompt = JSON.parse(String(init.body)).messages[1].content;
return reply(JSON.stringify({ brief: "## Status\nGlucose slightly high." }));
}) as unknown as typeof fetch;
const { app, db, cookie } = await authed(mock);
await db.insert(labDraws).values({ id: "d1", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 1 });
await db.insert(biomarkers).values({ drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" });
const gen = await app.request("/api/brief/generate", { method: "POST", headers: { cookie } });
expect(gen.status).toBe(201);
expect(userPrompt).toContain("Glucose");
const latest = await (await app.request("/api/brief/latest", { headers: { cookie } })).json();
expect(latest.brief.content).toContain("Glucose slightly high");
expect((await db.select().from(briefs))).toHaveLength(1);
});
test("latest is null when none; llm failure → 502", async () => {
const bad = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
const { app, cookie } = await authed(bad);
expect((await (await app.request("/api/brief/latest", { headers: { cookie } })).json()).brief).toBeNull();
expect((await app.request("/api/brief/generate", { method: "POST", headers: { cookie } })).status).toBe(502);
});
});
```
- [ ] **Step 2: Run to verify it fails, then implement**
`server/src/lib/brief.ts`:
```ts
import { desc } from "drizzle-orm";
import type { Db } from "../db";
import { briefs } from "../db/schema";
import { makeToolExecutor } from "./health-tools";
import { chatJSON, type LlmDeps } from "./llm";
const PROMPT = `You write a short daily health brief from the user's own lab data. Ground every statement in the data provided. Structure: 1-2 sentence status summary, then "Worth attention" bullets for out-of-range values with their dates and trends, then one practical, non-prescriptive suggestion. You are not a doctor; do not diagnose or prescribe. If there is no data, say so and suggest uploading lab results. Return JSON: {"brief": "<markdown>"}.`;
export function localDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function shouldGenerateBrief(now: Date, briefHour: number, lastBriefDate: string | null): boolean {
return now.getHours() >= briefHour && lastBriefDate !== localDate(now);
}
export async function latestBrief(db: Db) {
return (await db.select().from(briefs).orderBy(desc(briefs.createdAt)).limit(1)).at(0) ?? null;
}
const MAX_HISTORY_KEYS = 6;
export async function generateBrief(deps: LlmDeps): Promise<string> {
const exec = makeToolExecutor(deps.db);
const draws = await exec("list_lab_draws", {});
const flagged = await exec("list_flagged_biomarkers", {});
// History for the flagged analytes so trends are grounded, capped.
const flaggedRows = JSON.parse(flagged) as { name: string }[];
const keysOut = JSON.parse(await exec("list_known_marker_keys", {})) as { key: string; display: string }[];
const byDisplay = new Map(keysOut.map((k) => [k.display.toLowerCase(), k.key]));
const historyKeys = [...new Set(flaggedRows.map((f) => byDisplay.get(f.name.toLowerCase())).filter((k): k is string => !!k))].slice(0, MAX_HISTORY_KEYS);
const histories: Record<string, unknown> = {};
for (const k of historyKeys) histories[k] = JSON.parse(await exec("get_marker_history", { markerKey: k }));
const user = JSON.stringify({ today: localDate(new Date()), draws: JSON.parse(draws), flagged: flaggedRows, histories });
const out = (await chatJSON(deps, { system: PROMPT, user })) as { brief?: unknown };
if (typeof out.brief !== "string" || !out.brief) throw new Error("llm_error: brief missing from completion");
await deps.db.insert(briefs).values({ date: localDate(new Date()), content: out.brief, createdAt: Date.now() });
return out.brief;
}
```
`server/src/routes/brief.ts`:
```ts
import { Hono } from "hono";
import type { Db } from "../db";
import { generateBrief, latestBrief } from "../lib/brief";
export function briefRoutes(deps: { db: Db; key: Buffer; llmFetch?: typeof fetch }) {
const app = new Hono();
app.get("/brief/latest", async (c) => {
const b = await latestBrief(deps.db);
return c.json({ brief: b ? { date: b.date, content: b.content, createdAt: b.createdAt } : null });
});
app.post("/brief/generate", async (c) => {
try {
const content = await generateBrief({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch });
return c.json({ content }, 201);
} catch (e) {
return c.json({ error: e instanceof Error ? e.message : "brief failed" }, 502);
}
});
return app;
}
```
Settings additions: in `shared/src/types.ts`, add `briefHour: z.number()` to `SettingsResponse` and `briefHour: z.number().int().min(0).max(23).optional()` to `SettingsUpdate`. In `server/src/routes/settings.ts`: DEFAULTS gains `brief_hour: "7"`; GET reads `brief_hour` and returns `briefHour: Number(...)`; PUT stores `String(briefHour)` when present. Update `server/test/settings.test.ts` defaults expectation to include `briefHour: 7`.
`server/src/app.ts`: mount `briefRoutes(deps)`.
`server/src/index.ts` — after `createApp`, add:
```ts
import { eq } from "drizzle-orm";
import { settings } from "./db/schema";
import { latestBrief, generateBrief, shouldGenerateBrief } from "./lib/brief";
// Daily brief scheduler: cheap check every 5 minutes; generateBrief runs at
// most once per local day. Never allowed to crash the server.
setInterval(async () => {
try {
const hasKey = (await db.select().from(settings).where(eq(settings.key, "llm_key"))).length > 0;
if (!hasKey) return;
const hourRow = (await db.select().from(settings).where(eq(settings.key, "brief_hour"))).at(0);
const hour = hourRow ? Number(hourRow.value) : 7;
const last = await latestBrief(db);
if (shouldGenerateBrief(new Date(), hour, last?.date ?? null)) {
await generateBrief({ db, key });
}
} catch (e) {
console.error("brief scheduler:", e instanceof Error ? e.message : e);
}
}, 5 * 60 * 1000);
```
(Requires `index.ts` to keep `db` and `key` in scope — refactor its two lines so `const db = openDb(dataDir); const key = loadOrCreateKey(dataDir);` are named before `createApp({ db, key, dataDir })`.)
- [ ] **Step 3: Run tests** — full suite PASS (settings test updated).
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat(brief): daily brief generator, scheduler check, routes, brief_hour setting"
```
---
### Task 6: Web UI — Chat page + Today brief card
**Files:**
- Create: `web/src/pages/Chat.tsx`
- Modify: `web/src/pages/Today.tsx`, `web/src/pages/Settings.tsx` (brief hour field), `web/src/App.tsx` (route + nav), `web/src/api.ts`, `web/src/styles.css`
**Interfaces:** consumes Tasks 4–5 endpoints.
- [ ] **Step 1: API client additions** (`web/src/api.ts`, inside `api`)
```ts
chatThreads: () => req<{ threads: { id: string; title: string; createdAt: number }[] }>("/api/chat/threads"),
createThread: () => req<{ id: string }>("/api/chat/threads", { method: "POST", body: "{}" }),
chatThread: (id: string) => req<{ id: string; title: string; messages: { role: string; content: string; createdAt: number }[] }>(`/api/chat/threads/${id}`),
sendMessage: (id: string, content: string) => req<{ reply: string }>(`/api/chat/threads/${id}/messages`, { method: "POST", body: JSON.stringify({ content }) }),
latestBrief: () => req<{ brief: { date: string; content: string; createdAt: number } | null }>("/api/brief/latest"),
generateBrief: () => req<{ content: string }>("/api/brief/generate", { method: "POST" }),
```
- [ ] **Step 2: Chat page**
`web/src/pages/Chat.tsx`:
```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
interface Msg { role: string; content: string }
export function Chat() {
const [threads, setThreads] = useState<{ id: string; title: string }[]>([]);
const [active, setActive] = useState<string | null>(null);
const [messages, setMessages] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottom = useRef<HTMLDivElement>(null);
const loadThreads = useCallback(() => {
api.chatThreads().then((r) => setThreads(r.threads)).catch(() => {});
}, []);
useEffect(loadThreads, [loadThreads]);
const open = async (id: string) => {
setActive(id);
setError(null);
const t = await api.chatThread(id).catch(() => null);
setMessages(t?.messages ?? []);
};
const newChat = async () => {
const { id } = await api.createThread();
loadThreads();
await open(id);
};
const send = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || busy) return;
let id = active;
if (!id) {
id = (await api.createThread()).id;
setActive(id);
}
const content = input;
setInput("");
setMessages((m) => [...m, { role: "user", content }]);
setBusy(true);
setError(null);
try {
const { reply } = await api.sendMessage(id, content);
setMessages((m) => [...m, { role: "assistant", content: reply }]);
loadThreads();
} catch (err) {
setError(err instanceof Error ? err.message : "send failed");
} finally {
setBusy(false);
bottom.current?.scrollIntoView({ behavior: "smooth" });
}
};
return (
<section className="chat">
<aside className="chat-threads">
<button onClick={newChat}>New chat</button>
<ul className="list">
{threads.map((t) => (
<li key={t.id}>
<button className={`link${t.id === active ? " active" : ""}`} onClick={() => open(t.id)}>{t.title}</button>
</li>
))}
</ul>
</aside>
<div className="chat-main">
<div className="chat-messages">
{messages.length === 0 && (
<p className="hint">
Ask about your lab results — "how is my LDL trending?", "what was flagged in my last draw?".
Your questions and relevant data slices go to the AI endpoint configured in Settings; use a local
endpoint like Ollama if you want zero third parties. Not medical advice.
</p>
)}
{messages.map((m, i) => (
<div key={i} className={`bubble ${m.role}`}>{m.content}</div>
))}
{busy && <div className="bubble assistant muted">Thinking…</div>}
<div ref={bottom} />
</div>
{error && <p className="error">{error}</p>}
<form onSubmit={send} className="chat-input">
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about your health data" disabled={busy} />
<button type="submit" disabled={busy || !input.trim()}>Send</button>
</form>
</div>
</section>
);
}
```
- [ ] **Step 3: Today brief card + Settings hour**
`web/src/pages/Today.tsx` (replace):
```tsx
import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
export function Today() {
const [brief, setBrief] = useState<{ date: string; content: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
api.latestBrief().then((r) => setBrief(r.brief)).catch(() => {});
}, []);
useEffect(refresh, [refresh]);
const generate = async () => {
setBusy(true);
setError(null);
try {
await api.generateBrief();
refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "generation failed");
} finally {
setBusy(false);
}
};
return (
<section>
<div className="row-between">
<h2>Today</h2>
<button onClick={generate} disabled={busy}>{busy ? "Generating…" : "Generate brief"}</button>
</div>
{error && <p className="error">{error}</p>}
{brief ? (
<article className="brief">
<p className="muted">{brief.date}</p>
<div className="brief-content">{brief.content}</div>
</article>
) : (
<p className="muted">No brief yet. Upload lab results, set your AI key in Settings, then generate one — or wait for the daily schedule.</p>
)}
</section>
);
}
```
`web/src/pages/Settings.tsx`: add a `briefHour` number state initialized from `s.briefHour`, an input
`<label>Daily brief hour (0-23) <input type="number" min={0} max={23} value={briefHour} onChange={...} /></label>`
inside the form, and include `briefHour` in the `putSettings` body.
- [ ] **Step 4: Routes, nav, styles**
`App.tsx`: nav gains `<Link to="/chat">Chat</Link>`; route `<Route path="/chat" element={<Chat />} />`.
`styles.css` append:
```css
.chat { display: flex; gap: 16px; min-height: 60vh; }
.chat-threads { width: 180px; flex-shrink: 0; }
.chat-main { flex: 1; display: flex; flex-direction: column; }
.chat-messages { flex: 1; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding-bottom: 12px; }
.bubble { padding: 10px 12px; border-radius: 12px; max-width: 85%; white-space: pre-wrap; }
.bubble.user { align-self: flex-end; background: color-mix(in srgb, currentColor 12%, transparent); }
.bubble.assistant { align-self: flex-start; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
.chat-input { display: flex; gap: 8px; }
.chat-input input { flex: 1; }
button.link.active { font-weight: 700; text-decoration: none; }
.brief-content { white-space: pre-wrap; line-height: 1.5; }
@media (max-width: 640px) { .chat { flex-direction: column; } .chat-threads { width: auto; } }
```
- [ ] **Step 5: Verify** — `bun run typecheck && bun run build:web && bun test server` all green.
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat(web): chat page, today brief card, brief hour setting"
```
---
## Self-Review (done at write time)
- **Spec coverage:** AI-layer chat with typed bounded tools ✔ (labs tools only — sleep/metrics/meals tools arrive with their data); daily brief at user hour, stored, on Today ✔; honest privacy wording in chat UI ✔; not-medical-advice framing in both prompts ✔.
- **Placeholders:** none.
- **Type consistency:** `LlmDeps` reused for tools loop; `ToolDef`/`ToolTraceEntry` exported from llm.ts and imported by health-tools; `createApp` deps unchanged (`llmFetch` already exists); brief scheduler uses named `db`/`key` from refactored index.ts; settings test update called out explicitly.
- **Known risk:** brief's flagged→analyteKey mapping goes through display-name lowercase matching (flagged tool output has no key) — acceptable M3 shortcut; noted for reviewers.