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

49 KiB
Raw Permalink Blame History

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:

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:

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

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):
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:

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:

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

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):
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:

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:

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

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
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:

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:

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

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:

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:

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:

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:

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

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)
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:

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):

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:

.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

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.