← 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
A lib/chat-grounding.jsA lib/chat-providers/claude-cli.jsA lib/chat-providers/gemini.jsA lib/chat-providers/index.jsA lib/chat-providers/ollama.jsA lib/chat-redact.jsA lib/chat-registry.jsM public/css/app.cssA public/js/chat.jsA routes/chat.jsM server.jsA tests/chat.test.js
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 →