[object Object]

← back to AbramsOS

Add nav-bar chat: model+agent pickers, grounded RAG, PII-redacted external lanes

d68396144c432eefaff4a0c23c7c494962e91d69 · 2026-09-01 14:56:03 -0700 · Steve Abrams

- lib/chat-registry.js: single source of truth for models (Gemini 2.5 Flash/Pro,
  Ollama qwen3/hermes3, Claude CLI) + 7 agent personas with data-scopes
- lib/chat-redact.js: PII redaction boundary (email/phone/SSN/card/NDC/MRN/
  address/named-people) applied to every external-lane prompt
- lib/chat-grounding.js: read-only per-agent record grounding via lib/db.js
- lib/chat-providers/*: gemini (Generative Language API), ollama (local, graceful
  degrade), claude-cli (spawn with ANTHROPIC keys stripped, no shell injection)
- routes/chat.js: POST /api/chat + GET /api/chat/registry, one audit_log row per
  call, read/answer only (no state-changing or external actions)
- views/partials/chat.ejs + footer wiring: chat inherits on every page
- public/js/chat.js + app.css: pickers, spec cards, lane badge, session history
- tests/chat.test.js: redaction golden fixture + registry + route auth-gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d68396144c432eefaff4a0c23c7c494962e91d69
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 14:56:03 2026 -0700

    Add nav-bar chat: model+agent pickers, grounded RAG, PII-redacted external lanes
    
    - lib/chat-registry.js: single source of truth for models (Gemini 2.5 Flash/Pro,
      Ollama qwen3/hermes3, Claude CLI) + 7 agent personas with data-scopes
    - lib/chat-redact.js: PII redaction boundary (email/phone/SSN/card/NDC/MRN/
      address/named-people) applied to every external-lane prompt
    - lib/chat-grounding.js: read-only per-agent record grounding via lib/db.js
    - lib/chat-providers/*: gemini (Generative Language API), ollama (local, graceful
      degrade), claude-cli (spawn with ANTHROPIC keys stripped, no shell injection)
    - routes/chat.js: POST /api/chat + GET /api/chat/registry, one audit_log row per
      call, read/answer only (no state-changing or external actions)
    - views/partials/chat.ejs + footer wiring: chat inherits on every page
    - public/js/chat.js + app.css: pickers, spec cards, lane badge, session history
    - tests/chat.test.js: redaction golden fixture + registry + route auth-gate
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/chat-grounding.js            | 129 ++++++++++++++++++++++++++
 lib/chat-providers/claude-cli.js |  72 +++++++++++++++
 lib/chat-providers/gemini.js     |  49 ++++++++++
 lib/chat-providers/index.js      |  21 +++++
 lib/chat-providers/ollama.js     |  55 +++++++++++
 lib/chat-redact.js               | 106 +++++++++++++++++++++
 lib/chat-registry.js             | 174 +++++++++++++++++++++++++++++++++++
 public/css/app.css               |  86 +++++++++++++++++
 public/js/chat.js                | 193 +++++++++++++++++++++++++++++++++++++++
 routes/chat.js                   | 116 +++++++++++++++++++++++
 server.js                        |   2 +
 tests/chat.test.js               |  97 ++++++++++++++++++++
 12 files changed, 1100 insertions(+)

diff --git a/lib/chat-grounding.js b/lib/chat-grounding.js
new file mode 100644
index 0000000..77152b2
--- /dev/null
+++ b/lib/chat-grounding.js
@@ -0,0 +1,129 @@
+// Grounding layer: given an agent scope, pull the user's OWN records from the
+// relevant domain tables (via lib/db.js only) and format them into a compact
+// CONTEXT block. Also returns the real person-names present so the redaction
+// pass can strip them on external lanes. Read-only — never writes.
+
+const db = require('../lib/db');
+
+const LIMIT = 40; // keep context bounded
+
+function fmt(rows, cols) {
+  if (!rows.length) return '(none on record)';
+  return rows
+    .map((r) => cols.map((c) => `${c}: ${r[c] ?? ''}`).join(' | '))
+    .join('\n');
+}
+
+const SCOPES = {
+  async claims(userId) {
+    const r = await db.query(
+      `SELECT claim_type, routing, jurisdiction, state, desired_remedy,
+              due_at, draft_subject
+         FROM claim_case WHERE user_id = $1
+        ORDER BY due_at NULLS LAST, created_at DESC LIMIT $2`,
+      [userId, LIMIT]
+    );
+    return {
+      title: 'Open claim cases',
+      block: fmt(r.rows, ['claim_type', 'routing', 'jurisdiction', 'state', 'desired_remedy', 'due_at']),
+      names: [],
+    };
+  },
+
+  async savings(userId) {
+    const r = await db.query(
+      `SELECT title, current_item, current_price, suggested_item, suggested_price,
+              est_savings, merchant, status
+         FROM savings_suggestion WHERE user_id = $1 AND status IN ('new','saved')
+        ORDER BY est_savings DESC NULLS LAST LIMIT $2`,
+      [userId, LIMIT]
+    );
+    const c = await db.query(
+      `SELECT merchant, title, code, discount_text, expires_on
+         FROM merchant_coupon WHERE user_id = $1 AND status = 'active'
+        ORDER BY created_at DESC LIMIT 20`,
+      [userId]
+    );
+    return {
+      title: 'Savings suggestions and coupons',
+      block:
+        'SUGGESTIONS:\n' + fmt(r.rows, ['title', 'current_item', 'current_price', 'suggested_item', 'suggested_price', 'est_savings', 'merchant']) +
+        '\n\nCOUPONS:\n' + fmt(c.rows, ['merchant', 'title', 'code', 'discount_text', 'expires_on']),
+      names: [],
+    };
+  },
+
+  async bills(userId) {
+    const r = await db.query(
+      `SELECT name, payee, category, amount, currency, cadence, due_date, autopay, status
+         FROM bill WHERE user_id = $1 AND status <> 'archived'
+        ORDER BY due_date NULLS LAST LIMIT $2`,
+      [userId, LIMIT]
+    );
+    return {
+      title: 'Tracked bills',
+      block: fmt(r.rows, ['name', 'payee', 'category', 'amount', 'cadence', 'due_date', 'autopay', 'status']),
+      names: [],
+    };
+  },
+
+  async vitals(userId) {
+    const r = await db.query(
+      `SELECT metric, systolic, diastolic, value, unit, category, measured_at
+         FROM health_reading WHERE user_id = $1
+        ORDER BY measured_at DESC LIMIT $2`,
+      [userId, LIMIT]
+    );
+    // person names can appear on readings via person_id; pull the roster so we
+    // can redact them on external lanes.
+    const p = await db.query(
+      `SELECT full_name, nickname FROM person WHERE user_id = $1 LIMIT 50`,
+      [userId]
+    );
+    const names = p.rows.flatMap((x) => [x.full_name, x.nickname]).filter(Boolean);
+    return {
+      title: 'Health readings',
+      block: fmt(r.rows, ['metric', 'systolic', 'diastolic', 'value', 'unit', 'measured_at']),
+      names,
+    };
+  },
+
+  async reminders(userId) {
+    const r = await db.query(
+      `SELECT title, reason_code, due_at, state
+         FROM calendar_reminder WHERE user_id = $1 AND state <> 'dismissed'
+        ORDER BY due_at NULLS LAST LIMIT $2`,
+      [userId, LIMIT]
+    );
+    return {
+      title: 'Upcoming reminders and deadlines',
+      block: fmt(r.rows, ['title', 'reason_code', 'due_at', 'state']),
+      names: [],
+    };
+  },
+
+  async warranties(userId) {
+    const r = await db.query(
+      `SELECT provider_name, commitment_type, promised_outcome, window_days,
+              refund_window_ends_at
+         FROM service_commitment WHERE user_id = $1
+        ORDER BY refund_window_ends_at NULLS LAST LIMIT $2`,
+      [userId, LIMIT]
+    );
+    return {
+      title: 'Service commitments and warranties',
+      block: fmt(r.rows, ['provider_name', 'commitment_type', 'promised_outcome', 'window_days', 'refund_window_ends_at']),
+      names: [],
+    };
+  },
+};
+
+// Returns { context, names } for an agent scope, or empty when scope is null.
+async function ground(scope, userId) {
+  if (!scope || !SCOPES[scope]) return { context: '', names: [] };
+  const { title, block, names } = await SCOPES[scope](userId);
+  const context = `--- CONTEXT: ${title} (user's own records) ---\n${block}\n--- END CONTEXT ---`;
+  return { context, names };
+}
+
+module.exports = { ground, SCOPES };
diff --git a/lib/chat-providers/claude-cli.js b/lib/chat-providers/claude-cli.js
new file mode 100644
index 0000000..7479586
--- /dev/null
+++ b/lib/chat-providers/claude-cli.js
@@ -0,0 +1,72 @@
+// Claude adapter — shells out to the local `claude` CLI in print mode. This is
+// the ONLY sanctioned path to Claude (AGENTS.md hard NO on ANTHROPIC_API_KEY /
+// ANTHROPIC_AUTH_TOKEN in any process env). External lane: PII must already be
+// redacted by the caller.
+//
+// Safety: spawn (no shell) with an ARGV array so the prompt can never be
+// interpreted as shell; the Anthropic key env vars are explicitly DELETED from
+// the child env; a hard timeout SIGKILLs a hung child.
+
+const { spawn } = require('child_process');
+
+const CLAUDE_BIN = process.env.CLAUDE_BIN || '/Users/macstudio3/.local/bin/claude';
+const TIMEOUT_MS = 60_000;
+const MAX_PROMPT_CHARS = 20_000;
+
+function renderPrompt({ system, messages }) {
+  const lines = [];
+  if (system) lines.push(String(system), '');
+  for (const m of messages) {
+    const who = m.role === 'assistant' ? 'Assistant' : 'User';
+    lines.push(`${who}: ${String(m.content ?? '')}`);
+  }
+  lines.push('Assistant:');
+  let prompt = lines.join('\n');
+  if (prompt.length > MAX_PROMPT_CHARS) prompt = prompt.slice(prompt.length - MAX_PROMPT_CHARS);
+  return prompt;
+}
+
+function chat({ system, messages }) {
+  const prompt = renderPrompt({ system, messages });
+
+  const env = { ...process.env };
+  delete env.ANTHROPIC_API_KEY;
+  delete env.ANTHROPIC_AUTH_TOKEN;
+
+  return new Promise((resolve, reject) => {
+    const child = spawn(CLAUDE_BIN, ['-p', prompt], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+
+    let out = '';
+    let err = '';
+    let done = false;
+
+    const timer = setTimeout(() => {
+      if (done) return;
+      done = true;
+      child.kill('SIGKILL');
+      reject(new Error('claude CLI timed out'));
+    }, TIMEOUT_MS);
+
+    child.stdout.on('data', (d) => { out += d.toString(); });
+    child.stderr.on('data', (d) => { err += d.toString(); });
+
+    child.on('error', (e) => {
+      if (done) return;
+      done = true;
+      clearTimeout(timer);
+      reject(new Error(`claude CLI spawn failed: ${e.message}`));
+    });
+
+    child.on('close', (code) => {
+      if (done) return;
+      done = true;
+      clearTimeout(timer);
+      if (code !== 0) return reject(new Error(`claude CLI exited ${code}: ${err.slice(0, 300)}`));
+      const text = out.trim();
+      if (!text) return reject(new Error('claude CLI returned empty output'));
+      resolve({ text });
+    });
+  });
+}
+
+module.exports = { chat };
diff --git a/lib/chat-providers/gemini.js b/lib/chat-providers/gemini.js
new file mode 100644
index 0000000..bb04b1d
--- /dev/null
+++ b/lib/chat-providers/gemini.js
@@ -0,0 +1,49 @@
+// Gemini adapter — Google Generative Language API. External lane: callers MUST
+// have already redacted PII from system + messages before reaching here.
+
+const API = 'https://generativelanguage.googleapis.com/v1beta/models';
+const TIMEOUT_MS = 30_000;
+
+function toContents(messages) {
+  return messages.map((m) => ({
+    role: m.role === 'assistant' ? 'model' : 'user',
+    parts: [{ text: String(m.content ?? '') }],
+  }));
+}
+
+async function chat({ system, messages, providerModel }) {
+  const key = process.env.GEMINI_API_KEY;
+  if (!key) throw new Error('GEMINI_API_KEY not set');
+
+  const body = { contents: toContents(messages) };
+  if (system) body.systemInstruction = { parts: [{ text: String(system) }] };
+
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+  try {
+    const r = await fetch(`${API}/${encodeURIComponent(providerModel)}:generateContent?key=${key}`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+      signal: ctrl.signal,
+    });
+    if (!r.ok) {
+      const detail = await r.text().catch(() => '');
+      throw new Error(`gemini ${r.status}: ${detail.slice(0, 300)}`);
+    }
+    const j = await r.json();
+    const text = (j.candidates?.[0]?.content?.parts || [])
+      .map((p) => p.text || '')
+      .join('')
+      .trim();
+    if (!text) {
+      const reason = j.candidates?.[0]?.finishReason || j.promptFeedback?.blockReason || 'empty';
+      throw new Error(`gemini returned no text (${reason})`);
+    }
+    return { text };
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+module.exports = { chat };
diff --git a/lib/chat-providers/index.js b/lib/chat-providers/index.js
new file mode 100644
index 0000000..3921b09
--- /dev/null
+++ b/lib/chat-providers/index.js
@@ -0,0 +1,21 @@
+// Uniform provider dispatcher. Each adapter exposes async chat({system,messages,providerModel}) -> {text}.
+
+const gemini = require('./gemini');
+const ollama = require('./ollama');
+const claudeCli = require('./claude-cli');
+
+async function dispatch(model, { system, messages }) {
+  const args = { system, messages, providerModel: model.providerModel };
+  switch (model.provider) {
+    case 'gemini':
+      return gemini.chat(args);
+    case 'ollama':
+      return ollama.chat(args);
+    case 'claude-cli':
+      return claudeCli.chat(args);
+    default:
+      throw new Error(`unknown provider: ${model.provider}`);
+  }
+}
+
+module.exports = { dispatch, availableModels: ollama.availableModels };
diff --git a/lib/chat-providers/ollama.js b/lib/chat-providers/ollama.js
new file mode 100644
index 0000000..6ea96a0
--- /dev/null
+++ b/lib/chat-providers/ollama.js
@@ -0,0 +1,55 @@
+// Ollama adapter — local, $0, on-box. Private lane: raw PII is allowed here
+// because nothing leaves the network. Degrades gracefully when a model is not
+// pulled.
+
+const BASE = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
+const CHAT_TIMEOUT_MS = 60_000;
+const TAGS_TIMEOUT_MS = 3_000;
+
+async function availableModels(base = BASE) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), TAGS_TIMEOUT_MS);
+  try {
+    const r = await fetch(`${base}/api/tags`, { signal: ctrl.signal });
+    if (!r.ok) return [];
+    const j = await r.json();
+    return (j.models || []).map((m) => m.name);
+  } catch (_) {
+    return [];
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+async function chat({ system, messages, providerModel }) {
+  const models = await availableModels();
+  if (models.length && !models.includes(providerModel)) {
+    throw new Error(`model ${providerModel} not pulled on Ollama`);
+  }
+
+  const msgs = [];
+  if (system) msgs.push({ role: 'system', content: String(system) });
+  for (const m of messages) {
+    msgs.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content ?? '') });
+  }
+
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), CHAT_TIMEOUT_MS);
+  try {
+    const r = await fetch(`${BASE}/api/chat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: providerModel, messages: msgs, stream: false }),
+      signal: ctrl.signal,
+    });
+    if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text().catch(() => '')).slice(0, 300)}`);
+    const j = await r.json();
+    const text = (j.message?.content || '').trim();
+    if (!text) throw new Error('ollama returned empty response');
+    return { text };
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+module.exports = { chat, availableModels, BASE };
diff --git a/lib/chat-redact.js b/lib/chat-redact.js
new file mode 100644
index 0000000..8b7ea83
--- /dev/null
+++ b/lib/chat-redact.js
@@ -0,0 +1,106 @@
+// The PII redaction boundary. Every prompt bound for an EXTERNAL model provider
+// (Gemini, Claude-CLI) passes through redact() first. Local Ollama lanes bypass
+// it (raw data never leaves the box). Per AGENTS.md: redact name, address,
+// account numbers, MRN, NDC-as-PHI before any prompt to an external provider.
+//
+// Design: order matters — the most specific/structured patterns run first so a
+// broad pattern can't swallow a token another rule should have labelled. Each
+// match is replaced with a stable [TAG] placeholder. redact() is pure and
+// synchronous; it returns { text, redactions } so the caller can audit HOW MANY
+// spans were removed without ever logging the spans themselves.
+
+// Structured-first. Every regex is global + case-insensitive where sensible.
+const RULES = [
+  // Email addresses
+  { tag: 'EMAIL', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
+
+  // Phone numbers (US-ish: optional +1, separators . - or space, parens)
+  { tag: 'PHONE', re: /(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/g },
+
+  // SSN
+  { tag: 'SSN', re: /\b\d{3}-\d{2}-\d{4}\b/g },
+
+  // Credit-card-like 13-19 digit runs (spaces/dashes allowed between groups)
+  { tag: 'CARD', re: /\b(?:\d[ -]?){13,19}\b/g },
+
+  // NDC drug codes (National Drug Code) — PHI when tied to a person.
+  // 4-4-2, 5-4-2, 5-3-2, 5-4-1 hyphenated forms.
+  { tag: 'NDC', re: /\b\d{4,5}-\d{3,4}-\d{1,2}\b/g },
+
+  // MRN / Rx / DEA / account numbers introduced by a label
+  {
+    tag: 'ACCTNO',
+    re: /\b(?:MRN|medical\s+record(?:\s+number)?|account(?:\s+(?:no|number|#))?|acct|rx(?:\s+(?:no|number|#))?|dea|policy(?:\s+(?:no|number|#))?|member(?:\s+id)?|claim(?:\s+(?:no|number|#))?)\s*[:#]?\s*([A-Z0-9][A-Z0-9-]{3,})\b/gi,
+    // keep the label, redact the value (capture group 1)
+    group: 1,
+  },
+
+  // Street address: number + street words + suffix
+  {
+    tag: 'ADDRESS',
+    re: /\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s){0,4}(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|court|ct|way|place|pl|circle|cir|terrace|ter|parkway|pkwy|highway|hwy|suite|ste|apt|unit|#)\b\.?/gi,
+  },
+
+  // ZIP (5 or ZIP+4) — only when preceded by a 2-letter state token to avoid
+  // nuking every 5-digit number (order numbers, prices).
+  { tag: 'ZIP', re: /\b[A-Z]{2}\s+\d{5}(?:-\d{4})?\b/g, group: 0 },
+];
+
+// Person-name redaction is done on a supplied list (from the grounding layer,
+// which already knows the real names in the record set) rather than by a fragile
+// "any capitalized word" heuristic that would shred product/merchant names. The
+// caller passes { names: ['Steve Abrams', 'Jane Doe', ...] }.
+function escapeRe(s) {
+  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function redact(input, { names = [] } = {}) {
+  let text = typeof input === 'string' ? input : String(input ?? '');
+  let redactions = 0;
+
+  // 1) Full-name matches first (most reliable single signal), but NOT the bare
+  //    token split yet — a first/last token can live inside an email local-part
+  //    (jane.doe@…), so we let the structured EMAIL/PHONE/etc. rules claim whole
+  //    tokens next, then do the loose name-token pass last.
+  for (const raw of names) {
+    const name = String(raw || '').trim();
+    if (name.length < 2) continue;
+    const re = new RegExp(`\\b${escapeRe(name)}\\b`, 'gi');
+    text = text.replace(re, () => { redactions++; return '[NAME]'; });
+  }
+
+  // 2) Structured patterns, most-specific first.
+  for (const rule of RULES) {
+    text = text.replace(rule.re, (match, g1) => {
+      if (rule.group === 1 && g1 != null) {
+        // Preserve the label prefix, redact only the captured value.
+        const idx = match.lastIndexOf(g1);
+        redactions++;
+        return match.slice(0, idx) + `[${rule.tag}]`;
+      }
+      redactions++;
+      return `[${rule.tag}]`;
+    });
+  }
+
+  // 3) Loose name-token pass LAST — catches a bare first/last name in prose
+  //    (e.g. "ask Jane about it") without shredding emails already redacted above.
+  for (const raw of names) {
+    const name = String(raw || '').trim();
+    if (name.indexOf(' ') === -1) continue; // single-token names handled in step 1
+    for (const part of name.split(/\s+/)) {
+      if (part.length < 3) continue;
+      const pre = new RegExp(`\\b${escapeRe(part)}\\b`, 'gi');
+      text = text.replace(pre, () => { redactions++; return '[NAME]'; });
+    }
+  }
+
+  return { text, redactions };
+}
+
+// Convenience: redact a whole grounding-context block and count spans across it.
+function redactContext(context, opts = {}) {
+  return redact(context, opts);
+}
+
+module.exports = { redact, redactContext };
diff --git a/lib/chat-registry.js b/lib/chat-registry.js
new file mode 100644
index 0000000..fd3d204
--- /dev/null
+++ b/lib/chat-registry.js
@@ -0,0 +1,174 @@
+// Single source of truth for chat MODELS + AGENT personas.
+// Consumed by BOTH the backend (routes/chat.js dispatch) and the frontend
+// (nav-bar spec cards, rendered via /api/chat/registry). Nothing here holds a
+// secret — provider credentials live in process.env and are read only inside
+// the adapters. Any model or agent added here appears everywhere automatically.
+
+// visibility: 'external' lanes have their prompt REDACTED before it leaves the box.
+//             'local' lanes (Ollama) may see raw PII (never leaves the network).
+const MODELS = [
+  {
+    id: 'gemini-2.5-flash',
+    label: 'Gemini 2.5 Flash',
+    provider: 'gemini',
+    providerModel: 'gemini-2.5-flash',
+    visibility: 'external',
+    contextWindow: '1M tokens',
+    speed: 'fast',
+    cost: '~$0.10 / 1M in · $0.40 / 1M out',
+    dataNote: 'External — Google. PII is redacted before send.',
+  },
+  {
+    id: 'gemini-2.5-pro',
+    label: 'Gemini 2.5 Pro',
+    provider: 'gemini',
+    providerModel: 'gemini-2.5-pro',
+    visibility: 'external',
+    contextWindow: '1M tokens',
+    speed: 'medium',
+    cost: '~$1.25 / 1M in · $10 / 1M out',
+    dataNote: 'External — Google. PII is redacted before send.',
+  },
+  {
+    id: 'ollama-qwen3',
+    label: 'Qwen3 14B (local)',
+    provider: 'ollama',
+    providerModel: 'qwen3:14b',
+    visibility: 'local',
+    contextWindow: '~40K tokens',
+    speed: 'medium',
+    cost: '$0 (on-box)',
+    dataNote: 'Private — runs on your own hardware. Sees raw data; nothing leaves the box.',
+  },
+  {
+    id: 'ollama-hermes3',
+    label: 'Hermes 3 8B (local)',
+    provider: 'ollama',
+    providerModel: 'hermes3:8b',
+    visibility: 'local',
+    contextWindow: '~8K tokens',
+    speed: 'fast',
+    cost: '$0 (on-box)',
+    dataNote: 'Private — runs on your own hardware. Sees raw data; nothing leaves the box.',
+  },
+  {
+    id: 'claude-cli',
+    label: 'Claude (CLI)',
+    provider: 'claude-cli',
+    providerModel: 'claude',
+    visibility: 'external',
+    contextWindow: 'large',
+    speed: 'medium',
+    cost: 'Max-plan (no metered API)',
+    dataNote: 'External — Anthropic via local CLI. PII is redacted before send.',
+  },
+];
+
+// Each agent = a system prompt + a data-scope. `scope` names the grounding
+// bundle in lib/chat-grounding.js. `null` scope = no record grounding (General).
+const AGENTS = [
+  {
+    id: 'general',
+    label: 'General',
+    icon: '🏠',
+    scope: null,
+    speed: 'n/a',
+    dataNote: 'No personal records pulled. Answers from the conversation only.',
+    system:
+      'You are the AbramsOS assistant, a calm household operations helper. ' +
+      'You answer questions and explain how AbramsOS works. You never take actions ' +
+      '(no sending email, no filing, no purchases) — you inform only. If asked to do ' +
+      'something that changes state, explain that those actions stay gated behind the user.',
+  },
+  {
+    id: 'claims',
+    label: 'Claims / Autopilot',
+    icon: '📋',
+    scope: 'claims',
+    speed: 'n/a',
+    dataNote: 'Grounds on your claim cases (types, remedies, deadlines).',
+    system:
+      'You are the AbramsOS Claims agent. You help the user understand their open ' +
+      'claim cases, class-action settlements, deadlines, and desired remedies, grounded ' +
+      'only in the CONTEXT records provided. You draft nothing that gets sent and file ' +
+      'nothing — filing and sending stay gated behind explicit user approval. Be precise ' +
+      'about dates and never invent a claim that is not in the context.',
+  },
+  {
+    id: 'savings',
+    label: 'Savings',
+    icon: '🐷',
+    scope: 'savings',
+    speed: 'n/a',
+    dataNote: 'Grounds on your savings suggestions and coupons.',
+    system:
+      'You are the AbramsOS Savings agent. Using only the CONTEXT records, help the user ' +
+      'understand cheaper/better substitutes for what they buy and active coupons. You ' +
+      'never auto-buy anything. Quote the estimated savings and its basis honestly; if the ' +
+      'context has no suggestion for something, say so.',
+  },
+  {
+    id: 'bills',
+    label: 'Bill Audit',
+    icon: '🧾',
+    scope: 'bills',
+    speed: 'n/a',
+    dataNote: 'Grounds on your tracked bills (payee, amount, cadence, due dates).',
+    system:
+      'You are the AbramsOS Bill-Audit agent. Using only the CONTEXT records, help the user ' +
+      'understand their recurring bills — amounts, cadence, due dates, autopay status — and ' +
+      'spot likely overcharges or duplicate services. You never pay or cancel anything.',
+  },
+  {
+    id: 'vitals',
+    label: 'Vitals / Health',
+    icon: '❤️',
+    scope: 'vitals',
+    speed: 'n/a',
+    dataNote: 'Grounds on your health readings. Health data is sensitive — prefer a local model.',
+    system:
+      'You are the AbramsOS Vitals agent. Using only the CONTEXT records (health readings), ' +
+      'help the user read trends in their own measurements. You are NOT a doctor and give no ' +
+      'diagnosis or treatment advice — surface trends and suggest they discuss anything ' +
+      'concerning with a clinician. Never invent a reading not in the context.',
+  },
+  {
+    id: 'reminders',
+    label: 'Reminders / Deadlines',
+    icon: '⏰',
+    scope: 'reminders',
+    speed: 'n/a',
+    dataNote: 'Grounds on your upcoming reminders and deadlines.',
+    system:
+      'You are the AbramsOS Reminders agent. Using only the CONTEXT records, help the user ' +
+      'see what is coming up and what is overdue. Be exact about due dates and order things ' +
+      'by urgency. You do not create or dismiss reminders — that stays in the app UI.',
+  },
+  {
+    id: 'warranties',
+    label: 'Warranties',
+    icon: '🛡️',
+    scope: 'warranties',
+    speed: 'n/a',
+    dataNote: 'Grounds on your service commitments and warranty windows.',
+    system:
+      'You are the AbramsOS Warranties agent. Using only the CONTEXT records (service ' +
+      'commitments and guarantee windows), help the user understand what coverage they have ' +
+      'and when a warranty/return window closes. You never file a claim — that stays gated.',
+  },
+];
+
+const MODEL_BY_ID = Object.fromEntries(MODELS.map((m) => [m.id, m]));
+const AGENT_BY_ID = Object.fromEntries(AGENTS.map((a) => [a.id, a]));
+
+const DEFAULT_MODEL_ID = 'ollama-qwen3'; // private-by-default
+const DEFAULT_AGENT_ID = 'general';
+
+module.exports = {
+  MODELS,
+  AGENTS,
+  MODEL_BY_ID,
+  AGENT_BY_ID,
+  DEFAULT_MODEL_ID,
+  DEFAULT_AGENT_ID,
+};
diff --git a/public/css/app.css b/public/css/app.css
index bc288cb..c3355ad 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -417,3 +417,89 @@ html.nav-collapsed .side-actions { flex-direction: column; }
   .nav-sec { display: none; }
   .side-actions { margin: 0 0 0 auto; border-top: none; }
 }
+
+/* ── Chat panel ─────────────────────────────────────────────────────────── */
+.aos-chat-launcher {
+  position: fixed; right: 20px; bottom: 20px; z-index: 40;
+  width: 52px; height: 52px; border-radius: 999px;
+  font-size: 22px; cursor: pointer; color: var(--text);
+  display: inline-flex; align-items: center; justify-content: center;
+  border: 1px solid var(--border-strong);
+}
+.aos-chat-launcher:hover { background: var(--surface-strong); }
+
+.aos-chat-panel {
+  position: fixed; right: 20px; bottom: 84px; z-index: 41;
+  width: 420px; max-width: calc(100vw - 32px);
+  height: 560px; max-height: calc(100vh - 120px);
+  display: flex; flex-direction: column; overflow: hidden;
+  padding: 0;
+}
+.aos-chat-panel[hidden] { display: none; }
+
+.aos-chat-head {
+  display: flex; align-items: flex-end; gap: 10px;
+  padding: 12px 14px; border-bottom: 1px solid var(--border);
+  flex-wrap: wrap;
+}
+.aos-chat-pickers { display: flex; gap: 10px; flex: 1 1 auto; min-width: 0; }
+.aos-chat-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 0; }
+.aos-chat-field > span { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim); }
+.aos-chat-selwrap { display: flex; align-items: center; gap: 4px; }
+.aos-chat-selwrap select {
+  flex: 1 1 auto; min-width: 0; max-width: 100%;
+  background: var(--surface-strong); color: var(--text);
+  border: 1px solid var(--border); border-radius: var(--radius-sm);
+  padding: 6px 8px; font-size: 13px;
+}
+.aos-chat-info, .aos-chat-close, .aos-chat-allspecs {
+  background: var(--surface-strong); color: var(--text-dim);
+  border: 1px solid var(--border); border-radius: var(--radius-sm);
+  cursor: pointer; padding: 4px 7px; font-size: 12px;
+}
+.aos-chat-info:hover, .aos-chat-close:hover, .aos-chat-allspecs:hover { color: var(--text); }
+.aos-chat-head-right { display: flex; align-items: center; gap: 6px; width: 100%; }
+
+.aos-chat-lane {
+  font-size: 11px; padding: 3px 9px; border-radius: 999px; margin-right: auto;
+  border: 1px solid var(--border);
+}
+.aos-chat-lane.is-local { color: var(--bg-0); background: var(--ok); border-color: transparent; }
+.aos-chat-lane.is-external { color: var(--bg-0); background: var(--warn); border-color: transparent; }
+
+.aos-chat-spec, .aos-chat-roster {
+  padding: 10px 14px; border-bottom: 1px solid var(--border);
+  overflow-y: auto; max-height: 240px; background: var(--surface);
+}
+.aos-chat-spec[hidden], .aos-chat-roster[hidden] { display: none; }
+.aos-spec-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
+.aos-spec-section { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); margin: 8px 0 4px; }
+.aos-spec-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 8px; }
+.aos-spec-row { display: flex; gap: 8px; font-size: 12px; padding: 1px 0; }
+.aos-spec-k { color: var(--text-dim); min-width: 74px; flex: 0 0 auto; }
+.aos-spec-v { color: var(--text); word-break: break-word; }
+
+.aos-chat-log { flex: 1 1 auto; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
+.aos-bubble { max-width: 88%; padding: 8px 11px; border-radius: 14px; font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
+.aos-bubble.aos-user { align-self: flex-end; background: var(--accent); color: var(--bg-0); }
+.aos-bubble.aos-assistant { align-self: flex-start; background: var(--surface-strong); border: 1px solid var(--border); color: var(--text); }
+.aos-bubble.aos-error { border-color: var(--danger); }
+.aos-bubble-foot { margin-top: 5px; font-size: 10px; color: var(--text-dim); }
+.aos-bubble.aos-user .aos-bubble-foot { color: rgba(10,15,28,0.6); }
+
+.aos-chat-form { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); }
+.aos-chat-form textarea {
+  flex: 1 1 auto; resize: none; max-height: 120px;
+  background: var(--surface-strong); color: var(--text);
+  border: 1px solid var(--border); border-radius: var(--radius-sm);
+  padding: 9px 11px; font: inherit; font-size: 14px;
+}
+.aos-chat-send {
+  background: var(--accent-strong); color: var(--bg-0); border: none;
+  border-radius: var(--radius-sm); padding: 0 16px; font-weight: 600; cursor: pointer;
+}
+.aos-chat-send:disabled { opacity: .5; cursor: default; }
+
+@media (max-width: 520px) {
+  .aos-chat-panel { right: 8px; left: 8px; width: auto; bottom: 76px; }
+}
diff --git a/public/js/chat.js b/public/js/chat.js
new file mode 100644
index 0000000..4f7e77b
--- /dev/null
+++ b/public/js/chat.js
@@ -0,0 +1,193 @@
+// Nav-bar chat. Vanilla, no framework. Roster + specs come from
+// /api/chat/registry (single source of truth). Both selections persist in
+// localStorage. Session-only history (v1 needs no persistence). All rendered
+// text goes through textContent — never innerHTML of model/user text.
+
+(function () {
+  const launcher = document.getElementById('aosChatLauncher');
+  const panel = document.getElementById('aosChatPanel');
+  if (!launcher || !panel) return;
+
+  const modelSel = document.getElementById('aosChatModel');
+  const agentSel = document.getElementById('aosChatAgent');
+  const laneEl = document.getElementById('aosChatLane');
+  const specEl = document.getElementById('aosChatSpec');
+  const rosterEl = document.getElementById('aosChatRoster');
+  const logEl = document.getElementById('aosChatLog');
+  const form = document.getElementById('aosChatForm');
+  const input = document.getElementById('aosChatInput');
+  const sendBtn = document.getElementById('aosChatSend');
+  const closeBtn = document.getElementById('aosChatClose');
+  const allSpecsBtn = document.getElementById('aosChatAllSpecs');
+
+  const MODEL_KEY = 'abramsos.chat.model';
+  const AGENT_KEY = 'abramsos.chat.agent';
+
+  let registry = { models: [], agents: [], defaults: {} };
+  const history = []; // {role, content}
+
+  function get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
+  function set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} }
+
+  function selectedModel() { return registry.models.find((m) => m.id === modelSel.value); }
+  function selectedAgent() { return registry.agents.find((a) => a.id === agentSel.value); }
+
+  function updateLane() {
+    const m = selectedModel();
+    if (!m) { laneEl.textContent = ''; return; }
+    const local = m.visibility === 'local';
+    laneEl.textContent = local ? '🔒 Private / local' : '☁ External / redacted';
+    laneEl.classList.toggle('is-local', local);
+    laneEl.classList.toggle('is-external', !local);
+  }
+
+  function fillSelect(sel, items, labelFn) {
+    sel.innerHTML = '';
+    for (const it of items) {
+      const opt = document.createElement('option');
+      opt.value = it.id;
+      opt.textContent = labelFn(it);
+      if (it.available === false) opt.disabled = true;
+      sel.appendChild(opt);
+    }
+  }
+
+  function specLines(pairs) {
+    const wrap = document.createElement('div');
+    for (const [k, v] of pairs) {
+      const row = document.createElement('div');
+      row.className = 'aos-spec-row';
+      const key = document.createElement('span'); key.className = 'aos-spec-k'; key.textContent = k;
+      const val = document.createElement('span'); val.className = 'aos-spec-v'; val.textContent = v;
+      row.appendChild(key); row.appendChild(val);
+      wrap.appendChild(row);
+    }
+    return wrap;
+  }
+
+  function renderModelSpec(target, m) {
+    const h = document.createElement('div'); h.className = 'aos-spec-title'; h.textContent = m.label;
+    target.appendChild(h);
+    target.appendChild(specLines([
+      ['Provider', m.provider],
+      ['Context', m.contextWindow || '—'],
+      ['Speed', m.speed || '—'],
+      ['Cost', m.cost || '—'],
+      ['Data', m.dataNote || '—'],
+      ['Available', m.available === false ? 'not pulled' : 'yes'],
+    ]));
+  }
+
+  function renderAgentSpec(target, a) {
+    const h = document.createElement('div'); h.className = 'aos-spec-title'; h.textContent = (a.icon ? a.icon + ' ' : '') + a.label;
+    target.appendChild(h);
+    target.appendChild(specLines([
+      ['Data scope', a.scope || 'none (general)'],
+      ['Data', a.dataNote || '—'],
+      ['Role', (a.system || '').slice(0, 220) + ((a.system || '').length > 220 ? '…' : '')],
+    ]));
+  }
+
+  function showSpec(kind) {
+    rosterEl.hidden = true;
+    specEl.hidden = false;
+    specEl.innerHTML = '';
+    if (kind === 'model') { const m = selectedModel(); if (m) renderModelSpec(specEl, m); }
+    else { const a = selectedAgent(); if (a) renderAgentSpec(specEl, a); }
+  }
+
+  function toggleAllSpecs() {
+    specEl.hidden = true;
+    if (!rosterEl.hidden) { rosterEl.hidden = true; return; }
+    rosterEl.hidden = false;
+    rosterEl.innerHTML = '';
+    const mh = document.createElement('div'); mh.className = 'aos-spec-section'; mh.textContent = 'Models';
+    rosterEl.appendChild(mh);
+    for (const m of registry.models) { const c = document.createElement('div'); c.className = 'aos-spec-card'; renderModelSpec(c, m); rosterEl.appendChild(c); }
+    const ah = document.createElement('div'); ah.className = 'aos-spec-section'; ah.textContent = 'Agents';
+    rosterEl.appendChild(ah);
+    for (const a of registry.agents) { const c = document.createElement('div'); c.className = 'aos-spec-card'; renderAgentSpec(c, a); rosterEl.appendChild(c); }
+  }
+
+  function bubble(role, text, foot) {
+    const b = document.createElement('div');
+    b.className = 'aos-bubble aos-' + role;
+    const body = document.createElement('div'); body.className = 'aos-bubble-body'; body.textContent = text;
+    b.appendChild(body);
+    if (foot) { const f = document.createElement('div'); f.className = 'aos-bubble-foot'; f.textContent = foot; b.appendChild(f); }
+    logEl.appendChild(b);
+    logEl.scrollTop = logEl.scrollHeight;
+    return b;
+  }
+
+  async function send(message) {
+    const model = selectedModel();
+    const agent = selectedAgent();
+    if (!model || !agent) return;
+    bubble('user', message);
+    history.push({ role: 'user', content: message });
+    const thinking = bubble('assistant', '…thinking');
+    sendBtn.disabled = true;
+    try {
+      const res = await fetch('/api/chat', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ message, modelId: model.id, agentId: agent.id, history: history.slice(-10) }),
+      });
+      const data = await res.json().catch(() => ({}));
+      if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
+      thinking.querySelector('.aos-bubble-body').textContent = data.reply;
+      const foot = 'via ' + data.modelId + ' · ' + data.lane +
+        (data.redactionApplied ? ' · ' + (data.redactions || 0) + ' fields redacted' : '');
+      const f = document.createElement('div'); f.className = 'aos-bubble-foot'; f.textContent = foot;
+      thinking.appendChild(f);
+      history.push({ role: 'assistant', content: data.reply });
+    } catch (err) {
+      thinking.classList.add('aos-error');
+      thinking.querySelector('.aos-bubble-body').textContent = 'Error: ' + err.message;
+    } finally {
+      sendBtn.disabled = false;
+      logEl.scrollTop = logEl.scrollHeight;
+    }
+  }
+
+  async function boot() {
+    try {
+      const res = await fetch('/api/chat/registry');
+      registry = await res.json();
+    } catch (_) {
+      return;
+    }
+    fillSelect(modelSel, registry.models, (m) => m.label + (m.available === false ? ' (not pulled)' : ''));
+    fillSelect(agentSel, registry.agents, (a) => (a.icon ? a.icon + ' ' : '') + a.label);
+
+    const savedModel = get(MODEL_KEY);
+    const savedAgent = get(AGENT_KEY);
+    const modelOk = savedModel && registry.models.some((m) => m.id === savedModel && m.available !== false);
+    modelSel.value = modelOk ? savedModel : (registry.defaults.modelId || registry.models[0]?.id || '');
+    agentSel.value = (savedAgent && registry.agents.some((a) => a.id === savedAgent))
+      ? savedAgent : (registry.defaults.agentId || registry.agents[0]?.id || '');
+
+    updateLane();
+
+    modelSel.addEventListener('change', () => { set(MODEL_KEY, modelSel.value); updateLane(); if (!specEl.hidden) showSpec('model'); });
+    agentSel.addEventListener('change', () => { set(AGENT_KEY, agentSel.value); if (!specEl.hidden) showSpec('agent'); });
+    document.querySelectorAll('.aos-chat-info').forEach((b) => b.addEventListener('click', () => showSpec(b.dataset.spec)));
+    allSpecsBtn.addEventListener('click', toggleAllSpecs);
+  }
+
+  launcher.addEventListener('click', () => { panel.hidden = !panel.hidden; if (!panel.hidden) input.focus(); });
+  closeBtn.addEventListener('click', () => { panel.hidden = true; });
+  form.addEventListener('submit', (e) => {
+    e.preventDefault();
+    const msg = input.value.trim();
+    if (!msg) return;
+    input.value = '';
+    send(msg);
+  });
+  input.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
+  });
+
+  boot();
+})();
diff --git a/routes/chat.js b/routes/chat.js
new file mode 100644
index 0000000..cd3c0e9
--- /dev/null
+++ b/routes/chat.js
@@ -0,0 +1,116 @@
+// Chat route. POST /api/chat grounds on the agent's data-scope, redacts PII for
+// external lanes, dispatches to the selected provider, writes one audit_log row,
+// and returns the reply. v1 is READ/ANSWER ONLY — it performs no state-changing
+// or external action (no send, no file). CSRF-exempt by /api/* convention;
+// requireAuth is applied by server.js before this router is mounted.
+
+const express = require('express');
+const registry = require('../lib/chat-registry');
+const grounding = require('../lib/chat-grounding');
+const { redact } = require('../lib/chat-redact');
+const providers = require('../lib/chat-providers');
+const audit = require('../lib/audit');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+const MAX_HISTORY = 10;
+const MAX_MESSAGE_CHARS = 8_000;
+
+router.get('/api/chat/registry', async (_req, res) => {
+  let ollamaAvailable = [];
+  try {
+    ollamaAvailable = await providers.availableModels();
+  } catch (_) {
+    ollamaAvailable = [];
+  }
+  const models = registry.MODELS.map((m) => ({
+    ...m,
+    available: m.provider === 'ollama'
+      ? ollamaAvailable.includes(m.providerModel)
+      : true,
+  }));
+  res.json({
+    models,
+    agents: registry.AGENTS,
+    defaults: { modelId: registry.DEFAULT_MODEL_ID, agentId: registry.DEFAULT_AGENT_ID },
+  });
+});
+
+router.post('/api/chat', async (req, res) => {
+  try {
+    const { message, modelId, agentId } = req.body || {};
+    if (!message || typeof message !== 'string') {
+      return res.status(400).json({ error: 'message required' });
+    }
+    const model = registry.MODEL_BY_ID[modelId];
+    const agent = registry.AGENT_BY_ID[agentId];
+    if (!model) return res.status(400).json({ error: 'unknown modelId' });
+    if (!agent) return res.status(400).json({ error: 'unknown agentId' });
+
+    const userId = req.userId || DEV_USER_ID;
+    const isExternal = model.visibility === 'external';
+
+    // Session-scoped history (client-supplied), sanitized + capped.
+    const history = Array.isArray(req.body.history)
+      ? req.body.history
+          .filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
+          .slice(-MAX_HISTORY)
+          .map((h) => ({ role: h.role, content: h.content.slice(0, MAX_MESSAGE_CHARS) }))
+      : [];
+
+    // Ground on the agent's data-scope (read-only).
+    const { context, names } = await grounding.ground(agent.scope, userId);
+
+    const userTurn = context
+      ? `${context}\n\nQuestion: ${message.slice(0, MAX_MESSAGE_CHARS)}`
+      : message.slice(0, MAX_MESSAGE_CHARS);
+
+    let messages = [...history, { role: 'user', content: userTurn }];
+    let system = agent.system;
+    let redactions = 0;
+
+    if (isExternal) {
+      // Redact EVERY outgoing span: system + all message contents.
+      const sys = redact(system, { names });
+      system = sys.text;
+      redactions += sys.redactions;
+      messages = messages.map((m) => {
+        const r = redact(m.content, { names });
+        redactions += r.redactions;
+        return { role: m.role, content: r.text };
+      });
+    }
+
+    const { text: reply } = await providers.dispatch(model, { system, messages });
+
+    // One audit_log row per chat call. No message text / no PII in metadata.
+    await audit.log({
+      actorType: 'user',
+      actorId: userId,
+      objectType: 'chat',
+      objectId: null,
+      eventType: 'chat_message',
+      metadata: {
+        modelId: model.id,
+        agentId: agent.id,
+        provider: model.provider,
+        visibility: model.visibility,
+        redaction_applied: isExternal,
+        redactions,
+      },
+    }).catch(() => {});
+
+    res.json({
+      reply,
+      lane: model.visibility,
+      redactionApplied: isExternal,
+      redactions,
+      modelId: model.id,
+      agentId: agent.id,
+    });
+  } catch (err) {
+    res.status(502).json({ error: err.message || 'chat failed' });
+  }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 64ba8e7..7b0e022 100644
--- a/server.js
+++ b/server.js
@@ -36,6 +36,7 @@ const warrantiesRouter = require('./routes/warranties');
 const peopleRouter = require('./routes/people');
 const medicationsRouter = require('./routes/medications');
 const prescriptionsRouter = require('./routes/prescriptions');
+const chatRouter = require('./routes/chat');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -120,6 +121,7 @@ app.use(warrantiesRouter);          // /warranties, /api/warranties*
 app.use(peopleRouter);              // /household, /api/people*
 app.use(medicationsRouter);         // /medications, /api/medications*
 app.use(prescriptionsRouter);       // /prescriptions, /api/prescriptions
+app.use(chatRouter);                // /api/chat, /api/chat/registry (nav-bar chat)
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/tests/chat.test.js b/tests/chat.test.js
new file mode 100644
index 0000000..3369215
--- /dev/null
+++ b/tests/chat.test.js
@@ -0,0 +1,97 @@
+// Chat: the PII-redaction golden fixture (the load-bearing safety test) + the
+// route auth-gate + the registry contract.
+
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+require('dotenv').config();
+const { redact } = require('../lib/chat-redact');
+const registry = require('../lib/chat-registry');
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+test.before(() => new Promise((r) => { server = app.listen(0, r); }));
+test.after(async () => {
+  await new Promise((r) => server.close(r));
+  await pool.end();
+});
+
+function req(method, path, body) {
+  return new Promise((resolve, reject) => {
+    const port = server.address().port;
+    const data = body ? JSON.stringify(body) : null;
+    const r = http.request(
+      { host: '127.0.0.1', port, path, method, headers: { 'Content-Type': 'application/json' } },
+      (res) => {
+        let buf = '';
+        res.on('data', (c) => (buf += c));
+        res.on('end', () => resolve({ status: res.statusCode, body: buf, headers: res.headers }));
+      }
+    );
+    r.on('error', reject);
+    if (data) r.write(data);
+    r.end();
+  });
+}
+
+// ── Golden fixture: redaction strips PII, keeps benign text ──────────────────
+test('redact strips email / phone / SSN / name / NDC / street address', () => {
+  const src =
+    'Contact Jane Doe at jane.doe@example.com or 415-555-0132. ' +
+    'SSN 123-45-6789. Ships to 1600 Amphitheatre Parkway. ' +
+    'Prescription NDC 12345-678-90. Ordered a Ninja Blender from Amazon for $89.';
+  const { text, redactions } = redact(src, { names: ['Jane Doe'] });
+
+  assert.ok(!/jane\.doe@example\.com/i.test(text), 'email not stripped');
+  assert.ok(!/415-555-0132/.test(text), 'phone not stripped');
+  assert.ok(!/123-45-6789/.test(text), 'SSN not stripped');
+  assert.ok(!/Jane/i.test(text) && !/\bDoe\b/i.test(text), 'name not stripped');
+  assert.ok(!/12345-678-90/.test(text), 'NDC not stripped');
+  assert.ok(!/1600 Amphitheatre Parkway/i.test(text), 'street address not stripped');
+
+  assert.match(text, /\[EMAIL\]/);
+  assert.match(text, /\[PHONE\]/);
+  assert.match(text, /\[SSN\]/);
+  assert.match(text, /\[NAME\]/);
+  assert.match(text, /\[NDC\]/);
+  assert.match(text, /\[ADDRESS\]/);
+
+  // Benign merchant/product text survives.
+  assert.match(text, /Ninja Blender/);
+  assert.match(text, /Amazon/);
+  assert.ok(redactions >= 6, `expected >=6 redactions, got ${redactions}`);
+});
+
+test('redact is a no-op signal on clean text', () => {
+  const { text, redactions } = redact('What savings do I have this month?', { names: [] });
+  assert.strictEqual(text, 'What savings do I have this month?');
+  assert.strictEqual(redactions, 0);
+});
+
+// ── Registry is internally consistent (single source of truth) ───────────────
+test('registry defaults resolve and every model has a known visibility', () => {
+  assert.ok(registry.MODEL_BY_ID[registry.DEFAULT_MODEL_ID], 'default model missing');
+  assert.ok(registry.AGENT_BY_ID[registry.DEFAULT_AGENT_ID], 'default agent missing');
+  for (const m of registry.MODELS) {
+    assert.ok(['external', 'local'].includes(m.visibility), `bad visibility on ${m.id}`);
+    assert.ok(['gemini', 'ollama', 'claude-cli'].includes(m.provider), `bad provider on ${m.id}`);
+  }
+  // Ollama lanes must be local (raw PII allowed); gemini/claude must be external.
+  for (const m of registry.MODELS) {
+    if (m.provider === 'ollama') assert.strictEqual(m.visibility, 'local', `${m.id} ollama must be local`);
+    else assert.strictEqual(m.visibility, 'external', `${m.id} must be external`);
+  }
+});
+
+// ── Route is auth-gated (unauth /api/* -> 401 under test env) ────────────────
+test('/api/chat is auth-gated', async () => {
+  const r = await req('POST', '/api/chat', { message: 'hi', modelId: 'ollama-qwen3', agentId: 'general' });
+  assert.ok([302, 401].includes(r.status), `expected 302 or 401, got ${r.status}`);
+});
+
+test('/api/chat/registry is auth-gated', async () => {
+  const r = await req('GET', '/api/chat/registry');
+  assert.ok([302, 401].includes(r.status), `expected 302 or 401, got ${r.status}`);
+});

← b5d614e auto-data-snapshot: 2026-09-01T14:52:01 (2 data files) — vie  ·  back to AbramsOS  ·  Harden nav-bar chat PII redaction boundary (TK-11084) 7fca2fd →