From 4c4cecfd2bedf1f5ba9da9191bc19e4150967a8a Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Mon, 1 Jun 2026 23:59:48 -0400
Subject: [PATCH 1/7] Add design spec for chat inventory intake
Capability-registry architecture (chat + MCP adapters over one source of
truth), agentic intake flow with identification cards, draft-by-default
Notion writes, vision/audio/batch input, and phased build order. Builds on
the merged PR #25 chat launcher.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...2026-06-01-chat-inventory-intake-design.md | 312 ++++++++++++++++++
1 file changed, 312 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md
diff --git a/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md b/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md
new file mode 100644
index 0000000..9707481
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md
@@ -0,0 +1,312 @@
+# Chat Inventory Intake — Design Spec
+
+**Date:** 2026-06-01
+**Status:** Approved for planning
+**Target:** v5 (`v5/`, Notion-backed)
+**Branch:** `philosophercode/chat-inventory-intake`
+
+## 1. Summary
+
+Add a low-barrier way to contribute inventory through the existing chat: a person
+describes equipment in free text, dictated audio, photos, and/or pasted product
+URLs (Amazon, store pages, manuals), and an **intake agent** parses the hint,
+researches it, shows an **identification card** to confirm, and — on confirmation —
+creates a draft catalog listing in Notion (tool + units + category/location +
+manuals/videos).
+
+The feature is delivered on top of a small architecture change: capabilities
+(catalog reads, units, maintenance, intake) are defined **once** and exposed through
+**both** the chat and the MCP endpoint. This is the first step toward a single,
+extensible chat where every future feature is "just another capability."
+
+## 2. Goals / Non-goals
+
+### Goals
+- One unified chat that catalogs equipment from messy multimodal input.
+- Smart agent: asks clarifying questions, enriches via web search/fetch, confirms
+ via an interactive card before writing.
+- Draft-by-default writes (`published = false`) — nothing reaches the live catalog
+ without a staff member publishing in Notion.
+- Scope C: create the Tool **and** its Units, and find-or-create Category/Location;
+ attach manuals/videos as Resource rows; attach the uploaded photo.
+- Batch: handle many items in one turn (multiple photos or a long free-text list),
+ one independently-confirmable card per item.
+- Every capability (chat query, maintenance, intake) is also MCP-accessible.
+
+### Non-goals (this iteration)
+- No authentication / accounts. Protection is draft-by-default + human confirmation.
+- No server-side transcription of uploaded audio files. Audio = browser dictation.
+- No editing/deleting existing tools via chat (create-only for now).
+- No Notion → live-catalog auto-publish. Publishing stays a manual staff action.
+- Not migrating capabilities to MCP-as-transport for the chat (Approach 3); chat
+ consumes capabilities in-process. MCP is a second adapter, not the chat's backend.
+
+## 3. Architecture: capability registry + two adapters
+
+A **capability** is a module grouping related tools plus a shared system-prompt
+fragment. Each tool is defined once as plain data + a `run()` function. Two thin
+adapters expose the registry through the chat and through MCP.
+
+### 3.1 CapabilityTool shape
+
+```ts
+type CapabilityCtx = {
+ // Surface-agnostic services the tool may use.
+ // Chat adapter populates writer/attachments/locale/focusedToolId.
+ // MCP adapter populates only what it can (no writer, no attachments).
+ writer?: UIMessageStreamWriter; // chat only — for emitting card data parts
+ attachments?: UploadedImage[]; // chat only — uploaded photos for this turn
+ locale?: string;
+ focusedToolId?: string;
+};
+
+type CapabilityTool = {
+ name: string; // "search_tools" | "report_issue" | "create_tool" ...
+ description: string;
+ inputSchema: z.ZodType;
+ kind: "read" | "write"; // write tools: MCP-gated by MCP_TOKEN
+ run: (input: I, ctx: CapabilityCtx) => Promise; // pure-ish data in, data out
+ card?: (result: R) => CardPayload; // optional: how the chat renders the result
+};
+
+type Capability = {
+ id: string; // "catalog" | "units" | "maintenance" | "intake"
+ promptFragment: (env: PromptEnv) => string; // instructions added to system prompt
+ tools: CapabilityTool[];
+};
+```
+
+### 3.2 Registry
+
+`v5/src/lib/capabilities/index.ts` exports an ordered array of capabilities:
+
+```ts
+export const CAPABILITIES = [catalog, units, maintenance, intake];
+```
+
+### 3.3 Adapters
+
+- **Chat adapter** (`v5/src/lib/capabilities/chat-adapter.ts`): `toAiTools(caps, ctx)`
+ → `Record`. Wraps each `run()` in the AI SDK `tool()` shape. For
+ tools with `card`, after `run()` returns it calls `ctx.writer.write({ type:
+ "data-card", data: card(result), ... })` so the client renders a widget, and
+ returns a compact text result to the model. Composes the system prompt by joining
+ each capability's `promptFragment`.
+- **MCP adapter** (`v5/src/lib/capabilities/mcp-adapter.ts`): `registerAll(server,
+ caps)` → calls `server.registerTool(name, { description, inputSchema },
+ handler)`; the handler runs `run()` and returns `{ content: [{ type: "text", text:
+ JSON.stringify(result) }] }`. **Rule for `write` tools:** they are registered over
+ MCP **only when `MCP_TOKEN` is configured** (the endpoint is then token-gated end to
+ end, as today). If no `MCP_TOKEN` is set, the MCP surface is read-only — `write`
+ capabilities are omitted entirely. See §8.
+
+### 3.4 What moves where (refactor, no behavior change)
+
+| Current location | New capability |
+|---|---|
+| `route.ts` `get_tool_details` + MCP `list/search/get_tool_details` | `catalog` |
+| `route.ts` `get_unit_details` + MCP `get_unit_details`/`get_maintenance_history` | `units` |
+| `route.ts` `report_issue` | `maintenance` |
+| (new) | `intake` |
+
+`web_fetch` / `web_search` stay as provider-native tools added by the chat adapter
+(they are Anthropic tools, not capabilities). The intake capability's prompt tells
+the agent to use them.
+
+The existing duplicated unit-lookup / tool-summary helpers in `route.ts` and
+`mcp/route.ts` collapse into shared helpers used by the capabilities.
+
+## 4. Intake agent flow
+
+The `intake` capability adds three tools and a prompt fragment.
+
+### 4.1 Tools
+
+1. **`research_tool`** (`read`) — input: a free-text description and/or product
+ URL(s) and/or a note that photos are attached. The agent is instructed to call
+ `web_search`/`web_fetch` (native) to gather canonical name, manufacturer, specs,
+ materials, manual PDF URL, and a setup video URL. `research_tool` itself
+ normalizes/returns a structured candidate (it may also call catalog
+ `search_tools` first to detect duplicates). Returns a `ToolCandidate`.
+2. **`propose_listing`** (`read`, has `card`) — input: one or more `ToolCandidate`s.
+ Returns the candidates and emits an **identification card** per candidate
+ (widget with photo, name, category, location, specs, found manual/video, and the
+ "also creating" list with `(new)` tags for taxonomy). No writes. This is the
+ "is this the tool you described?" gate.
+3. **`create_tool`** (`write`) — input: a confirmed `ToolCandidate`. Performs the
+ Notion writes (§5) and returns `{ tool_id, unit_ids, draft_url, created: {...} }`.
+ Card flips to a success state: "Saved as a draft — staff will publish it."
+
+### 4.2 ToolCandidate (shared type)
+
+```ts
+type ToolCandidate = {
+ name: string;
+ description: string;
+ category?: { name: string; group: string; isNew: boolean };
+ location?: { room: string; zone: string; isNew: boolean };
+ materials: string[];
+ ppe_required: string[];
+ tags: string[];
+ training_required?: boolean;
+ use_restrictions?: string;
+ units: { label: string; status?: string; condition?: string; serial?: string }[];
+ resources: { title: string; url: string; type: "Manual" | "Video" | "Other" }[];
+ image_upload_ids: string[]; // Notion file_upload ids from /api/upload-notion
+ source_urls: string[]; // provenance: what the agent read
+ duplicate_of?: { id: string; name: string } | null; // catalog match, if any
+};
+```
+
+### 4.3 Confirmation & batch
+
+- The agent **never** calls `create_tool` without a prior `propose_listing` and an
+ explicit user confirmation (click or typed "yes / add it").
+- Confirmation is delivered by the card's **"Looks right — add it"** button, which
+ seeds a follow-up user message (e.g. `confirm add: `) that the agent
+ resolves to a `create_tool` call. "Edit" lets the user type a correction; the
+ agent re-runs `propose_listing`. "✕" discards.
+- **Batch:** when multiple items are present, the agent emits multiple cards (one
+ `propose_listing` call with N candidates, or N calls). Each card confirms and
+ writes independently. "Add all" seeds a confirm-all message.
+
+### 4.4 Duplicate handling
+
+`research_tool`/`propose_listing` run a catalog `search_tools` on the candidate
+name. If a strong match exists, the card shows "Already in catalog" and offers
+**Add a unit to the existing tool** instead of creating a new tool.
+
+## 5. Notion write layer
+
+New functions in `v5/src/lib/notion.ts`, following the existing
+`createMaintenanceLog` pattern (`/pages` POST, write-prop helpers already present:
+`titleProp`, `richTextProp`, `selectProp`, `relationProp`, `dateProp`,
+`fileUploadsProp`). Add `multiSelectProp`, `checkboxProp`, `urlProp` as needed.
+
+- `createTool(fields: Partial): Promise` — sets
+ `published = false` always. Maps category/location relation ids, materials/ppe/tags
+ multi-selects, attaches images via `image_attachments` file_upload prop.
+- `createUnit(fields: Partial): Promise` — links `tool`
+ relation; default `status = "Available"`.
+- `findOrCreateCategory(name, group): Promise<{ id; isNew }>` — query
+ `categories` for a case-insensitive name match; create if absent.
+- `findOrCreateLocation(room, zone): Promise<{ id; isNew }>` — query `locations`;
+ create if absent.
+- `createResource(fields: Partial): Promise` —
+ manuals/videos linked to the new tool (`published = false`).
+
+**Write order in `create_tool.run`:** resolve/create category + location → create
+tool (with image uploads + relations, `published=false`) → create units (link to
+tool) → create resources (link to tool). Each step is best-effort logged; a failure
+after the tool is created returns a partial-success result naming what landed so the
+user/staff can finish in Notion (no silent failures).
+
+**Image path:** photos are uploaded client-side to the existing
+`/api/upload-notion` (returns `file_upload_id`); ids ride on the candidate as
+`image_upload_ids` and are attached to the tool page's image field at create time.
+`/api/upload-notion` is image-only today — unchanged.
+
+## 6. Input handling (chat client)
+
+### 6.1 Vision (model must see photos) — change
+Today `ChatFab` uploads photos to Notion and passes only a `[Attached photos:
+file_upload_id=…]` text hint; the model never sees the image. For identification we
+must send the image bytes to the model.
+
+- On image attach, in addition to the existing Notion upload, include the image as a
+ **file/image part** on the outgoing user message so Claude can see it.
+- Keep the `file_upload_id` hint so `create_tool` can attach the *same* photo to the
+ Notion page without re-uploading.
+- Apply existing size/type guards; respect the request size ceiling.
+
+### 6.2 Audio (browser dictation) — additive
+- Add a mic button to the chat input that uses the browser **Web Speech API**
+ (`SpeechRecognition`) to transcribe speech into the draft text box.
+- Feature-detect; hide/disable the button where unsupported (graceful degradation).
+- No new dependency, no API key, no server route. Server-side file transcription is
+ explicitly out of scope (see Non-goals).
+
+### 6.3 Identification card rendering — new
+- The chat client renders `type: "data-card"` parts as an ``
+ widget (photo, fields, found resources, "also creating" with `(new)` badges,
+ action buttons). Success and "already in catalog" are card states.
+- Buttons seed follow-up messages via the existing send path (and the PR #25
+ `ChatLauncherProvider` seed mechanism where a launcher is involved).
+
+### 6.4 Entry point (front door) — reuses PR #25
+- Add an "Add equipment" entry (nav button and/or FAB action) that calls
+ `useChatLauncher().open("I'd like to add new equipment to the inventory.")`.
+- Add `nav.add` / `nav.addAria` / `nav.addSeed` to all 12 locale files
+ (`v5/messages/*.json`), mirroring the `nav.report*` keys PR #25 added.
+
+## 7. Relationship to PR #25 (merged)
+
+PR #25 (merged to `main`, squash `f0b8643`) added the **front-door layer**:
+`ChatLauncherProvider` (open + seed the chat from anywhere) and a "Report" nav
+button driving the existing `report_issue` flow. It changed no server/tool logic.
+
+This design adopts it directly:
+- `report_issue` becomes the `maintenance` capability (now MCP-accessible too).
+- The intake "Add equipment" front door is just another caller of
+ `ChatLauncherProvider` — no new launcher infrastructure.
+
+```
+Front doors: "Report" btn · "Add equipment" btn · tool-page deep links
+ │ open(seedText) (PR #25 launcher)
+ ▼
+ ChatFab ─► chat route ─► capability registry ─► catalog · units · maintenance · intake
+ MCP route ───────────────────────►┘ (same registry, token-gated)
+```
+
+## 8. Security & safety
+
+- **Draft-by-default:** every created tool/unit/resource is `published = false`.
+ The catalog read path (`fetchAllTools`) already filters to published.
+- **Human confirmation:** `create_tool` only runs after `propose_listing` + explicit
+ confirm. The agent prompt forbids writing without confirmation.
+- **Taxonomy guardrail:** new Category/Location creation is surfaced on the card with
+ a `(new)` badge before the user confirms.
+- **MCP writes:** `write` capabilities are exposed over MCP **only when `MCP_TOKEN`
+ is configured** (which token-gates the whole endpoint). With no token set, MCP is
+ read-only and write tools are not registered. Read tools stay open (as today). Rate
+ limits already exist on chat/upload/MCP routes.
+- **No silent failures:** partial write failures return a structured report of what
+ landed; nothing is swallowed.
+
+## 9. Phased build order
+
+Each phase is independently shippable.
+
+1. **Phase 1 — Capability registry + adapters (refactor, no behavior change).**
+ Build the registry, chat adapter, MCP adapter; port `catalog`, `units`,
+ `maintenance`. Chat and MCP behavior unchanged; `report_issue` now MCP-callable.
+ Verify: existing chat flows + MCP tools still work; `build`/`lint`/tests green.
+2. **Phase 2 — Notion write layer + intake tools + vision.**
+ Add `createTool`/`createUnit`/`findOrCreate*`/`createResource`; add `research_tool`,
+ `propose_listing`, `create_tool` (data only); send images to the model. Drive it
+ text-first (cards can be plain text initially).
+3. **Phase 3 — Identification card UI + batch + front door.**
+ `` widget + `data-card` parts; confirm/edit/discard buttons;
+ batch stacks; "Add equipment" launcher entry + locale keys.
+4. **Phase 4 — Audio dictation.**
+ Mic button via Web Speech API with feature detection.
+
+## 10. Testing
+
+- **Unit:** `findOrCreate*` (match vs create), `create_tool` write order + partial
+ failure reporting, ToolCandidate normalization, adapter wrapping (capability →
+ AI tool and → MCP tool shapes).
+- **Integration:** intake happy path (text-only → propose → confirm → draft created),
+ duplicate detection → "add unit" path, batch with one bad item.
+- **Manual:** photo identification end-to-end; audio dictation on Chrome/Safari;
+ draft visibility (not in catalog until published).
+- Match the existing v5 test setup; run `build` + `lint` in `v5/` before each phase
+ lands (per PR #25's verification bar).
+
+## 11. Open questions / future
+
+- Server-side transcription of recorded audio files (deferred provider add-on).
+- Edit/delete of existing tools via chat (create-only for now).
+- Promoting the registry so the chat consumes capabilities *over* MCP (Approach 3),
+ once capabilities stabilize.
+- Optional light gate on the intake front door if draft spam becomes a problem.
From 8d5a95a4f389e59444e4e4a009661107440d81dd Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 00:56:54 -0400
Subject: [PATCH 2/7] Implement chat inventory intake feature (v5)
Capability-registry architecture: catalog/units/maintenance/intake
capabilities defined once, exposed through both the chat and MCP via
chat-adapter and mcp-adapter. Adds the agentic intake flow
(research_tool -> propose_listing -> create_tool) with draft-by-default
Notion writes, duplicate detection, batch handling, and identification
cards. Chat route now sends uploaded photos to the model (vision);
ChatFab gains card rendering and Web Speech dictation; "Add equipment"
front door + nav.add* in all 12 locales.
Built via contract-first parallel subagents. typecheck/lint/build green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/messages/ar.json | 8 +-
v5/messages/en.json | 8 +-
v5/messages/es.json | 8 +-
v5/messages/fr.json | 8 +-
v5/messages/he.json | 8 +-
v5/messages/hi.json | 8 +-
v5/messages/ja.json | 8 +-
v5/messages/ko.json | 8 +-
v5/messages/pt-BR.json | 8 +-
v5/messages/ru.json | 8 +-
v5/messages/tr.json | 8 +-
v5/messages/zh-CN.json | 8 +-
v5/src/app/api/chat/route.ts | 506 ++++++++++----------
v5/src/app/api/mcp/route.ts | 312 +------------
v5/src/components/ChatFab.tsx | 206 ++++++++-
v5/src/components/ChatLauncherContext.tsx | 5 +-
v5/src/components/IdentificationCard.tsx | 252 ++++++++++
v5/src/components/PrimaryNav.tsx | 8 +
v5/src/lib/capabilities/catalog.ts | 260 +++++++++++
v5/src/lib/capabilities/chat-adapter.ts | 236 ++++++++++
v5/src/lib/capabilities/helpers.ts | 129 ++++++
v5/src/lib/capabilities/index.ts | 54 +++
v5/src/lib/capabilities/intake.ts | 532 ++++++++++++++++++++++
v5/src/lib/capabilities/maintenance.ts | 125 +++++
v5/src/lib/capabilities/mcp-adapter.ts | 79 ++++
v5/src/lib/capabilities/types.ts | 266 +++++++++++
v5/src/lib/capabilities/units.ts | 169 +++++++
v5/src/lib/notion.ts | 189 ++++++++
v5/src/lib/types.ts | 8 +
v5/src/styles/globals.css | 340 ++++++++++++++
30 files changed, 3182 insertions(+), 590 deletions(-)
create mode 100644 v5/src/components/IdentificationCard.tsx
create mode 100644 v5/src/lib/capabilities/catalog.ts
create mode 100644 v5/src/lib/capabilities/chat-adapter.ts
create mode 100644 v5/src/lib/capabilities/helpers.ts
create mode 100644 v5/src/lib/capabilities/index.ts
create mode 100644 v5/src/lib/capabilities/intake.ts
create mode 100644 v5/src/lib/capabilities/maintenance.ts
create mode 100644 v5/src/lib/capabilities/mcp-adapter.ts
create mode 100644 v5/src/lib/capabilities/types.ts
create mode 100644 v5/src/lib/capabilities/units.ts
diff --git a/v5/messages/ar.json b/v5/messages/ar.json
index 986ef66..8ba38ec 100644
--- a/v5/messages/ar.json
+++ b/v5/messages/ar.json
@@ -12,7 +12,13 @@
"themeToggleAria": "تبديل سمة الألوان (النظام ← فاتح ← داكن)",
"themeToggleTitle": "تبديل سمة الألوان",
"languageLabel": "اللغة",
- "languageSelectorAria": "اختيار اللغة"
+ "languageSelectorAria": "اختيار اللغة",
+ "report": "إبلاغ",
+ "reportAria": "الإبلاغ عن مشكلة",
+ "reportSeed": "أريد الإبلاغ عن مشكلة.",
+ "add": "إضافة",
+ "addAria": "إضافة معدات جديدة إلى المخزون",
+ "addSeed": "أريد إضافة معدات جديدة إلى المخزون."
},
"status": {
"labStatusLabel": "حالة المختبر",
diff --git a/v5/messages/en.json b/v5/messages/en.json
index 5d4389d..55c9b68 100644
--- a/v5/messages/en.json
+++ b/v5/messages/en.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Cycle color theme (system → light → dark)",
"themeToggleTitle": "Cycle color theme",
"languageLabel": "Language",
- "languageSelectorAria": "Select language"
+ "languageSelectorAria": "Select language",
+ "report": "REPORT",
+ "reportAria": "Report a problem",
+ "reportSeed": "I'd like to report a problem.",
+ "add": "ADD",
+ "addAria": "Add new equipment to the inventory",
+ "addSeed": "I'd like to add new equipment to the inventory."
},
"status": {
"labStatusLabel": "Lab status",
diff --git a/v5/messages/es.json b/v5/messages/es.json
index 22a7c67..25edace 100644
--- a/v5/messages/es.json
+++ b/v5/messages/es.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Alternar tema de color (sistema → claro → oscuro)",
"themeToggleTitle": "Alternar tema de color",
"languageLabel": "Idioma",
- "languageSelectorAria": "Seleccionar idioma"
+ "languageSelectorAria": "Seleccionar idioma",
+ "report": "REPORTAR",
+ "reportAria": "Reportar un problema",
+ "reportSeed": "Quiero reportar un problema.",
+ "add": "AGREGAR",
+ "addAria": "Agregar nuevo equipo al inventario",
+ "addSeed": "Quiero agregar nuevo equipo al inventario."
},
"status": {
"labStatusLabel": "Estado del laboratorio",
diff --git a/v5/messages/fr.json b/v5/messages/fr.json
index 0780a96..058e19e 100644
--- a/v5/messages/fr.json
+++ b/v5/messages/fr.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Changer le thème de couleur (système → clair → sombre)",
"themeToggleTitle": "Changer le thème de couleur",
"languageLabel": "Langue",
- "languageSelectorAria": "Choisir la langue"
+ "languageSelectorAria": "Choisir la langue",
+ "report": "SIGNALER",
+ "reportAria": "Signaler un problème",
+ "reportSeed": "Je voudrais signaler un problème.",
+ "add": "AJOUTER",
+ "addAria": "Ajouter un nouvel équipement à l'inventaire",
+ "addSeed": "Je voudrais ajouter un nouvel équipement à l'inventaire."
},
"status": {
"labStatusLabel": "État du labo",
diff --git a/v5/messages/he.json b/v5/messages/he.json
index f126d43..fa15e19 100644
--- a/v5/messages/he.json
+++ b/v5/messages/he.json
@@ -12,7 +12,13 @@
"themeToggleAria": "החלפת ערכת צבעים (מערכת ← בהיר ← כהה)",
"themeToggleTitle": "החלפת ערכת צבעים",
"languageLabel": "שפה",
- "languageSelectorAria": "בחירת שפה"
+ "languageSelectorAria": "בחירת שפה",
+ "report": "דיווח",
+ "reportAria": "דיווח על תקלה",
+ "reportSeed": "אני רוצה לדווח על תקלה.",
+ "add": "הוספה",
+ "addAria": "הוספת ציוד חדש למלאי",
+ "addSeed": "אני רוצה להוסיף ציוד חדש למלאי."
},
"status": {
"labStatusLabel": "מצב המעבדה",
diff --git a/v5/messages/hi.json b/v5/messages/hi.json
index a9cb2a6..8279a94 100644
--- a/v5/messages/hi.json
+++ b/v5/messages/hi.json
@@ -12,7 +12,13 @@
"themeToggleAria": "रंग थीम बदलें (सिस्टम → हल्का → गहरा)",
"themeToggleTitle": "रंग थीम बदलें",
"languageLabel": "भाषा",
- "languageSelectorAria": "भाषा चुनें"
+ "languageSelectorAria": "भाषा चुनें",
+ "report": "रिपोर्ट करें",
+ "reportAria": "समस्या की रिपोर्ट करें",
+ "reportSeed": "मैं एक समस्या की रिपोर्ट करना चाहता हूँ।",
+ "add": "जोड़ें",
+ "addAria": "इन्वेंट्री में नया उपकरण जोड़ें",
+ "addSeed": "मैं इन्वेंट्री में नया उपकरण जोड़ना चाहता हूँ।"
},
"status": {
"labStatusLabel": "लैब की स्थिति",
diff --git a/v5/messages/ja.json b/v5/messages/ja.json
index 53c8a2f..da08be1 100644
--- a/v5/messages/ja.json
+++ b/v5/messages/ja.json
@@ -12,7 +12,13 @@
"themeToggleAria": "カラーテーマを切り替え(システム → ライト → ダーク)",
"themeToggleTitle": "カラーテーマを切り替え",
"languageLabel": "言語",
- "languageSelectorAria": "言語を選択"
+ "languageSelectorAria": "言語を選択",
+ "report": "報告",
+ "reportAria": "問題を報告",
+ "reportSeed": "問題を報告したいです。",
+ "add": "追加",
+ "addAria": "在庫に新しい機材を追加",
+ "addSeed": "在庫に新しい機材を追加したいです。"
},
"status": {
"labStatusLabel": "ラボの状況",
diff --git a/v5/messages/ko.json b/v5/messages/ko.json
index 8635c90..4e4e976 100644
--- a/v5/messages/ko.json
+++ b/v5/messages/ko.json
@@ -12,7 +12,13 @@
"themeToggleAria": "색상 테마 전환 (시스템 → 라이트 → 다크)",
"themeToggleTitle": "색상 테마 전환",
"languageLabel": "언어",
- "languageSelectorAria": "언어 선택"
+ "languageSelectorAria": "언어 선택",
+ "report": "신고",
+ "reportAria": "문제 신고",
+ "reportSeed": "문제를 신고하고 싶어요.",
+ "add": "추가",
+ "addAria": "재고에 새 장비 추가",
+ "addSeed": "재고에 새 장비를 추가하고 싶어요."
},
"status": {
"labStatusLabel": "랩 상태",
diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json
index ce91fb8..b315a32 100644
--- a/v5/messages/pt-BR.json
+++ b/v5/messages/pt-BR.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Alternar tema de cores (sistema → claro → escuro)",
"themeToggleTitle": "Alternar tema de cores",
"languageLabel": "Idioma",
- "languageSelectorAria": "Selecionar idioma"
+ "languageSelectorAria": "Selecionar idioma",
+ "report": "RELATAR",
+ "reportAria": "Relatar um problema",
+ "reportSeed": "Quero relatar um problema.",
+ "add": "ADICIONAR",
+ "addAria": "Adicionar novo equipamento ao inventário",
+ "addSeed": "Quero adicionar novo equipamento ao inventário."
},
"status": {
"labStatusLabel": "Status do laboratório",
diff --git a/v5/messages/ru.json b/v5/messages/ru.json
index f36aaca..bba969a 100644
--- a/v5/messages/ru.json
+++ b/v5/messages/ru.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Переключить цветовую тему (система → светлая → тёмная)",
"themeToggleTitle": "Переключить цветовую тему",
"languageLabel": "Язык",
- "languageSelectorAria": "Выбрать язык"
+ "languageSelectorAria": "Выбрать язык",
+ "report": "СООБЩИТЬ",
+ "reportAria": "Сообщить о проблеме",
+ "reportSeed": "Я хочу сообщить о проблеме.",
+ "add": "ДОБАВИТЬ",
+ "addAria": "Добавить новое оборудование в инвентарь",
+ "addSeed": "Я хочу добавить новое оборудование в инвентарь."
},
"status": {
"labStatusLabel": "Статус лаборатории",
diff --git a/v5/messages/tr.json b/v5/messages/tr.json
index 657765e..c10978f 100644
--- a/v5/messages/tr.json
+++ b/v5/messages/tr.json
@@ -12,7 +12,13 @@
"themeToggleAria": "Renk temasını değiştir (sistem → açık → koyu)",
"themeToggleTitle": "Renk temasını değiştir",
"languageLabel": "Dil",
- "languageSelectorAria": "Dil seç"
+ "languageSelectorAria": "Dil seç",
+ "report": "BİLDİR",
+ "reportAria": "Sorun bildir",
+ "reportSeed": "Bir sorun bildirmek istiyorum.",
+ "add": "EKLE",
+ "addAria": "Envantere yeni ekipman ekle",
+ "addSeed": "Envantere yeni ekipman eklemek istiyorum."
},
"status": {
"labStatusLabel": "Laboratuvar durumu",
diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json
index 79c087a..278fe20 100644
--- a/v5/messages/zh-CN.json
+++ b/v5/messages/zh-CN.json
@@ -12,7 +12,13 @@
"themeToggleAria": "切换配色主题(系统 → 浅色 → 深色)",
"themeToggleTitle": "切换配色主题",
"languageLabel": "语言",
- "languageSelectorAria": "选择语言"
+ "languageSelectorAria": "选择语言",
+ "report": "报告",
+ "reportAria": "报告问题",
+ "reportSeed": "我想报告一个问题。",
+ "add": "添加",
+ "addAria": "向库存添加新设备",
+ "addSeed": "我想向库存添加新设备。"
},
"status": {
"labStatusLabel": "实验室状态",
diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts
index b49ac11..e5f6d68 100644
--- a/v5/src/app/api/chat/route.ts
+++ b/v5/src/app/api/chat/route.ts
@@ -5,25 +5,23 @@ import {
createUIMessageStreamResponse,
stepCountIs,
streamText,
- tool,
type FilePart,
+ type ImagePart,
type ModelMessage,
type TextPart,
type UIMessage,
type UserModelMessage,
} from "ai";
-import { z } from "zod";
import { getCatalogTool, getCatalogTools } from "../../../lib/catalog";
-import {
- createMaintenanceLog,
- fetchAllResources,
- fetchMaintenanceLogsByUnit,
-} from "../../../lib/notion";
-import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types";
+import { fetchAllResources } from "../../../lib/notion";
+import type { MakerLabTool } from "../../../components/catalog-types";
import type { ResourceRecord } from "../../../lib/types";
-import { languageNameForLocale } from "../../../i18n/config";
import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit";
-import { siteConfig } from "../../../lib/site-config";
+import { CAPABILITIES, composeChat } from "../../../lib/capabilities";
+import type {
+ CapabilityCtx,
+ UploadedImage,
+} from "../../../lib/capabilities";
const MAX_PDFS_PER_CHAT = 3;
const MAX_PDF_BYTES = 10 * 1024 * 1024; // 10MB ceiling
@@ -44,8 +42,6 @@ interface ChatRequest {
locale?: string;
}
-const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const;
-
export async function POST(req: Request) {
// Rate limit before any expensive work (Notion fetch / model call).
const ip = getClientIp(req);
@@ -63,7 +59,6 @@ export async function POST(req: Request) {
const { messages, toolId, locale }: ChatRequest = await req.json();
const tools = await getCatalogTools();
const focused = toolId ? await getCatalogTool(toolId) : null;
- const unitLookup = buildUnitLookup(tools);
const { manuals, skipped } = focused
? await collectToolManuals(focused.id)
: { manuals: [], skipped: 0 };
@@ -78,11 +73,17 @@ export async function POST(req: Request) {
console.info(`[chat] manuals attached: ${manuals.length}`);
console.info(`[chat] manuals skipped (will web_fetch): ${skipped}`);
}
- const system = buildSystemPrompt(tools, focused, manuals, locale);
- const modelMessages = attachManualsToFirstUserMessage(
- await convertToModelMessages(messages),
- manuals
- );
+
+ // Convert the UI messages, attach any server-fetched manuals, and surface the
+ // uploaded photos for this turn both to the model (image bytes — design spec
+ // §6.1) and to the capability layer (file_upload ids the intake `create_tool`
+ // re-uses to attach the same photo to the new Notion page).
+ const baseMessages = await convertToModelMessages(messages);
+ const attachments = collectAttachments(baseMessages);
+ if (attachments.length > 0) {
+ console.info(`[chat] attachments for this turn: ${attachments.length}`);
+ }
+ const modelMessages = attachManualsToFirstUserMessage(baseMessages, manuals);
const stream = createUIMessageStream({
execute: ({ writer }) => {
@@ -94,143 +95,48 @@ export async function POST(req: Request) {
});
}
+ // Build the capability context for this turn. The chat surface populates
+ // every field; capabilities degrade gracefully when one is absent.
+ const ctx: CapabilityCtx = {
+ writer,
+ attachments,
+ locale,
+ focusedToolId: focused?.id,
+ };
+
+ // Compose the system prompt + capability tools from the shared registry,
+ // then add the provider-native research tools (Anthropic web tools are not
+ // capabilities — the intake capability's prompt tells the agent to use
+ // them). web_fetch keeps the focused-tool domain allow-list.
+ const { tools: capabilityTools, system } = composeChat(CAPABILITIES, ctx, {
+ tools,
+ focusedTool: focused,
+ locale,
+ });
+
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
- system,
+ system: appendManualSections(system, focused, manuals),
messages: modelMessages,
- tools: {
- get_unit_details: tool({
- description:
- "Fetch details for a specific physical unit, including its status, condition, and recent maintenance history. Use when the student names or asks about a specific unit (e.g. 'Prusa #1', 'Form 2 #2') or reports an issue tied to one.",
- inputSchema: z.object({
- unit_label: z
- .string()
- .describe("The unit label, e.g. 'Prusa #1' or 'Form 2 #1'."),
- }),
- execute: async ({ unit_label }) => {
- const match = findUnit(unitLookup, unit_label);
- if (!match) {
- const sample = unitLookup
- .slice(0, 8)
- .map((u) => u.label)
- .join(", ");
- return {
- found: false,
- message: `No unit found matching "${unit_label}". Some known units: ${sample}${unitLookup.length > 8 ? "…" : ""}`,
- };
- }
-
- const logs = await fetchMaintenanceLogsByUnit(match.id).catch(
- () => []
- );
-
- return {
- found: true,
- unit_id: match.id,
- unit_label: match.label,
- tool_name: match.toolName,
- tool_slug: match.toolSlug,
- status: match.status,
- condition: match.condition,
- location: match.location,
- serial: match.serial,
- date_acquired: match.dateAcquired,
- detail_page: `/tools/${match.toolSlug}`,
- maintenance_logs: logs.slice(0, 10).map((log) => ({
- title: log.fields.title,
- type: log.fields.type || "",
- priority: log.fields.priority || "",
- status: log.fields.status || "",
- date_reported: log.fields.date_reported || "",
- description: log.fields.description || "",
- })),
- };
+ tools: {
+ ...capabilityTools,
+ web_search: anthropic.tools.webSearch_20250305({
+ maxUses: 5,
+ }),
+ web_fetch: anthropic.tools.webFetch_20250910({
+ maxUses: 5,
+ maxContentTokens: 20000,
+ citations: { enabled: true },
+ ...(focused
+ ? (() => {
+ const hosts = uniqueHosts(focused.links.map((l) => l.href));
+ return hosts.length ? { allowedDomains: hosts } : {};
+ })()
+ : {}),
+ }),
},
- }),
- report_issue: tool({
- description:
- "File a maintenance ticket in Notion when a student reports a problem with a tool or unit. Gather a short title and a clear description first. If they named a specific unit (like 'Prusa #1'), include it so the log is linked. Ask for the reporter's name if they haven't given it.",
- inputSchema: z.object({
- title: z.string().describe("Short summary of the issue"),
- description: z
- .string()
- .describe("Full description of what's wrong"),
- unit_label: z
- .string()
- .optional()
- .describe("Unit label if the issue is tied to a specific unit"),
- priority: z
- .enum(PRIORITIES)
- .default("Medium")
- .describe(
- "Critical = unsafe / lab-blocking, High = unusable, Medium = degraded, Low = cosmetic"
- ),
- reported_by: z
- .string()
- .optional()
- .describe("Student name or NetID if provided"),
- photo_uploads: z
- .array(
- z.object({
- id: z.string().describe("Notion file_upload_id"),
- name: z.string().describe("Original filename"),
- })
- )
- .optional()
- .describe(
- "Notion file_upload references. Parse these from the [Attached photos: file_upload_id=... name=...] hint in the student's message."
- ),
- }),
- execute: async ({
- title,
- description,
- unit_label,
- priority,
- reported_by,
- photo_uploads,
- }) => {
- const match = unit_label ? findUnit(unitLookup, unit_label) : null;
- try {
- const record = await createMaintenanceLog({
- title,
- description,
- type: "Issue Report",
- priority,
- status: "Open",
- reported_by: reported_by || undefined,
- unit: match ? [match.id] : undefined,
- date_reported: new Date().toISOString().split("T")[0],
- photo_uploads: photo_uploads?.length ? photo_uploads : undefined,
- });
- return {
- success: true,
- ticket_id: record.id,
- unit_resolved: match
- ? { id: match.id, label: match.label }
- : null,
- message: `Logged maintenance ticket ${record.id}.`,
- };
- } catch (err) {
- const message =
- err instanceof Error ? err.message : "Failed to file ticket";
- return { success: false, error: message };
- }
- },
- }),
- web_fetch: anthropic.tools.webFetch_20250910({
- maxUses: 5,
- maxContentTokens: 20000,
- citations: { enabled: true },
- ...(focused
- ? (() => {
- const hosts = uniqueHosts(focused.links.map((l) => l.href));
- return hosts.length ? { allowedDomains: hosts } : {};
- })()
- : {}),
- }),
- },
- stopWhen: stepCountIs(5),
- });
+ stopWhen: stepCountIs(10),
+ });
writer.merge(result.toUIMessageStream());
},
@@ -239,47 +145,150 @@ export async function POST(req: Request) {
return createUIMessageStreamResponse({ stream });
}
-interface UnitLookupEntry {
- id: string;
- label: string;
- toolName: string;
- toolSlug: string;
- status: MakerLabUnit["status"];
- condition: MakerLabUnit["condition"];
- location: string;
- serial: string;
- dateAcquired: string | null;
+// ── Attachments / vision (design spec §6.1) ────────────────────────
+
+/**
+ * Reconstruct the uploaded photos for this turn into {@link UploadedImage}s.
+ *
+ * The chat client uploads each photo to Notion and appends a text hint
+ * (`[Attached photos: file_upload_id= name=; ...]`) to the user
+ * message; for vision it also includes the image bytes as image/file parts so
+ * Claude can see them. We pair the hint entries (which carry the durable Notion
+ * `file_upload_id` the intake `create_tool` re-uses) with the inline image bytes
+ * (the `dataUrl` the model sees) from the latest user message, in order.
+ */
+function collectAttachments(messages: ModelMessage[]): UploadedImage[] {
+ const lastUser = [...messages]
+ .reverse()
+ .find((m): m is UserModelMessage => m.role === "user");
+ if (!lastUser) return [];
+
+ const text = userMessageText(lastUser);
+ const hints = parsePhotoHints(text);
+ const images = userMessageImages(lastUser);
+
+ if (hints.length === 0 && images.length === 0) return [];
+
+ // Pair hints (file_upload_id + name) with inline image bytes by position. Some
+ // entries may have only one side: a hint without bytes still feeds the intake
+ // layer; bytes without a hint still let the model see the photo.
+ const count = Math.max(hints.length, images.length);
+ const attachments: UploadedImage[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const hint = hints[i];
+ const image = images[i];
+ attachments.push({
+ file_upload_id: hint?.file_upload_id ?? "",
+ name: hint?.name ?? image?.name ?? `photo-${i + 1}`,
+ contentType: image?.contentType ?? "image/jpeg",
+ dataUrl: image?.dataUrl,
+ });
+ }
+ return attachments;
+}
+
+/** Flatten a user message's text parts into a single string. */
+function userMessageText(message: UserModelMessage): string {
+ const content = message.content;
+ if (typeof content === "string") return content;
+ return content
+ .filter((p): p is TextPart => p.type === "text")
+ .map((p) => p.text)
+ .join("\n");
}
-function buildUnitLookup(tools: MakerLabTool[]): UnitLookupEntry[] {
- return tools.flatMap((tool) =>
- tool.units.map((unit) => ({
- id: unit.id,
- label: unit.name,
- toolName: tool.name,
- toolSlug: tool.slug,
- status: unit.status,
- condition: unit.condition,
- location: unit.location,
- serial: unit.serial,
- dateAcquired: unit.dateAcquired,
- }))
- );
+interface InlineImage {
+ name?: string;
+ contentType: string;
+ dataUrl: string;
}
-function findUnit(
- units: UnitLookupEntry[],
- label: string
-): UnitLookupEntry | null {
- const needle = label.trim().toLowerCase();
- if (!needle) return null;
- return (
- units.find((u) => u.label.toLowerCase() === needle) ||
- units.find((u) => u.label.toLowerCase().includes(needle)) ||
- null
- );
+/**
+ * Extract inline image bytes from a user message as data URLs the model can see.
+ * Handles both image parts and file parts with an `image/*` media type.
+ */
+function userMessageImages(message: UserModelMessage): InlineImage[] {
+ const content = message.content;
+ if (typeof content === "string") return [];
+ const images: InlineImage[] = [];
+ for (const part of content) {
+ if (part.type === "image") {
+ const dataUrl = imageDataToUrl(
+ (part as ImagePart).image,
+ (part as ImagePart).mediaType
+ );
+ if (dataUrl) {
+ images.push({
+ contentType: (part as ImagePart).mediaType ?? "image/jpeg",
+ dataUrl,
+ });
+ }
+ } else if (part.type === "file") {
+ const file = part as FilePart;
+ // mediaType may be a full IANA type ("image/png") or the top-level
+ // segment ("image") — accept both.
+ if (typeof file.mediaType === "string" && file.mediaType.startsWith("image")) {
+ const mime = file.mediaType.includes("/") ? file.mediaType : "image/jpeg";
+ const dataUrl = imageDataToUrl(file.data, mime);
+ if (dataUrl) {
+ images.push({
+ name: file.filename,
+ contentType: mime,
+ dataUrl,
+ });
+ }
+ }
+ }
+ }
+ return images;
+}
+
+/** Normalize AI SDK image/file data (URL | data URL | base64 | bytes) to a data URL. */
+function imageDataToUrl(
+ data: unknown,
+ mediaType: string | undefined
+): string | undefined {
+ const mime = mediaType || "image/jpeg";
+ if (data instanceof URL) return data.toString();
+ if (typeof data === "string") {
+ if (data.startsWith("http://") || data.startsWith("https://")) return data;
+ if (data.startsWith("data:")) return data;
+ return `data:${mime};base64,${data}`;
+ }
+ if (data instanceof Uint8Array) {
+ return `data:${mime};base64,${Buffer.from(data).toString("base64")}`;
+ }
+ if (data instanceof ArrayBuffer) {
+ return `data:${mime};base64,${Buffer.from(data).toString("base64")}`;
+ }
+ return undefined;
+}
+
+const PHOTO_HINT_RE = /\[Attached photos:\s*([^\]]+)\]/i;
+
+interface PhotoHint {
+ file_upload_id: string;
+ name: string;
}
+/** Parse the `[Attached photos: file_upload_id=… name=…; …]` hint into entries. */
+function parsePhotoHints(text: string): PhotoHint[] {
+ const block = text.match(PHOTO_HINT_RE);
+ if (!block) return [];
+ return block[1]
+ .split(";")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const id = entry.match(/file_upload_id=(\S+)/)?.[1] ?? "";
+ const name = entry.match(/name=([^;]+?)\s*$/)?.[1]?.trim() ?? "";
+ return { file_upload_id: id, name };
+ })
+ .filter((hint) => hint.file_upload_id || hint.name);
+}
+
+// ── Helpers (focused tool / manuals) ───────────────────────────────
+
function uniqueHosts(urls: string[]): string[] {
const set = new Set();
for (const u of urls) {
@@ -300,7 +309,9 @@ function isPdfUrl(url: string | undefined): boolean {
function pickPdfUrl(resource: ResourceRecord): string | null {
if (isPdfUrl(resource.fields.url)) return resource.fields.url ?? null;
- const file = (resource.fields.files || []).find((f) => isPdfUrl(f.filename) || isPdfUrl(f.url));
+ const file = (resource.fields.files || []).find(
+ (f) => isPdfUrl(f.filename) || isPdfUrl(f.url)
+ );
return file?.url || null;
}
@@ -366,12 +377,16 @@ async function collectToolManuals(
const url = pickPdfUrl(r);
if (!url) {
if (r.fields.url) {
- console.info(`[chat] skipping non-PDF resource: ${r.fields.title} (${r.fields.url})`);
+ console.info(
+ `[chat] skipping non-PDF resource: ${r.fields.title} (${r.fields.url})`
+ );
}
continue;
}
if (manuals.length >= MAX_PDFS_PER_CHAT) {
- console.info(`[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.fields.title}`);
+ console.info(
+ `[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.fields.title}`
+ );
continue;
}
const title = r.fields.title || "Manual";
@@ -412,115 +427,56 @@ function attachManualsToFirstUserMessage(
const target = messages[firstUserIdx] as UserModelMessage;
const existing = target.content;
- const existingParts: (TextPart | FilePart)[] = Array.isArray(existing)
- ? (existing.filter((p) => p.type === "text" || p.type === "file") as (TextPart | FilePart)[])
+ const existingParts: (TextPart | FilePart | ImagePart)[] = Array.isArray(existing)
+ ? (existing.filter(
+ (p) => p.type === "text" || p.type === "file" || p.type === "image"
+ ) as (TextPart | FilePart | ImagePart)[])
: [{ type: "text", text: String(existing ?? "") }];
const updated: UserModelMessage = {
role: "user",
content: [...fileParts, ...existingParts],
};
- return [...messages.slice(0, firstUserIdx), updated, ...messages.slice(firstUserIdx + 1)];
+ return [
+ ...messages.slice(0, firstUserIdx),
+ updated,
+ ...messages.slice(firstUserIdx + 1),
+ ];
}
-function buildSystemPrompt(
- tools: MakerLabTool[],
+/**
+ * Append the per-request manual sections to the composed system prompt. These
+ * depend on the server-side PDF fetch for the focused tool, so they live in the
+ * route rather than the surface-agnostic chat adapter (which only knows the
+ * catalog/focused-tool/locale env). When no manuals were attached this is a
+ * no-op and the adapter's prompt is returned unchanged.
+ */
+function appendManualSections(
+ system: string,
focused: MakerLabTool | null,
- manuals: AttachedManual[] = [],
- locale?: string
+ manuals: AttachedManual[]
): string {
- const sections: string[] = [
- `You are the ${siteConfig.chatAssistantName} — a friendly, knowledgeable helper for ${siteConfig.audience} using the ${siteConfig.institution} MakerLab. Answer questions about lab tools, training requirements, safety, materials, and which machines are right for a given project. Be concise, accurate, and grounded only in the catalog provided below. If the user asks about a tool that isn't in the catalog, say so honestly.`,
- `## Linking tools\n\nWhenever you mention a tool that exists in the catalog below, **format its name as a markdown link** to its detail page using the slug provided in the catalog: \`[Tool Name](/tools/)\`. This lets the student jump straight to the tool's page. Examples:\n- "You could use the [Bambu Lab X1-Carbon Combo 3D Printer](/tools/) for that."\n- "For laser cutting acrylic, check the [Epilog Helix 24](/tools/)."\n\nDo **not** link the tool the student is already viewing (see Active tool context). Do not invent slugs — only use slugs from the catalog list.`,
- `## Reporting maintenance issues\n\nYou are a first-line helper, not a ticket-creation machine. Follow this order:\n\n1. **Diagnose conversationally first.** When a student describes a problem, ask a clarifying question or two and walk them through quick fixes they can likely do themselves — swap the filament, re-level the bed, clear a jam, restart the slicer, replace a worn bit, check the e-stop, power-cycle, reseat cables, re-home axes, etc. Start with the simplest plausible fix and escalate from there.\n2. **Recognize when to escalate.** Move toward filing a ticket if: the issue is unsafe, the tool clearly needs staff intervention, the student says they can't fix it, the problem keeps recurring, or the student explicitly asks to log it.\n3. **Proactively offer to log.** Even after a successful self-fix for things staff should know about (jams, low filament, missing parts, anything that affects the next user), gently offer: "Want me to log a quick note so staff knows this happened?" Don't push — just offer.\n4. **Gather details and file.** Once the student agrees (or asks directly), collect: a short title, a clear description of what's wrong and what's already been tried, the affected unit if any, a priority, and the student's name/NetID. If they named a specific unit you don't recognize, call \`get_unit_details\` first to verify it exists. Then call \`report_issue\`. After it succeeds, tell the student the ticket was filed and include the ticket ID. If they only name a tool (not a specific unit), it's fine to file without one — but ask first if they can tell you which unit.\n\nIf the student's message includes a hint like \`[Attached photos: file_upload_id= name=; ...]\`, parse each \`file_upload_id\` and \`name\` pair and pass them as the \`photo_uploads\` argument to \`report_issue\` (do not echo the raw hint back to the student). The IDs are already uploaded to Notion and will be attached to the ticket.\n\nPriority guide: Critical = unsafe or blocks all lab use · High = tool unusable · Medium = degraded performance · Low = cosmetic.`,
- `## Unit details\n\nWhen a student asks about a specific unit ("how is Prusa #1 doing?", "is Form 2 #2 working?", "show me the history on the Trotec"), call \`get_unit_details\` to fetch its live status and recent maintenance history. Surface the status, condition, and a short recap of the most recent log entries.`,
- ];
-
- if (locale && locale !== "en") {
- const language = languageNameForLocale(locale);
- sections.push(
- `## Response language\n\nRespond to the student in **${language}**, regardless of the language they write in. Translate your explanations and conversational text into ${language}. However, ALWAYS keep the following in English so MakerLab staff can read them: tool and equipment names (use the exact catalog names), unit labels (e.g. "Prusa #1"), and — critically — the \`title\` and \`description\` you pass to the \`report_issue\` tool when filing a maintenance ticket. Maintenance ticket content must be written in English even though you reply to the student in ${language}.`
- );
- }
+ if (manuals.length === 0) return system;
- if (focused) {
- sections.push(
- `## Active tool context\n\nThe student is currently viewing the **${focused.name}** detail page in the MakerLab catalog. If they use pronouns like "this", "it", "that tool", or "the machine", or ask things like "how do I use it" / "what can I make with this" without naming a tool, assume they are asking about the ${focused.name}. Use the resource links below when relevant — point to the SOP, safety doc, or manual when the student asks how to use, set up, or troubleshoot the tool. Do not wrap "${focused.name}" itself in a tool link — the student is already on its page.\n\n${describeTool(focused)}`
- );
- }
+ const sections: string[] = [system];
- if (manuals.length > 0) {
- const list = manuals
- .map((m) => `- **${m.title}** — ${m.url}`)
- .join("\n");
- sections.push(
- `## Available manuals\n\nThe following PDF manuals are attached to this conversation as documents — Claude can read both their text and figures directly:\n\n${list}`
- );
- }
+ const list = manuals.map((m) => `- **${m.title}** — ${m.url}`).join("\n");
+ sections.push(
+ `## Available manuals\n\nThe following PDF manuals are attached to this conversation as documents — Claude can read both their text and figures directly:\n\n${list}`
+ );
if (focused && focused.links.length > 0) {
const attachedUrls = new Set(manuals.map((m) => m.url));
- const list = focused.links
+ const annotated = focused.links
.map((link) => {
const tag = attachedUrls.has(link.href) ? " (attached)" : "";
return `- [${link.kind || "Resource"}] ${link.label} — ${link.href}${tag}`;
})
.join("\n");
sections.push(
- `## Resources for this tool\n\nThe following resources are linked from the **${focused.name}** Notion page. Items marked "(attached)" are already inlined above as PDF documents — read them directly. Anything else can be retrieved with the \`web_fetch\` tool.\n\n${list}`
+ `## Attached manuals vs. fetchable resources\n\nItems marked "(attached)" below are already inlined above as PDF documents — read them directly instead of calling \`web_fetch\`. If an attached PDF was expected to answer the question but you can't actually read it (rare — usually means Anthropic's fetch was blocked by the host), call \`web_fetch\` on the same URL as a fallback.\n\n${annotated}`
);
}
- sections.push(
- `## Fetching resources\n\nUse the \`web_fetch\` tool to read any URL from the "Resources for this tool" list that isn't already attached — HTML SOPs, safety pages, manufacturer guides, etc. Rules:\n\n- Prefer attached PDFs when they exist; do not call \`web_fetch\` on a URL already marked "(attached)".\n- If an attached PDF was expected to answer the question but you can't actually read it (rare — usually means Anthropic's fetch was blocked by the host), call \`web_fetch\` on the same URL as a fallback.\n- Only call \`web_fetch\` on exact URLs that appear in "Resources for this tool". Do not invent URLs or fetch general web pages the student wasn't routed to.`
- );
-
- sections.push(
- `## Citing sources\n\nWhen you draw on an attached manual or a \`web_fetch\`ed page, cite the source inline as a **markdown link** using the exact URL from the lists above. Two formats:\n\n1. PDF with a known page: \`[Form 4 Manual, p. 14](https://media.formlabs.com/.../-ENUS-Form-4-Manual.pdf#page=14)\` — append \`#page=N\` so browser PDF viewers jump to the page.\n2. HTML page or PDF with no known page: \`[Trotec Speedy 400 SOP](https://...)\`.\n\nDo not invent page numbers or URLs. Always use exact URLs from the lists above.`
- );
-
- sections.push(`## MakerLab catalog (${tools.length} tools)`);
- sections.push(
- tools
- .map((tool) => {
- const head = `- **${tool.name}** — slug: \`${tool.slug}\` — ${tool.category}${tool.categorySub ? ` / ${tool.categorySub}` : ""} · ${tool.location}${tool.zone ? ` / ${tool.zone}` : ""} · ${tool.trainingLevel}`;
- if (!tool.units.length) return head;
- const units = tool.units
- .map((unit) => `${unit.name} [${unit.status}]`)
- .join(", ");
- return `${head}\n units: ${units}`;
- })
- .join("\n")
- );
-
return sections.join("\n\n");
}
-
-function describeTool(tool: MakerLabTool): string {
- const lines: string[] = [
- `**${tool.name}**`,
- `- Category: ${tool.category}${tool.categorySub ? ` / ${tool.categorySub}` : ""}`,
- `- Location: ${tool.location}${tool.zone ? ` / ${tool.zone}` : ""}`,
- `- Training: ${tool.trainingLabel} (level: ${tool.trainingLevel})`,
- ];
- if (tool.materials.length) lines.push(`- Materials: ${tool.materials.join(", ")}`);
- if (tool.ppe.length) lines.push(`- PPE: ${tool.ppe.join(", ")}`);
- if (tool.useRestrictions) lines.push(`- Restrictions: ${tool.useRestrictions}`);
- if (tool.emergencyStop) lines.push(`- Emergency stop: ${tool.emergencyStop}`);
- if (tool.description) lines.push(`- Description: ${tool.description}`);
- if (tool.units.length) {
- lines.push("- Units:");
- for (const unit of tool.units) {
- lines.push(
- ` - ${unit.name} — status: ${unit.status}, condition: ${unit.condition}${unit.serial && unit.serial !== "Unlisted" ? `, serial: ${unit.serial}` : ""}`
- );
- }
- }
- if (tool.links.length) {
- lines.push("- Resources:");
- for (const link of tool.links) {
- lines.push(` - ${link.kind || "Resource"}: ${link.label} — ${link.href}`);
- }
- }
- return lines.join("\n");
-}
diff --git a/v5/src/app/api/mcp/route.ts b/v5/src/app/api/mcp/route.ts
index cd3421c..b8bdff9 100644
--- a/v5/src/app/api/mcp/route.ts
+++ b/v5/src/app/api/mcp/route.ts
@@ -1,302 +1,25 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
-import { z } from "zod";
-import { getCatalogTool, getCatalogTools } from "../../../lib/catalog";
-import { fetchMaintenanceLogsByUnit } from "../../../lib/notion";
-import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types";
+import { CAPABILITIES } from "../../../lib/capabilities";
+import { registerAll } from "../../../lib/capabilities/mcp-adapter";
import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit";
-// ── Helpers ────────────────────────────────────────────────────────
-
-interface UnitLookupEntry {
- id: string;
- label: string;
- toolName: string;
- toolSlug: string;
- status: MakerLabUnit["status"];
- condition: MakerLabUnit["condition"];
- location: string;
- serial: string;
- dateAcquired: string | null;
-}
-
-// Flatten the catalog's units into a lookup so we can resolve a unit by its
-// label the same way the chat route's get_unit_details tool does.
-function buildUnitLookup(tools: MakerLabTool[]): UnitLookupEntry[] {
- return tools.flatMap((tool) =>
- tool.units.map((unit) => ({
- id: unit.id,
- label: unit.name,
- toolName: tool.name,
- toolSlug: tool.slug,
- status: unit.status,
- condition: unit.condition,
- location: unit.location,
- serial: unit.serial,
- dateAcquired: unit.dateAcquired,
- }))
- );
-}
-
-function findUnit(units: UnitLookupEntry[], label: string): UnitLookupEntry | null {
- const needle = label.trim().toLowerCase();
- if (!needle) return null;
- return (
- units.find((u) => u.label.toLowerCase() === needle) ||
- units.find((u) => u.label.toLowerCase().includes(needle)) ||
- null
- );
-}
-
-// Resolve a tool by Notion page id or (partial) name.
-function findTool(tools: MakerLabTool[], idOrName: string): MakerLabTool | null {
- const needle = idOrName.trim().toLowerCase();
- if (!needle) return null;
- return (
- tools.find((t) => t.id.toLowerCase() === needle || t.slug.toLowerCase() === needle) ||
- tools.find((t) => t.name.toLowerCase() === needle) ||
- tools.find((t) => t.name.toLowerCase().includes(needle)) ||
- null
- );
-}
-
-function summarizeTool(tool: MakerLabTool): string {
- return [
- tool.name,
- `id: ${tool.id}`,
- `${tool.category}${tool.categorySub ? ` > ${tool.categorySub}` : ""}`,
- `${tool.location}${tool.zone ? ` / ${tool.zone}` : ""}`,
- `training: ${tool.trainingLevel}`,
- `status: ${tool.status}`,
- ].join(" | ");
-}
-
-function recentMaintenance(unitId: string) {
- return fetchMaintenanceLogsByUnit(unitId)
- .catch(() => [])
- .then((logs) =>
- logs.slice(0, 10).map((log) => ({
- title: log.fields.title,
- type: log.fields.type || "",
- priority: log.fields.priority || "",
- status: log.fields.status || "",
- date_reported: log.fields.date_reported || "",
- description: log.fields.description || "",
- }))
- );
-}
-
// ── Server factory ─────────────────────────────────────────────────
-function createServer(): McpServer {
+/**
+ * Build a fresh MCP server with every capability tool registered. The capability
+ * registry (design spec §3) is the single source of truth for both the chat and
+ * MCP surfaces; the MCP adapter (`registerAll`) wraps each tool's `run()` in an
+ * MCP handler.
+ *
+ * `allowWrites` gates `kind: "write"` tools (e.g. `report_issue`, `create_tool`):
+ * they are exposed over MCP **only when `MCP_TOKEN` is configured** (which
+ * token-gates the whole endpoint). With no token set, the MCP surface is
+ * read-only — write tools are omitted entirely (design spec §3.3 / §8).
+ */
+function createServer(allowWrites: boolean): McpServer {
const server = new McpServer({ name: "makerlab", version: "1.0.0" });
-
- server.registerTool(
- "list_tools",
- {
- description:
- "List all tools in the MakerLab catalog. Returns name, id, category, location, training level, and status. Optionally filter by category or location (partial match).",
- inputSchema: {
- category: z.string().optional().describe("Filter by category (partial match)"),
- location: z.string().optional().describe("Filter by location (partial match)"),
- },
- },
- async ({ category, location }) => {
- let tools = await getCatalogTools();
- if (category) {
- const cat = category.toLowerCase();
- tools = tools.filter(
- (t) =>
- t.category.toLowerCase().includes(cat) ||
- t.categorySub.toLowerCase().includes(cat)
- );
- }
- if (location) {
- const loc = location.toLowerCase();
- tools = tools.filter(
- (t) =>
- t.location.toLowerCase().includes(loc) ||
- t.zone.toLowerCase().includes(loc)
- );
- }
- const lines = tools.map(summarizeTool);
- return {
- content: [
- { type: "text", text: `Found ${tools.length} tools:\n\n${lines.join("\n")}` },
- ],
- };
- }
- );
-
- server.registerTool(
- "search_tools",
- {
- description:
- "Keyword search across tool names, descriptions, materials, and tags. Returns matching tools with a short summary.",
- inputSchema: { query: z.string().describe("Search keyword or phrase") },
- },
- async ({ query }) => {
- const q = query.toLowerCase();
- const tools = await getCatalogTools();
- const results = tools.filter((t) =>
- [t.name, t.description, t.shortDescription, ...t.materials, ...t.tags]
- .join(" ")
- .toLowerCase()
- .includes(q)
- );
- if (results.length === 0) {
- return { content: [{ type: "text", text: `No tools found matching "${query}"` }] };
- }
- const summary = results
- .map(
- (t) =>
- `- ${t.name} (id: ${t.id}): ${t.shortDescription.slice(0, 120)}${t.shortDescription.length > 120 ? "…" : ""}`
- )
- .join("\n");
- return {
- content: [{ type: "text", text: `Found ${results.length} tools:\n\n${summary}` }],
- };
- }
- );
-
- server.registerTool(
- "get_tool_details",
- {
- description:
- "Get full details for a tool by id, slug, or name. Includes description, materials, PPE, training, use restrictions, emergency stop, units, and resource links (SOPs, safety docs, manuals).",
- inputSchema: {
- id_or_name: z.string().describe("Tool id, slug, or name"),
- },
- },
- async ({ id_or_name }) => {
- const needle = id_or_name.trim();
- // Try a direct id/slug lookup first (cheaper, single record), then fall
- // back to a name search across the full catalog.
- let tool = await getCatalogTool(needle);
- if (!tool) {
- const tools = await getCatalogTools();
- tool = findTool(tools, needle);
- }
- if (!tool) {
- return {
- content: [{ type: "text", text: `Tool not found: ${id_or_name}` }],
- isError: true,
- };
- }
-
- const result = {
- id: tool.id,
- slug: tool.slug,
- name: tool.name,
- category: tool.category,
- category_sub: tool.categorySub,
- location: tool.location,
- zone: tool.zone,
- training_level: tool.trainingLevel,
- training_label: tool.trainingLabel,
- status: tool.status,
- description: tool.description,
- materials: tool.materials,
- ppe: tool.ppe,
- tags: tool.tags,
- use_restrictions: tool.useRestrictions,
- emergency_stop: tool.emergencyStop,
- notes: tool.notes,
- links: tool.links,
- units: tool.units,
- detail_page: `/tools/${tool.slug}`,
- };
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
- }
- );
-
- server.registerTool(
- "get_unit_details",
- {
- description:
- "Fetch details for a specific physical unit by its label (e.g. 'Prusa #1', 'Form 2 #2'), including status, condition, location, and recent maintenance history.",
- inputSchema: {
- unit_label: z.string().describe("The unit label, e.g. 'Prusa #1' or 'Form 2 #1'"),
- },
- },
- async ({ unit_label }) => {
- const tools = await getCatalogTools();
- const lookup = buildUnitLookup(tools);
- const match = findUnit(lookup, unit_label);
- if (!match) {
- const sample = lookup
- .slice(0, 8)
- .map((u) => u.label)
- .join(", ");
- return {
- content: [
- {
- type: "text",
- text: `No unit found matching "${unit_label}". Some known units: ${sample}${lookup.length > 8 ? "…" : ""}`,
- },
- ],
- };
- }
-
- const result = {
- unit_id: match.id,
- unit_label: match.label,
- tool_name: match.toolName,
- tool_slug: match.toolSlug,
- status: match.status,
- condition: match.condition,
- location: match.location,
- serial: match.serial,
- date_acquired: match.dateAcquired,
- detail_page: `/tools/${match.toolSlug}`,
- maintenance_logs: await recentMaintenance(match.id),
- };
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
- }
- );
-
- server.registerTool(
- "get_maintenance_history",
- {
- description:
- "Get recent maintenance logs for a unit by its label (e.g. 'Prusa #1'). Returns the most recent entries with type, priority, status, date, and description.",
- inputSchema: {
- unit_label: z.string().describe("The unit label to fetch maintenance history for"),
- },
- },
- async ({ unit_label }) => {
- const tools = await getCatalogTools();
- const lookup = buildUnitLookup(tools);
- const match = findUnit(lookup, unit_label);
- if (!match) {
- return {
- content: [{ type: "text", text: `No unit found matching "${unit_label}".` }],
- isError: true,
- };
- }
- const logs = await recentMaintenance(match.id);
- if (logs.length === 0) {
- return {
- content: [
- { type: "text", text: `No maintenance history for ${match.label}.` },
- ],
- };
- }
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify(
- { unit_label: match.label, unit_id: match.id, maintenance_logs: logs },
- null,
- 2
- ),
- },
- ],
- };
- }
- );
-
+ registerAll(server, CAPABILITIES, { allowWrites });
return server;
}
@@ -341,7 +64,10 @@ async function handler(req: Request): Promise {
);
}
- const server = createServer();
+ // Write capabilities are only exposed when MCP_TOKEN is configured (the
+ // endpoint is then token-gated end to end). Read tools stay available.
+ const allowWrites = Boolean(expectedToken);
+ const server = createServer(allowWrites);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true, // Return JSON instead of SSE — required for serverless
diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx
index c0a62a9..c8c1d37 100644
--- a/v5/src/components/ChatFab.tsx
+++ b/v5/src/components/ChatFab.tsx
@@ -2,13 +2,15 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
+import { IdentificationCard } from "./IdentificationCard";
import { useChatLauncher } from "./ChatLauncherContext";
+import type { CardPayload } from "../lib/capabilities/types";
interface Suggestion {
icon: "search" | "clipboard" | "pin";
@@ -40,7 +42,14 @@ function stripCitations(text: string): string {
function Icon({
name,
}: {
- name: Suggestion["icon"] | "send" | "close" | "newchat" | "paperclip" | "remove";
+ name:
+ | Suggestion["icon"]
+ | "send"
+ | "close"
+ | "newchat"
+ | "paperclip"
+ | "remove"
+ | "mic";
}) {
switch (name) {
case "search":
@@ -97,9 +106,70 @@ function Icon({
);
+ case "mic":
+ return (
+
+
+
+
+
+ );
}
}
+// ── Browser dictation (Web Speech API) ──────────────────────────────
+// Minimal structural types for the SpeechRecognition API. It is not in the
+// standard DOM lib typings and is vendor-prefixed in Chromium (`webkit`). We
+// feature-detect at runtime and hide the mic where it is unavailable, so these
+// types only describe the shape we actually touch.
+interface SpeechRecognitionAlternativeLike {
+ transcript: string;
+}
+interface SpeechRecognitionResultLike {
+ readonly length: number;
+ isFinal: boolean;
+ [index: number]: SpeechRecognitionAlternativeLike;
+}
+interface SpeechRecognitionResultListLike {
+ readonly length: number;
+ [index: number]: SpeechRecognitionResultLike;
+}
+interface SpeechRecognitionEventLike {
+ resultIndex: number;
+ results: SpeechRecognitionResultListLike;
+}
+interface SpeechRecognitionLike {
+ lang: string;
+ continuous: boolean;
+ interimResults: boolean;
+ start(): void;
+ stop(): void;
+ abort(): void;
+ onresult: ((event: SpeechRecognitionEventLike) => void) | null;
+ onerror: (() => void) | null;
+ onend: (() => void) | null;
+}
+type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
+
+function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
+ if (typeof window === "undefined") return null;
+ const w = window as unknown as {
+ SpeechRecognition?: SpeechRecognitionCtor;
+ webkitSpeechRecognition?: SpeechRecognitionCtor;
+ };
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
+}
+
+// Whether browser dictation is available. Read via `useSyncExternalStore` so
+// the server snapshot is always `false` (no mic on the server-rendered HTML),
+// the client snapshot reflects the real API, and React reconciles the
+// difference on hydration without a synchronous setState-in-effect.
+const SPEECH_STORE = {
+ subscribe: () => () => {},
+ getSnapshot: () => getSpeechRecognitionCtor() !== null,
+ getServerSnapshot: () => false,
+};
+
interface PendingPhoto {
key: string;
file_upload_id: string;
@@ -261,6 +331,76 @@ export function ChatFab() {
);
}
+ // ── Dictation (Web Speech API) ──────────────────────────────────
+ // Feature-detect once on mount so the mic button only renders where the API
+ // exists (Chrome/Safari). The active recognition instance is kept in a ref so
+ // the toggle handler can stop it without re-rendering on every interim result.
+ const speechSupported = useSyncExternalStore(
+ SPEECH_STORE.subscribe,
+ SPEECH_STORE.getSnapshot,
+ SPEECH_STORE.getServerSnapshot
+ );
+ const [isListening, setIsListening] = useState(false);
+ const recognitionRef = useRef(null);
+ // Text captured before dictation started, so interim results append rather
+ // than overwrite what the user already typed.
+ const dictationBaseRef = useRef("");
+
+ useEffect(() => {
+ return () => {
+ recognitionRef.current?.abort();
+ recognitionRef.current = null;
+ };
+ }, []);
+
+ function stopDictation() {
+ recognitionRef.current?.stop();
+ }
+
+ function startDictation() {
+ const Ctor = getSpeechRecognitionCtor();
+ if (!Ctor) return;
+ const recognition = new Ctor();
+ recognition.lang = localeRef.current || locale || "en";
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ dictationBaseRef.current = draft;
+
+ recognition.onresult = (event) => {
+ let transcript = "";
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
+ transcript += event.results[i][0]?.transcript ?? "";
+ }
+ const base = dictationBaseRef.current;
+ const next = base ? `${base.replace(/\s+$/, "")} ${transcript}` : transcript;
+ setDraft(next);
+ };
+ recognition.onerror = () => {
+ setIsListening(false);
+ };
+ recognition.onend = () => {
+ recognitionRef.current = null;
+ setIsListening(false);
+ };
+
+ recognitionRef.current = recognition;
+ try {
+ recognition.start();
+ setIsListening(true);
+ } catch {
+ recognitionRef.current = null;
+ setIsListening(false);
+ }
+ }
+
+ function toggleDictation() {
+ if (isListening) {
+ stopDictation();
+ } else {
+ startDictation();
+ }
+ }
+
const scrollRef = useRef(null);
useEffect(() => {
if (scrollRef.current) {
@@ -300,9 +440,9 @@ export function ChatFab() {
}
// Auto-send a seeded message when something outside ChatFab (e.g. the nav
- // "Report" button) opens the chat with an intent. The nonce guard makes this
- // idempotent so a re-render never resends, and we wait until any in-flight
- // turn finishes before sending.
+ // "Report" / "Add equipment" buttons) opens the chat with an intent. The
+ // nonce guard makes this idempotent so a re-render never resends, and we wait
+ // until any in-flight turn finishes before sending.
const lastSeedNonce = useRef(null);
useEffect(() => {
if (!pendingSeed || isLoading) return;
@@ -319,8 +459,18 @@ export function ChatFab() {
send(label);
}
+ // An identification-card button was clicked: seed a follow-up user message
+ // through the existing send path (e.g. "confirm add: "), which the intake
+ // agent resolves into the right tool call.
+ function handleCardAction(seedMessage: string) {
+ if (isLoading || !seedMessage.trim()) return;
+ if (isListening) stopDictation();
+ send(seedMessage);
+ }
+
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
+ if (isListening) stopDictation();
const text = draft.trim();
if (!text || isLoading || uploadingCount > 0) return;
let outgoing = text;
@@ -424,10 +574,29 @@ export function ChatFab() {
p.type.startsWith("tool-") &&
(p as { state?: string }).state !== "output-available"
);
- if (textParts.length === 0 && !pendingTool) return null;
+ // Identification cards arrive as `data-card` parts emitted by
+ // the intake capability's `card()` (design spec §6.3).
+ const cardParts = message.parts.filter(
+ (p): p is typeof p & { data: CardPayload } =>
+ p.type === "data-card" &&
+ (p as { data?: unknown }).data != null
+ );
+ if (
+ textParts.length === 0 &&
+ cardParts.length === 0 &&
+ !pendingTool
+ )
+ return null;
return (
-
- {textParts.length === 0 && pendingTool ? (
+ 0 ? " chat-msg-has-card" : ""
+ }`}
+ >
+ {textParts.length === 0 &&
+ cardParts.length === 0 &&
+ pendingTool ? (
{toolStatusLabel(pendingTool.type, t)}
@@ -446,6 +615,14 @@ export function ChatFab() {
{part.text}
)
)}
+ {cardParts.map((part, index) => (
+
+ ))}
);
})}
@@ -528,6 +705,19 @@ export function ChatFab() {
>
+ {speechSupported ? (
+
+
+
+ ) : null}
(null);
/**
* Owns just the chat launcher's open state and any one-shot seed message, so
- * that entry points outside `ChatFab` (e.g. the nav "Report" button) can open
- * and seed the chat. All conversation/message state stays inside `ChatFab`.
+ * that entry points outside `ChatFab` (e.g. the nav "Report" / "Add equipment"
+ * buttons) can open and seed the chat. All conversation/message state stays
+ * inside `ChatFab`.
*/
export function ChatLauncherProvider({
children,
diff --git a/v5/src/components/IdentificationCard.tsx b/v5/src/components/IdentificationCard.tsx
new file mode 100644
index 0000000..7520bc2
--- /dev/null
+++ b/v5/src/components/IdentificationCard.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import type {
+ CardAction,
+ CardPayload,
+ IdentificationCardPayload,
+} from "../lib/capabilities/types";
+
+/**
+ * Renders an identification card (design spec §4.1, §6.3) emitted by the intake
+ * agent as a `data-card` chat part. Presentational only: it shows the proposed
+ * (or saved) listing — photo, name, category/location, spec lines, found
+ * manuals/videos, the "also creating" taxonomy with `(new)` badges, and the
+ * action buttons. Clicking an action calls {@link onAction} with the button's
+ * `seedMessage`, which the chat wires back into the existing send path so the
+ * agent can resolve it (e.g. `confirm add: `).
+ *
+ * The card itself owns no chat/network state; all behavior flows up through
+ * `onAction`. It switches on the discriminated `kind` so future card types can
+ * extend `CardPayload` without changing the call site.
+ */
+export function IdentificationCard({
+ card,
+ onAction,
+ disabled,
+}: {
+ card: CardPayload;
+ onAction: (seedMessage: string) => void;
+ disabled?: boolean;
+}) {
+ switch (card.kind) {
+ case "identification":
+ return (
+
+ );
+ default:
+ // Exhaustiveness guard: a new CardPayload member must add a case above.
+ return null;
+ }
+}
+
+function CheckIcon() {
+ return (
+
+
+
+ );
+}
+
+function ManualIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function VideoIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function LinkIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function resourceIcon(type: "Manual" | "Video" | "Other") {
+ if (type === "Manual") return ;
+ if (type === "Video") return ;
+ return ;
+}
+
+function actionClass(variant: CardAction["variant"]): string {
+ if (variant === "primary") return "id-card-action id-card-action-primary";
+ if (variant === "danger") return "id-card-action id-card-action-danger";
+ return "id-card-action id-card-action-secondary";
+}
+
+function Identification({
+ card,
+ onAction,
+ disabled,
+}: {
+ card: IdentificationCardPayload;
+ onAction: (seedMessage: string) => void;
+ disabled?: boolean;
+}) {
+ const isSuccess = card.state === "success";
+ const isDuplicate = card.state === "duplicate";
+ const photo = card.photoUrls[0];
+
+ return (
+
+ {isSuccess ? (
+
+
+
+
+ Saved as a draft — staff will publish it.
+
+ ) : null}
+ {isDuplicate && card.duplicateOf ? (
+
+ Already in catalog: {card.duplicateOf.name}
+
+ ) : null}
+
+
+ {photo ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {card.photoUrls.length > 1 ? (
+
+ +{card.photoUrls.length - 1}
+
+ ) : null}
+
+ ) : null}
+
+
{card.name}
+ {card.category ? (
+
{card.category}
+ ) : null}
+ {card.location ? (
+
{card.location}
+ ) : null}
+
+
+
+ {card.specLines.length > 0 ? (
+
+ {card.specLines.map((line, i) => (
+
+
{line.label}
+ {line.value}
+
+ ))}
+
+ ) : null}
+
+ {card.foundResources.length > 0 ? (
+
+ ) : null}
+
+ {card.alsoCreating.length > 0 ? (
+
+
Also creating
+
+ {card.alsoCreating.map((row, i) => (
+
+ {row.label}
+ {row.isNew ? (
+ (new)
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+ {isSuccess && card.draftUrl ? (
+
+ Open draft in Notion
+
+ ) : null}
+
+ {card.actions.length > 0 ? (
+
+ {card.actions.map((action) => (
+ onAction(action.seedMessage)}
+ >
+ {action.label}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/v5/src/components/PrimaryNav.tsx b/v5/src/components/PrimaryNav.tsx
index 24463fb..6e1b873 100644
--- a/v5/src/components/PrimaryNav.tsx
+++ b/v5/src/components/PrimaryNav.tsx
@@ -27,6 +27,14 @@ export function PrimaryNav() {
{t(link.key)}
))}
+ open(t("addSeed"))}
+ aria-label={t("addAria")}
+ >
+ {t("add")}
+
;
+}
+
+/** Full tool details returned by `get_tool_details`. */
+interface ToolDetailsResult {
+ found: boolean;
+ message?: string;
+ id?: string;
+ slug?: string;
+ name?: string;
+ category?: string;
+ category_sub?: string;
+ location?: string;
+ zone?: string;
+ training_level?: MakerLabTool["trainingLevel"];
+ training_label?: string;
+ status?: MakerLabTool["status"];
+ description?: string;
+ short_description?: string;
+ materials?: string[];
+ ppe?: string[];
+ tags?: string[];
+ use_restrictions?: string | null;
+ emergency_stop?: string | null;
+ notes?: string | null;
+ links?: MakerLabTool["links"];
+ units?: MakerLabTool["units"];
+ detail_page?: string;
+}
+
+// ── Inputs ─────────────────────────────────────────────────────────
+
+const listToolsInput = z.object({
+ category: z
+ .string()
+ .optional()
+ .describe("Filter by category (partial match)"),
+ location: z
+ .string()
+ .optional()
+ .describe("Filter by location (partial match)"),
+});
+type ListToolsInput = z.infer;
+
+const searchToolsInput = z.object({
+ query: z.string().describe("Search keyword or phrase"),
+});
+type SearchToolsInput = z.infer;
+
+const getToolDetailsInput = z.object({
+ id_or_name: z.string().describe("Tool id, slug, or name"),
+});
+type GetToolDetailsInput = z.infer;
+
+// ── Tools ──────────────────────────────────────────────────────────
+
+const listTools: CapabilityTool = {
+ name: "list_tools",
+ description:
+ "List all tools in the MakerLab catalog. Returns name, id, category, location, training level, and status. Optionally filter by category or location (partial match).",
+ inputSchema: listToolsInput,
+ kind: "read",
+ async run({ category, location }) {
+ let tools = await getCatalogTools();
+ if (category) {
+ const cat = category.toLowerCase();
+ tools = tools.filter(
+ (t) =>
+ t.category.toLowerCase().includes(cat) ||
+ t.categorySub.toLowerCase().includes(cat)
+ );
+ }
+ if (location) {
+ const loc = location.toLowerCase();
+ tools = tools.filter(
+ (t) =>
+ t.location.toLowerCase().includes(loc) ||
+ t.zone.toLowerCase().includes(loc)
+ );
+ }
+ return {
+ count: tools.length,
+ tools: tools.map((t) => ({
+ id: t.id,
+ slug: t.slug,
+ name: t.name,
+ summary: summarizeTool(t),
+ })),
+ };
+ },
+};
+
+const searchTools: CapabilityTool = {
+ name: "search_tools",
+ description:
+ "Keyword search across tool names, descriptions, materials, and tags. Returns matching tools with a short summary.",
+ inputSchema: searchToolsInput,
+ kind: "read",
+ async run({ query }) {
+ const q = query.toLowerCase();
+ const tools = await getCatalogTools();
+ const results = tools.filter((t) =>
+ [t.name, t.description, t.shortDescription, ...t.materials, ...t.tags]
+ .join(" ")
+ .toLowerCase()
+ .includes(q)
+ );
+ return {
+ query,
+ count: results.length,
+ tools: results.map((t) => ({
+ id: t.id,
+ slug: t.slug,
+ name: t.name,
+ summary: summarizeTool(t),
+ short_description: t.shortDescription,
+ })),
+ };
+ },
+};
+
+const getToolDetails: CapabilityTool = {
+ name: "get_tool_details",
+ description:
+ "Get full details for a tool by id, slug, or name. Includes description, materials, PPE, training, use restrictions, emergency stop, units, and resource links (SOPs, safety docs, manuals).",
+ inputSchema: getToolDetailsInput,
+ kind: "read",
+ async run({ id_or_name }) {
+ const needle = id_or_name.trim();
+ // Try a direct id/slug lookup first (cheaper, single record), then fall
+ // back to a name search across the full catalog.
+ let tool = await getCatalogTool(needle);
+ if (!tool) {
+ const tools = await getCatalogTools();
+ tool = findTool(tools, needle);
+ }
+ if (!tool) {
+ return { found: false, message: `Tool not found: ${id_or_name}` };
+ }
+
+ return {
+ found: true,
+ id: tool.id,
+ slug: tool.slug,
+ name: tool.name,
+ category: tool.category,
+ category_sub: tool.categorySub,
+ location: tool.location,
+ zone: tool.zone,
+ training_level: tool.trainingLevel,
+ training_label: tool.trainingLabel,
+ status: tool.status,
+ description: tool.description,
+ short_description: tool.shortDescription,
+ materials: tool.materials,
+ ppe: tool.ppe,
+ tags: tool.tags,
+ use_restrictions: tool.useRestrictions,
+ emergency_stop: tool.emergencyStop,
+ notes: tool.notes,
+ links: tool.links,
+ units: tool.units,
+ detail_page: `/tools/${tool.slug}`,
+ };
+ },
+};
+
+// ── Prompt fragment ────────────────────────────────────────────────
+
+/**
+ * Describe a single catalog tool in the compact bullet form the chat system
+ * prompt uses (name + slug + category/location/training, then a units line).
+ */
+function describeCatalogEntry(tool: MakerLabTool): string {
+ const head = `- **${tool.name}** — slug: \`${tool.slug}\` — ${tool.category}${
+ tool.categorySub ? ` / ${tool.categorySub}` : ""
+ } · ${tool.location}${tool.zone ? ` / ${tool.zone}` : ""} · ${tool.trainingLevel}`;
+ if (!tool.units.length) return head;
+ const units = tool.units
+ .map((unit) => `${unit.name} [${unit.status}]`)
+ .join(", ");
+ return `${head}\n units: ${units}`;
+}
+
+function promptFragment(env: PromptEnv): string {
+ const { tools, focusedTool } = env;
+ const sections: string[] = [];
+
+ sections.push(
+ `## Browsing the catalog\n\nUse the catalog tools to answer questions about what's in the lab:\n\n- \`list_tools\` — list everything, optionally filtered by category or location (partial match). Use this for "what do you have", "show me the 3D printers", "what's in the wood shop".\n- \`search_tools\` — keyword search across names, descriptions, materials, and tags. Use this when the student describes a need ("something to cut acrylic", "a tool for sanding") rather than naming a tool.\n- \`get_tool_details\` — full details for one tool by id, slug, or name. Use this when the student asks about a specific tool's specs, training, PPE, restrictions, units, or resources, before answering with anything beyond the summary already in the catalog list below.\n\nGround every answer in the catalog. If a student asks about a tool that isn't in the catalog, say so honestly rather than inventing one.`
+ );
+
+ sections.push(
+ `## Linking tools\n\nWhenever you mention a tool that exists in the catalog below, **format its name as a markdown link** to its detail page using the slug provided in the catalog: \`[Tool Name](/tools/)\`. This lets the student jump straight to the tool's page. Examples:\n- "You could use the [Bambu Lab X1-Carbon Combo 3D Printer](/tools/) for that."\n- "For laser cutting acrylic, check the [Epilog Helix 24](/tools/)."\n\nDo **not** link the tool the student is already viewing (see Active tool context). Do not invent slugs — only use slugs from the catalog list.`
+ );
+
+ if (focusedTool) {
+ sections.push(
+ `## Active tool context\n\nThe student is currently viewing the **${focusedTool.name}** detail page in the MakerLab catalog. If they use pronouns like "this", "it", "that tool", or "the machine", or ask things like "how do I use it" / "what can I make with this" without naming a tool, assume they are asking about the ${focusedTool.name}. Do not wrap "${focusedTool.name}" itself in a tool link — the student is already on its page.`
+ );
+ }
+
+ sections.push(`## MakerLab catalog (${tools.length} tools)`);
+ sections.push(tools.map(describeCatalogEntry).join("\n"));
+
+ return sections.join("\n\n");
+}
+
+// ── Capability ─────────────────────────────────────────────────────
+
+export const catalog: Capability = {
+ id: "catalog",
+ promptFragment,
+ tools: [
+ listTools as CapabilityTool,
+ searchTools as CapabilityTool,
+ getToolDetails as CapabilityTool,
+ ],
+};
diff --git a/v5/src/lib/capabilities/chat-adapter.ts b/v5/src/lib/capabilities/chat-adapter.ts
new file mode 100644
index 0000000..e30c0f5
--- /dev/null
+++ b/v5/src/lib/capabilities/chat-adapter.ts
@@ -0,0 +1,236 @@
+import { tool, type Tool } from "ai";
+import type {
+ Capability,
+ CapabilityCtx,
+ CapabilityTool,
+ PromptEnv,
+} from "./types";
+import { languageNameForLocale } from "../../i18n/config";
+import { siteConfig } from "../site-config";
+import type { MakerLabTool } from "../../components/catalog-types";
+
+/**
+ * Chat adapter (design spec §3.3). Bridges the surface-agnostic capability
+ * registry to the Vercel AI SDK:
+ *
+ * - {@link toAiTools} wraps each {@link CapabilityTool} in the AI SDK `tool()`
+ * shape. The `execute` runs the tool's `run(input, ctx)`. When the tool
+ * declares a `card`, the adapter emits a `data-card` UI part through
+ * `ctx.writer` after `run()` resolves so the client renders an interactive
+ * widget, then hands the model a compact text result instead of the full
+ * payload.
+ * - {@link buildSystemPrompt} composes the system prompt by joining the chat
+ * surface's scaffolding (intro, tool-linking, focused-tool context, resource
+ * fetching/citing, catalog listing) with each capability's `promptFragment`.
+ * - {@link composeChat} is a convenience that returns both at once.
+ *
+ * Manual (PDF) attachment sections remain owned by the chat route itself — they
+ * depend on per-request server-side PDF fetching that has no place in the
+ * surface-agnostic prompt env.
+ */
+
+/**
+ * Convert every capability's tools into a `Record` for the AI SDK.
+ * Tools with a `card` emit a `data-card` part via `ctx.writer` and return a
+ * compact acknowledgement to the model; tools without a card return their full
+ * structured result.
+ */
+export function toAiTools(
+ capabilities: Capability[],
+ ctx: CapabilityCtx
+): Record {
+ const aiTools: Record = {};
+ for (const capability of capabilities) {
+ for (const capTool of capability.tools) {
+ aiTools[capTool.name] = wrapTool(capTool, ctx);
+ }
+ }
+ return aiTools;
+}
+
+/** Wrap a single capability tool in the AI SDK `tool()` shape. */
+function wrapTool(
+ capTool: CapabilityTool,
+ ctx: CapabilityCtx
+): Tool {
+ return tool({
+ description: capTool.description,
+ inputSchema: capTool.inputSchema,
+ execute: async (input: unknown) => {
+ const result = await capTool.run(input, ctx);
+ if (capTool.card) {
+ const card = capTool.card(result);
+ if (ctx.writer) {
+ ctx.writer.write({ type: "data-card", data: card });
+ }
+ return compactCardResult(card, result);
+ }
+ return result;
+ },
+ });
+}
+
+/**
+ * Build the compact text/object result returned to the model for a card-bearing
+ * tool. The full card payload is rendered client-side via the streamed
+ * `data-card` part, so the model only needs a short, structured summary it can
+ * reason about (and reference when proposing the next step).
+ */
+function compactCardResult(
+ card: ReturnType>,
+ result: unknown
+): unknown {
+ // Identification cards (the only card kind today) summarize to a compact
+ // object the model can act on without re-deriving from the raw result.
+ if (card.kind === "identification") {
+ return {
+ card_rendered: true,
+ kind: card.kind,
+ candidate_id: card.candidateId,
+ state: card.state,
+ name: card.name,
+ category: card.category,
+ location: card.location,
+ duplicate_of: card.duplicateOf ?? null,
+ draft_url: card.draftUrl,
+ also_creating: card.alsoCreating.map((row) => ({
+ label: row.label,
+ entity: row.entity,
+ is_new: row.isNew,
+ })),
+ };
+ }
+ // Unknown future card kinds: acknowledge and fall back to the raw result so
+ // nothing is silently dropped from the model's view.
+ return { card_rendered: true, result };
+}
+
+/**
+ * Compose the chat system prompt: the chat surface scaffolding plus every
+ * capability's `promptFragment(env)`, in registry order. Preserves parity with
+ * the original chat route prompt (intro, tool-linking, focused-tool context,
+ * resource fetching/citing, catalog listing) while letting capabilities inject
+ * their own instructions (unit lookups, maintenance flow, intake, …).
+ */
+export function buildSystemPrompt(
+ capabilities: Capability[],
+ env: PromptEnv
+): string {
+ const { tools, focusedTool, locale } = env;
+ const sections: string[] = [introSection(), linkingSection()];
+
+ for (const capability of capabilities) {
+ const fragment = capability.promptFragment(env).trim();
+ if (fragment) sections.push(fragment);
+ }
+
+ if (locale && locale !== "en") {
+ sections.push(languageSection(locale));
+ }
+
+ if (focusedTool) {
+ sections.push(focusedToolSection(focusedTool));
+ }
+
+ if (focusedTool && focusedTool.links.length > 0) {
+ sections.push(resourcesSection(focusedTool));
+ }
+
+ sections.push(fetchingSection());
+ sections.push(citingSection());
+ sections.push(catalogSection(tools));
+
+ return sections.join("\n\n");
+}
+
+/**
+ * Convenience composer (design spec §3.3): returns the wrapped AI SDK tools and
+ * the composed system prompt for a single chat request.
+ */
+export function composeChat(
+ capabilities: Capability[],
+ ctx: CapabilityCtx,
+ env: PromptEnv
+): { tools: Record; system: string } {
+ return {
+ tools: toAiTools(capabilities, ctx),
+ system: buildSystemPrompt(capabilities, env),
+ };
+}
+
+// ── Prompt sections (parity with the original chat route) ───────────
+
+function introSection(): string {
+ return `You are the ${siteConfig.chatAssistantName} — a friendly, knowledgeable helper for ${siteConfig.audience} using the ${siteConfig.institution} MakerLab. Answer questions about lab tools, training requirements, safety, materials, and which machines are right for a given project. Be concise, accurate, and grounded only in the catalog provided below. If the user asks about a tool that isn't in the catalog, say so honestly.`;
+}
+
+function linkingSection(): string {
+ return `## Linking tools\n\nWhenever you mention a tool that exists in the catalog below, **format its name as a markdown link** to its detail page using the slug provided in the catalog: \`[Tool Name](/tools/)\`. This lets the student jump straight to the tool's page. Examples:\n- "You could use the [Bambu Lab X1-Carbon Combo 3D Printer](/tools/) for that."\n- "For laser cutting acrylic, check the [Epilog Helix 24](/tools/)."\n\nDo **not** link the tool the student is already viewing (see Active tool context). Do not invent slugs — only use slugs from the catalog list.`;
+}
+
+function languageSection(locale: string): string {
+ const language = languageNameForLocale(locale);
+ return `## Response language\n\nRespond to the student in **${language}**, regardless of the language they write in. Translate your explanations and conversational text into ${language}. However, ALWAYS keep the following in English so MakerLab staff can read them: tool and equipment names (use the exact catalog names), unit labels (e.g. "Prusa #1"), and — critically — the \`title\` and \`description\` you pass to the \`report_issue\` tool when filing a maintenance ticket. Maintenance ticket content must be written in English even though you reply to the student in ${language}.`;
+}
+
+function focusedToolSection(focused: MakerLabTool): string {
+ return `## Active tool context\n\nThe student is currently viewing the **${focused.name}** detail page in the MakerLab catalog. If they use pronouns like "this", "it", "that tool", or "the machine", or ask things like "how do I use it" / "what can I make with this" without naming a tool, assume they are asking about the ${focused.name}. Use the resource links below when relevant — point to the SOP, safety doc, or manual when the student asks how to use, set up, or troubleshoot the tool. Do not wrap "${focused.name}" itself in a tool link — the student is already on its page.\n\n${describeTool(focused)}`;
+}
+
+function resourcesSection(focused: MakerLabTool): string {
+ const list = focused.links
+ .map((link) => `- [${link.kind || "Resource"}] ${link.label} — ${link.href}`)
+ .join("\n");
+ return `## Resources for this tool\n\nThe following resources are linked from the **${focused.name}** Notion page. Retrieve any of them with the \`web_fetch\` tool when relevant.\n\n${list}`;
+}
+
+function fetchingSection(): string {
+ return `## Fetching resources\n\nUse the \`web_fetch\` tool to read any URL from the "Resources for this tool" list — HTML SOPs, safety pages, manufacturer guides, manual PDFs, etc. Rules:\n\n- Only call \`web_fetch\` on exact URLs that appear in "Resources for this tool" (or, during intake, on product/manual URLs the student supplied). Do not invent URLs or fetch general web pages the student wasn't routed to.`;
+}
+
+function citingSection(): string {
+ return `## Citing sources\n\nWhen you draw on a \`web_fetch\`ed page, cite the source inline as a **markdown link** using the exact URL from the lists above. Two formats:\n\n1. PDF with a known page: \`[Form 4 Manual, p. 14](https://media.formlabs.com/.../-ENUS-Form-4-Manual.pdf#page=14)\` — append \`#page=N\` so browser PDF viewers jump to the page.\n2. HTML page or PDF with no known page: \`[Trotec Speedy 400 SOP](https://...)\`.\n\nDo not invent page numbers or URLs. Always use exact URLs from the lists above.`;
+}
+
+function catalogSection(tools: MakerLabTool[]): string {
+ const header = `## MakerLab catalog (${tools.length} tools)`;
+ const list = tools
+ .map((t) => {
+ const head = `- **${t.name}** — slug: \`${t.slug}\` — ${t.category}${t.categorySub ? ` / ${t.categorySub}` : ""} · ${t.location}${t.zone ? ` / ${t.zone}` : ""} · ${t.trainingLevel}`;
+ if (!t.units.length) return head;
+ const units = t.units.map((unit) => `${unit.name} [${unit.status}]`).join(", ");
+ return `${head}\n units: ${units}`;
+ })
+ .join("\n");
+ return `${header}\n\n${list}`;
+}
+
+/** Full multi-line description of the focused tool (parity with the route). */
+function describeTool(t: MakerLabTool): string {
+ const lines: string[] = [
+ `**${t.name}**`,
+ `- Category: ${t.category}${t.categorySub ? ` / ${t.categorySub}` : ""}`,
+ `- Location: ${t.location}${t.zone ? ` / ${t.zone}` : ""}`,
+ `- Training: ${t.trainingLabel} (level: ${t.trainingLevel})`,
+ ];
+ if (t.materials.length) lines.push(`- Materials: ${t.materials.join(", ")}`);
+ if (t.ppe.length) lines.push(`- PPE: ${t.ppe.join(", ")}`);
+ if (t.useRestrictions) lines.push(`- Restrictions: ${t.useRestrictions}`);
+ if (t.emergencyStop) lines.push(`- Emergency stop: ${t.emergencyStop}`);
+ if (t.description) lines.push(`- Description: ${t.description}`);
+ if (t.units.length) {
+ lines.push("- Units:");
+ for (const unit of t.units) {
+ lines.push(
+ ` - ${unit.name} — status: ${unit.status}, condition: ${unit.condition}${unit.serial && unit.serial !== "Unlisted" ? `, serial: ${unit.serial}` : ""}`
+ );
+ }
+ }
+ if (t.links.length) {
+ lines.push("- Resources:");
+ for (const link of t.links) {
+ lines.push(` - ${link.kind || "Resource"}: ${link.label} — ${link.href}`);
+ }
+ }
+ return lines.join("\n");
+}
diff --git a/v5/src/lib/capabilities/helpers.ts b/v5/src/lib/capabilities/helpers.ts
new file mode 100644
index 0000000..07d76c6
--- /dev/null
+++ b/v5/src/lib/capabilities/helpers.ts
@@ -0,0 +1,129 @@
+import { fetchMaintenanceLogsByUnit } from "../notion";
+import type {
+ MakerLabTool,
+ MakerLabUnit,
+} from "../../components/catalog-types";
+
+/**
+ * Shared catalog/unit/maintenance helpers used by the catalog + units (and
+ * intake) capabilities. These were previously duplicated between the chat route
+ * (`app/api/chat/route.ts`) and the MCP route (`app/api/mcp/route.ts`); they are
+ * collapsed here so both adapters resolve units and summarize tools identically.
+ *
+ * Pure data transforms plus one thin Notion read wrapper — server-only via the
+ * `../notion` import.
+ */
+
+// ── Unit lookup ────────────────────────────────────────────────────
+
+/** A flattened, label-addressable view of a single physical unit. */
+export interface UnitLookupEntry {
+ id: string;
+ label: string;
+ toolName: string;
+ toolSlug: string;
+ status: MakerLabUnit["status"];
+ condition: MakerLabUnit["condition"];
+ location: string;
+ serial: string;
+ dateAcquired: string | null;
+}
+
+/**
+ * Flatten the catalog's units into a lookup so we can resolve a unit by its
+ * label the same way across chat and MCP.
+ */
+export function buildUnitLookup(tools: MakerLabTool[]): UnitLookupEntry[] {
+ return tools.flatMap((tool) =>
+ tool.units.map((unit) => ({
+ id: unit.id,
+ label: unit.name,
+ toolName: tool.name,
+ toolSlug: tool.slug,
+ status: unit.status,
+ condition: unit.condition,
+ location: unit.location,
+ serial: unit.serial,
+ dateAcquired: unit.dateAcquired,
+ }))
+ );
+}
+
+/**
+ * Resolve a unit by label: exact (case-insensitive) match first, then the first
+ * substring match, else null.
+ */
+export function findUnit(
+ units: UnitLookupEntry[],
+ label: string
+): UnitLookupEntry | null {
+ const needle = label.trim().toLowerCase();
+ if (!needle) return null;
+ return (
+ units.find((u) => u.label.toLowerCase() === needle) ||
+ units.find((u) => u.label.toLowerCase().includes(needle)) ||
+ null
+ );
+}
+
+// ── Tool lookup / summaries ────────────────────────────────────────
+
+/** Resolve a tool by Notion page id, slug, exact name, or partial name. */
+export function findTool(
+ tools: MakerLabTool[],
+ idOrName: string
+): MakerLabTool | null {
+ const needle = idOrName.trim().toLowerCase();
+ if (!needle) return null;
+ return (
+ tools.find(
+ (t) => t.id.toLowerCase() === needle || t.slug.toLowerCase() === needle
+ ) ||
+ tools.find((t) => t.name.toLowerCase() === needle) ||
+ tools.find((t) => t.name.toLowerCase().includes(needle)) ||
+ null
+ );
+}
+
+/** A compact one-line summary of a tool for list/search results. */
+export function summarizeTool(tool: MakerLabTool): string {
+ return [
+ tool.name,
+ `id: ${tool.id}`,
+ `${tool.category}${tool.categorySub ? ` > ${tool.categorySub}` : ""}`,
+ `${tool.location}${tool.zone ? ` / ${tool.zone}` : ""}`,
+ `training: ${tool.trainingLevel}`,
+ `status: ${tool.status}`,
+ ].join(" | ");
+}
+
+// ── Maintenance ────────────────────────────────────────────────────
+
+/** A flattened, model-friendly maintenance log entry. */
+export interface MaintenanceEntry {
+ title: string;
+ type: string;
+ priority: string;
+ status: string;
+ date_reported: string;
+ description: string;
+}
+
+/**
+ * Fetch the most recent maintenance logs for a unit (cap 10), flattened to the
+ * shape both chat and MCP return. Best-effort: resolves to [] on failure.
+ */
+export function recentMaintenance(unitId: string): Promise {
+ return fetchMaintenanceLogsByUnit(unitId)
+ .catch(() => [])
+ .then((logs) =>
+ logs.slice(0, 10).map((log) => ({
+ title: log.fields.title,
+ type: log.fields.type || "",
+ priority: log.fields.priority || "",
+ status: log.fields.status || "",
+ date_reported: log.fields.date_reported || "",
+ description: log.fields.description || "",
+ }))
+ );
+}
diff --git a/v5/src/lib/capabilities/index.ts b/v5/src/lib/capabilities/index.ts
new file mode 100644
index 0000000..4ba15eb
--- /dev/null
+++ b/v5/src/lib/capabilities/index.ts
@@ -0,0 +1,54 @@
+import { catalog } from "./catalog";
+import { units } from "./units";
+import { maintenance } from "./maintenance";
+import { intake } from "./intake";
+import type { Capability } from "./types";
+
+/**
+ * The capability registry (design spec §3.2). A single ordered source of truth
+ * for the assistant's abilities: each {@link Capability} bundles a group of
+ * tools with a system-prompt fragment. Both surfaces consume this same array —
+ * the chat adapter (`toAiTools` / `buildSystemPrompt` / `composeChat`) and the
+ * MCP adapter (`registerAll`) — so chat and MCP stay in lockstep.
+ *
+ * Order matters: it determines the order tools are registered and the order
+ * each capability's prompt fragment appears in the composed system prompt.
+ * - `catalog` — read-only discovery (list / search / details).
+ * - `units` — per-unit status + maintenance history (read).
+ * - `maintenance` — file maintenance tickets (write).
+ * - `intake` — research / propose / create catalog listings (read+write).
+ *
+ * This module is the canonical import for everything in the capabilities layer:
+ * the registry itself, the two adapters, and the shared contract types.
+ */
+export const CAPABILITIES: Capability[] = [catalog, units, maintenance, intake];
+
+// Re-export the individual capabilities for direct/selective use and testing.
+export { catalog, units, maintenance, intake };
+
+// Re-export the surface adapters so consumers import from one place.
+export {
+ toAiTools,
+ buildSystemPrompt,
+ composeChat,
+} from "./chat-adapter";
+export { registerAll, type RegisterAllOptions } from "./mcp-adapter";
+
+// Re-export the contract types most consumers need.
+export type {
+ Capability,
+ CapabilityCtx,
+ CapabilityKind,
+ CapabilityTool,
+ PromptEnv,
+ UploadedImage,
+ ToolCandidate,
+ CardPayload,
+ CardState,
+ CardAction,
+ CardResource,
+ CardSpecLine,
+ CardAlsoCreating,
+ IdentificationCardPayload,
+} from "./types";
+export { uploadedImageSchema, toolCandidateSchema } from "./types";
diff --git a/v5/src/lib/capabilities/intake.ts b/v5/src/lib/capabilities/intake.ts
new file mode 100644
index 0000000..13f9641
--- /dev/null
+++ b/v5/src/lib/capabilities/intake.ts
@@ -0,0 +1,532 @@
+import { z } from "zod";
+import { getCatalogTools } from "../catalog";
+import {
+ createResource,
+ createTool,
+ createUnit,
+ findOrCreateCategory,
+ findOrCreateLocation,
+} from "../notion";
+import type { MakerLabTool } from "../../components/catalog-types";
+import { findTool } from "./helpers";
+import {
+ toolCandidateSchema,
+ type Capability,
+ type CapabilityCtx,
+ type CapabilityTool,
+ type CardAction,
+ type CardAlsoCreating,
+ type CardResource,
+ type CardSpecLine,
+ type IdentificationCardPayload,
+ type PromptEnv,
+ type ToolCandidate,
+} from "./types";
+
+/**
+ * The `intake` capability (design spec §4): a low-barrier path for cataloging
+ * equipment from messy multimodal input. Three tools cooperate —
+ *
+ * 1. `research_tool` (read) — normalize a free-text hint / product URLs into a
+ * structured {@link ToolCandidate} and run a catalog
+ * search for duplicate detection.
+ * 2. `propose_listing` (read) — emit an identification card per candidate so the
+ * user can confirm before anything is written.
+ * 3. `create_tool` (write) — perform the draft-by-default Notion writes in the
+ * spec §5 order with partial-failure reporting.
+ *
+ * Web research itself is done by the provider-native `web_search` / `web_fetch`
+ * tools (added by the chat adapter, not here); the prompt fragment instructs the
+ * model to use them before calling `research_tool`.
+ */
+
+// ── Candidate ids ──────────────────────────────────────────────────
+
+/**
+ * Derive a stable-ish candidate id from a name. Used to correlate a card with a
+ * ToolCandidate across the propose → confirm → create handshake, and to seed the
+ * `confirm add: ` follow-up message the card's button sends.
+ */
+function candidateId(name: string): string {
+ const slug = name
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return slug || "candidate";
+}
+
+// ── Duplicate detection ────────────────────────────────────────────
+
+/**
+ * Keyword-match a candidate against the published catalog (mirrors the MCP
+ * `search_tools` heuristic) and return the strongest match, if any. Returns null
+ * on any failure so research never hard-fails on a catalog read.
+ */
+async function detectDuplicate(
+ name: string
+): Promise<{ id: string; name: string } | null> {
+ let tools: MakerLabTool[];
+ try {
+ tools = await getCatalogTools();
+ } catch {
+ return null;
+ }
+ // Exact / partial name resolution first.
+ const byName = findTool(tools, name);
+ if (byName) return { id: byName.id, name: byName.name };
+
+ // Fall back to a keyword search across searchable fields.
+ const q = name.toLowerCase().trim();
+ if (!q) return null;
+ const match = tools.find((t) =>
+ [t.name, t.description, t.shortDescription, ...t.materials, ...t.tags]
+ .join(" ")
+ .toLowerCase()
+ .includes(q)
+ );
+ return match ? { id: match.id, name: match.name } : null;
+}
+
+// ── research_tool ──────────────────────────────────────────────────
+
+const researchInputSchema = z.object({
+ candidate: toolCandidateSchema.describe(
+ "The best-effort structured candidate you assembled from the user's description, attached photos, and any web_search/web_fetch you already ran. Leave duplicate_of unset — research_tool fills it in."
+ ),
+});
+type ResearchInput = z.infer;
+
+interface ResearchResult {
+ candidate: ToolCandidate;
+ duplicate: { id: string; name: string } | null;
+}
+
+const researchTool: CapabilityTool = {
+ name: "research_tool",
+ description:
+ "Normalize a researched equipment candidate and check the catalog for duplicates. BEFORE calling this, use the native web_search / web_fetch tools (and any attached photos) to gather the canonical name, manufacturer, specs, materials, PPE, a manual PDF URL, and a setup video URL. Pass your best-effort ToolCandidate; this tool runs a catalog search on the name and returns the candidate annotated with duplicate_of when a strong existing match is found. Read-only — it writes nothing.",
+ inputSchema: researchInputSchema,
+ kind: "read",
+ run: async ({ candidate }, ctx: CapabilityCtx): Promise => {
+ // Carry through any image uploads attached to this chat turn so the eventual
+ // create_tool can re-attach the same photos without re-uploading.
+ const attachmentIds = (ctx.attachments || []).map((a) => a.file_upload_id);
+ const mergedImageIds = Array.from(
+ new Set([...(candidate.image_upload_ids || []), ...attachmentIds])
+ );
+
+ const duplicate = await detectDuplicate(candidate.name);
+
+ const normalized: ToolCandidate = {
+ ...candidate,
+ materials: candidate.materials || [],
+ ppe_required: candidate.ppe_required || [],
+ tags: candidate.tags || [],
+ units: candidate.units || [],
+ resources: candidate.resources || [],
+ image_upload_ids: mergedImageIds,
+ source_urls: candidate.source_urls || [],
+ duplicate_of: duplicate,
+ };
+
+ return { candidate: normalized, duplicate };
+ },
+};
+
+// ── propose_listing ────────────────────────────────────────────────
+
+const proposeInputSchema = z.object({
+ candidates: z
+ .array(toolCandidateSchema)
+ .min(1)
+ .describe(
+ "One or more researched candidates to confirm with the user. Pass several at once when the user described a batch — each gets its own independently-confirmable card."
+ ),
+});
+type ProposeInput = z.infer;
+
+interface ProposeResult {
+ candidates: ToolCandidate[];
+}
+
+/** Build the "also creating" rows (taxonomy + units + resources) for a card. */
+function buildAlsoCreating(c: ToolCandidate): CardAlsoCreating[] {
+ const rows: CardAlsoCreating[] = [];
+ if (c.category) {
+ rows.push({
+ label: `Category: ${c.category.group} / ${c.category.name}`,
+ entity: "category",
+ isNew: c.category.isNew,
+ });
+ }
+ if (c.location) {
+ rows.push({
+ label: `Location: ${c.location.room} / ${c.location.zone}`,
+ entity: "location",
+ isNew: c.location.isNew,
+ });
+ }
+ for (const unit of c.units) {
+ rows.push({
+ label: `Unit: ${unit.label}`,
+ entity: "unit",
+ isNew: true,
+ });
+ }
+ for (const resource of c.resources) {
+ rows.push({
+ label: `${resource.type}: ${resource.title}`,
+ entity: "resource",
+ isNew: true,
+ });
+ }
+ return rows;
+}
+
+/** Build the spec lines (materials, PPE, training, restrictions) for a card. */
+function buildSpecLines(c: ToolCandidate): CardSpecLine[] {
+ const lines: CardSpecLine[] = [];
+ if (c.materials.length) {
+ lines.push({ label: "Materials", value: c.materials.join(", ") });
+ }
+ if (c.ppe_required.length) {
+ lines.push({ label: "PPE", value: c.ppe_required.join(", ") });
+ }
+ if (c.tags.length) {
+ lines.push({ label: "Tags", value: c.tags.join(", ") });
+ }
+ if (typeof c.training_required === "boolean") {
+ lines.push({
+ label: "Training",
+ value: c.training_required ? "Required" : "Not required",
+ });
+ }
+ if (c.use_restrictions) {
+ lines.push({ label: "Restrictions", value: c.use_restrictions });
+ }
+ return lines;
+}
+
+function toFoundResources(c: ToolCandidate): CardResource[] {
+ return c.resources.map((r) => ({
+ title: r.title,
+ url: r.url,
+ type: r.type,
+ }));
+}
+
+/** Action buttons for a proposed (or duplicate) candidate card. */
+function buildActions(c: ToolCandidate): CardAction[] {
+ const id = candidateId(c.name);
+ if (c.duplicate_of) {
+ return [
+ {
+ id: "add-unit",
+ label: "Add a unit to the existing tool",
+ seedMessage: `add unit to existing: ${c.duplicate_of.id}`,
+ variant: "primary",
+ },
+ {
+ id: "create-anyway",
+ label: "No, create a new tool",
+ seedMessage: `create new tool anyway: ${id}`,
+ variant: "secondary",
+ },
+ {
+ id: "discard",
+ label: "Discard",
+ seedMessage: `discard: ${id}`,
+ variant: "danger",
+ },
+ ];
+ }
+ return [
+ {
+ id: "confirm",
+ label: "Looks right — add it",
+ seedMessage: `confirm add: ${id}`,
+ variant: "primary",
+ },
+ {
+ id: "edit",
+ label: "Edit",
+ seedMessage: `edit: ${id}`,
+ variant: "secondary",
+ },
+ {
+ id: "discard",
+ label: "Discard",
+ seedMessage: `discard: ${id}`,
+ variant: "danger",
+ },
+ ];
+}
+
+/** Map a single candidate to its identification card payload. */
+function candidateToCard(c: ToolCandidate): IdentificationCardPayload {
+ const isDuplicate = Boolean(c.duplicate_of);
+ return {
+ kind: "identification",
+ candidateId: candidateId(c.name),
+ state: isDuplicate ? "duplicate" : "proposed",
+ name: c.name,
+ photoUrls: [],
+ category: c.category
+ ? `${c.category.group} / ${c.category.name}`
+ : undefined,
+ location: c.location
+ ? `${c.location.room} / ${c.location.zone}`
+ : undefined,
+ specLines: buildSpecLines(c),
+ foundResources: toFoundResources(c),
+ alsoCreating: buildAlsoCreating(c),
+ actions: buildActions(c),
+ duplicateOf: c.duplicate_of ?? undefined,
+ };
+}
+
+const proposeListing: CapabilityTool = {
+ name: "propose_listing",
+ description:
+ "Show the user an identification card for each researched candidate so they can confirm before anything is written. This is the mandatory confirmation gate: ALWAYS call propose_listing and wait for an explicit user confirmation before create_tool. Pass multiple candidates to confirm a batch — each renders an independent card. When a candidate has a duplicate_of match, its card offers 'Add a unit to the existing tool' instead of creating a new tool. Read-only.",
+ inputSchema: proposeInputSchema,
+ kind: "read",
+ run: async ({ candidates }): Promise => {
+ return { candidates };
+ },
+ // The chat adapter emits one data-card per candidate; for a single result we
+ // surface the first candidate's card here (the adapter handles the batch).
+ card: (result: ProposeResult): IdentificationCardPayload =>
+ candidateToCard(result.candidates[0]),
+};
+
+// ── create_tool ────────────────────────────────────────────────────
+
+const createInputSchema = z.object({
+ candidate: toolCandidateSchema.describe(
+ "The single, user-confirmed candidate to write to Notion. Only call this AFTER propose_listing and an explicit user confirmation. For a batch, call create_tool once per confirmed candidate."
+ ),
+});
+type CreateInput = z.infer;
+
+interface CreateResult {
+ success: boolean;
+ /** Notion page id of the created tool, when it landed. */
+ tool_id: string | null;
+ /** Notion page ids of the created units. */
+ unit_ids: string[];
+ /** Notion page url of the created tool draft, when it landed. */
+ draft_url: string | null;
+ /** The candidate name (for card rendering on partial failure). */
+ name: string;
+ created: {
+ tool: boolean;
+ category: { id: string; isNew: boolean } | null;
+ location: { id: string; isNew: boolean } | null;
+ units: number;
+ resources: number;
+ };
+ /** Human-readable notes about steps that did not land (no silent failures). */
+ warnings: string[];
+}
+
+/** Build a Notion page URL from a page id (dashes stripped, as Notion expects). */
+function notionPageUrl(pageId: string): string {
+ return `https://www.notion.so/${pageId.replace(/-/g, "")}`;
+}
+
+const createToolTool: CapabilityTool = {
+ name: "create_tool",
+ description:
+ "Create a draft catalog listing in Notion for a confirmed candidate: find-or-create its Category and Location, create the Tool (published=false), create each Unit linked to the tool, and create each manual/video Resource (published=false). NEVER call this without a prior propose_listing and an explicit user confirmation. Everything is created as a draft — staff publish it later in Notion. Returns the created ids and a draft link; on partial failure it reports exactly what landed so nothing is lost silently.",
+ inputSchema: createInputSchema,
+ kind: "write",
+ run: async ({ candidate }, ctx: CapabilityCtx): Promise => {
+ const warnings: string[] = [];
+ const created: CreateResult["created"] = {
+ tool: false,
+ category: null,
+ location: null,
+ units: 0,
+ resources: 0,
+ };
+
+ // 1. Resolve / create category + location (best-effort — the tool can still
+ // be created without them; we just note it).
+ let categoryId: string | null = null;
+ if (candidate.category) {
+ try {
+ const cat = await findOrCreateCategory(
+ candidate.category.name,
+ candidate.category.group
+ );
+ categoryId = cat.id;
+ created.category = cat;
+ } catch (err) {
+ warnings.push(
+ `Could not resolve category "${candidate.category.name}": ${errMsg(err)}`
+ );
+ }
+ }
+
+ let locationId: string | null = null;
+ if (candidate.location) {
+ try {
+ const loc = await findOrCreateLocation(
+ candidate.location.room,
+ candidate.location.zone
+ );
+ locationId = loc.id;
+ created.location = loc;
+ } catch (err) {
+ warnings.push(
+ `Could not resolve location "${candidate.location.room} / ${candidate.location.zone}": ${errMsg(err)}`
+ );
+ }
+ }
+
+ // 2. Create the tool (published=false). If this fails there is nothing to
+ // link units/resources to, so we bail with a clean failure.
+ const attachmentNames = new Map(
+ (ctx.attachments || []).map((a) => [a.file_upload_id, a.name])
+ );
+ const imageUploads = candidate.image_upload_ids.map((id) => ({
+ id,
+ name: attachmentNames.get(id) || "photo",
+ }));
+
+ let toolId: string;
+ try {
+ const toolRecord = await createTool({
+ name: candidate.name,
+ description: candidate.description,
+ category: categoryId ? [categoryId] : undefined,
+ location: locationId ? [locationId] : undefined,
+ materials: candidate.materials,
+ ppe_required: candidate.ppe_required,
+ tags: candidate.tags,
+ training_required: candidate.training_required,
+ use_restrictions: candidate.use_restrictions,
+ image_uploads: imageUploads.length ? imageUploads : undefined,
+ });
+ toolId = toolRecord.id;
+ created.tool = true;
+ } catch (err) {
+ return {
+ success: false,
+ tool_id: null,
+ unit_ids: [],
+ draft_url: null,
+ name: candidate.name,
+ created,
+ warnings: [...warnings, `Failed to create the tool: ${errMsg(err)}`],
+ };
+ }
+
+ // 3. Create units linked to the tool (best-effort per unit).
+ const unitIds: string[] = [];
+ for (const unit of candidate.units) {
+ try {
+ const unitRecord = await createUnit({
+ unit_label: unit.label,
+ tool: [toolId],
+ serial_number: unit.serial,
+ status: unit.status as UnitStatusInput,
+ condition: unit.condition as UnitConditionInput,
+ });
+ unitIds.push(unitRecord.id);
+ created.units += 1;
+ } catch (err) {
+ warnings.push(`Could not create unit "${unit.label}": ${errMsg(err)}`);
+ }
+ }
+
+ // 4. Create resources linked to the tool (best-effort per resource).
+ for (const resource of candidate.resources) {
+ try {
+ await createResource({
+ title: resource.title,
+ tool: [toolId],
+ type: resource.type,
+ url: resource.url,
+ });
+ created.resources += 1;
+ } catch (err) {
+ warnings.push(
+ `Could not create resource "${resource.title}": ${errMsg(err)}`
+ );
+ }
+ }
+
+ return {
+ success: true,
+ tool_id: toolId,
+ unit_ids: unitIds,
+ draft_url: notionPageUrl(toolId),
+ name: candidate.name,
+ created,
+ warnings,
+ };
+ },
+ card: (result: CreateResult): IdentificationCardPayload => ({
+ kind: "identification",
+ candidateId: candidateId(result.name),
+ state: "success",
+ name: result.name,
+ photoUrls: [],
+ specLines: result.warnings.map((w) => ({ label: "Note", value: w })),
+ foundResources: [],
+ alsoCreating: [],
+ actions: result.draft_url
+ ? [
+ {
+ id: "open-draft",
+ label: "Open draft in Notion",
+ seedMessage: result.draft_url,
+ variant: "secondary",
+ },
+ ]
+ : [],
+ draftUrl: result.draft_url ?? undefined,
+ }),
+};
+
+// The Notion write layer narrows status/condition to its own enums; the
+// candidate carries free-form strings, so we alias the accepted inputs to keep
+// the call sites readable without re-importing the full union here.
+type UnitStatusInput = Parameters[0]["status"];
+type UnitConditionInput = Parameters[0]["condition"];
+
+function errMsg(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+// ── Prompt fragment ────────────────────────────────────────────────
+
+function promptFragment(_env: PromptEnv): string {
+ return [
+ `## Adding equipment to the inventory (intake)`,
+ `When someone wants to add a tool to the catalog — from a free-text description, dictated notes, attached photos, or pasted product/store/manual URLs — act as an intake agent and follow this flow:`,
+ `1. **Research first.** Use the native \`web_search\` and \`web_fetch\` tools (and any attached photos) to identify the equipment: canonical name and manufacturer, a one-paragraph description, key specs, typical materials and required PPE, sensible tags, a manual PDF URL, and a setup/overview video URL. Read product pages and manuals before guessing. Track every URL you actually read so you can pass it as \`source_urls\` for provenance.`,
+ `2. **Normalize.** Call \`research_tool\` with your best-effort \`ToolCandidate\`. It checks the catalog for duplicates and returns the candidate annotated with \`duplicate_of\` when a strong match already exists. Propose a \`category\` (with its group) and a \`location\` (room + zone), setting \`isNew\` to your best judgment; staff will confirm. Include at least one \`unit\` (e.g. " #1") unless the user is clearly describing a consumable.`,
+ `3. **Confirm — always.** Call \`propose_listing\` with the candidate(s). This renders an identification card. **Never call \`create_tool\` without first calling \`propose_listing\` and getting an explicit user confirmation** (a click on "Looks right — add it", or a typed "yes / add it"). Wait for that confirmation.`,
+ `4. **Handle duplicates.** If \`research_tool\` reported a \`duplicate_of\`, the card surfaces "Already in catalog". Offer to **add a unit to the existing tool** rather than creating a new tool, unless the user explicitly wants a separate listing.`,
+ `5. **Create on confirmation.** Once the user confirms, call \`create_tool\` with that single candidate. Everything is saved as a **draft** (\`published = false\`) — tell the user it's saved as a draft and that staff will publish it. If \`create_tool\` reports \`warnings\` (a partial write), relay exactly what landed and what to finish in Notion; never claim full success when steps failed.`,
+ `**Batches:** when the user describes several items at once (a long list, or multiple photos), assemble one candidate per item and pass them all to a single \`propose_listing\` call so each gets its own card. Confirm and \`create_tool\` each item independently; if the user says "add all", create each confirmed candidate in turn.`,
+ `Confirmation messages from card buttons arrive as short follow-ups like \`confirm add: \`, \`add unit to existing: \`, \`edit: \`, or \`discard: \`. Resolve \`confirm add\` to a \`create_tool\` call for the matching candidate; on \`edit\`, ask what to change and re-run \`propose_listing\`; on \`discard\`, drop that candidate.`,
+ ].join("\n\n");
+}
+
+// ── Capability ─────────────────────────────────────────────────────
+
+export const intake: Capability = {
+ id: "intake",
+ promptFragment,
+ // Heterogeneous tool input/output types are erased to the registry's loose
+ // element type; the adapters re-validate each tool's input via its own schema.
+ tools: [researchTool, proposeListing, createToolTool] as unknown as CapabilityTool<
+ unknown,
+ unknown
+ >[],
+};
diff --git a/v5/src/lib/capabilities/maintenance.ts b/v5/src/lib/capabilities/maintenance.ts
new file mode 100644
index 0000000..f1a15a1
--- /dev/null
+++ b/v5/src/lib/capabilities/maintenance.ts
@@ -0,0 +1,125 @@
+import { z } from "zod";
+import { getCatalogTools } from "../catalog";
+import { createMaintenanceLog } from "../notion";
+import { buildUnitLookup, findUnit } from "./helpers";
+import type { Capability, CapabilityCtx, CapabilityTool } from "./types";
+
+/**
+ * The `maintenance` capability: filing maintenance tickets from chat (and now
+ * MCP). Ported byte-for-byte from the chat route's `report_issue` tool and its
+ * "Reporting maintenance issues" system-prompt section (design spec §3.4, §7).
+ * Behavior is intentionally unchanged.
+ */
+
+const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const;
+
+// ── report_issue ───────────────────────────────────────────────────
+
+interface ReportIssueInput {
+ title: string;
+ description: string;
+ unit_label?: string;
+ priority: (typeof PRIORITIES)[number];
+ reported_by?: string;
+ photo_uploads?: Array<{ id: string; name: string }>;
+}
+
+interface ReportIssueResult {
+ success: boolean;
+ ticket_id?: string;
+ unit_resolved?: { id: string; label: string } | null;
+ message?: string;
+ error?: string;
+}
+
+const reportIssueInputSchema: z.ZodType = z.object({
+ title: z.string().describe("Short summary of the issue"),
+ description: z.string().describe("Full description of what's wrong"),
+ unit_label: z
+ .string()
+ .optional()
+ .describe("Unit label if the issue is tied to a specific unit"),
+ priority: z
+ .enum(PRIORITIES)
+ .default("Medium")
+ .describe(
+ "Critical = unsafe / lab-blocking, High = unusable, Medium = degraded, Low = cosmetic"
+ ),
+ reported_by: z
+ .string()
+ .optional()
+ .describe("Student name or NetID if provided"),
+ photo_uploads: z
+ .array(
+ z.object({
+ id: z.string().describe("Notion file_upload_id"),
+ name: z.string().describe("Original filename"),
+ })
+ )
+ .optional()
+ .describe(
+ "Notion file_upload references. Parse these from the [Attached photos: file_upload_id=... name=...] hint in the student's message."
+ ),
+});
+
+const reportIssue: CapabilityTool = {
+ name: "report_issue",
+ description:
+ "File a maintenance ticket in Notion when a student reports a problem with a tool or unit. Gather a short title and a clear description first. If they named a specific unit (like 'Prusa #1'), include it so the log is linked. Ask for the reporter's name if they haven't given it.",
+ inputSchema: reportIssueInputSchema,
+ kind: "write",
+ async run(input: ReportIssueInput, _ctx: CapabilityCtx): Promise {
+ const { title, description, unit_label, priority, reported_by, photo_uploads } =
+ input;
+ const tools = await getCatalogTools();
+ const unitLookup = buildUnitLookup(tools);
+ const match = unit_label ? findUnit(unitLookup, unit_label) : null;
+ try {
+ const record = await createMaintenanceLog({
+ title,
+ description,
+ type: "Issue Report",
+ priority,
+ status: "Open",
+ reported_by: reported_by || undefined,
+ unit: match ? [match.id] : undefined,
+ date_reported: new Date().toISOString().split("T")[0],
+ photo_uploads: photo_uploads?.length ? photo_uploads : undefined,
+ });
+ return {
+ success: true,
+ ticket_id: record.id,
+ unit_resolved: match ? { id: match.id, label: match.label } : null,
+ message: `Logged maintenance ticket ${record.id}.`,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to file ticket";
+ return { success: false, error: message };
+ }
+ },
+};
+
+// ── Prompt fragment ────────────────────────────────────────────────
+
+function promptFragment(): string {
+ return `## Reporting maintenance issues
+
+You are a first-line helper, not a ticket-creation machine. Follow this order:
+
+1. **Diagnose conversationally first.** When a student describes a problem, ask a clarifying question or two and walk them through quick fixes they can likely do themselves — swap the filament, re-level the bed, clear a jam, restart the slicer, replace a worn bit, check the e-stop, power-cycle, reseat cables, re-home axes, etc. Start with the simplest plausible fix and escalate from there.
+2. **Recognize when to escalate.** Move toward filing a ticket if: the issue is unsafe, the tool clearly needs staff intervention, the student says they can't fix it, the problem keeps recurring, or the student explicitly asks to log it.
+3. **Proactively offer to log.** Even after a successful self-fix for things staff should know about (jams, low filament, missing parts, anything that affects the next user), gently offer: "Want me to log a quick note so staff knows this happened?" Don't push — just offer.
+4. **Gather details and file.** Once the student agrees (or asks directly), collect: a short title, a clear description of what's wrong and what's already been tried, the affected unit if any, a priority, and the student's name/NetID. If they named a specific unit you don't recognize, call \`get_unit_details\` first to verify it exists. Then call \`report_issue\`. After it succeeds, tell the student the ticket was filed and include the ticket ID. If they only name a tool (not a specific unit), it's fine to file without one — but ask first if they can tell you which unit.
+
+If the student's message includes a hint like \`[Attached photos: file_upload_id= name=; ...]\`, parse each \`file_upload_id\` and \`name\` pair and pass them as the \`photo_uploads\` argument to \`report_issue\` (do not echo the raw hint back to the student). The IDs are already uploaded to Notion and will be attached to the ticket.
+
+Priority guide: Critical = unsafe or blocks all lab use · High = tool unusable · Medium = degraded performance · Low = cosmetic.`;
+}
+
+// ── Capability ─────────────────────────────────────────────────────
+
+export const maintenance: Capability = {
+ id: "maintenance",
+ promptFragment,
+ tools: [reportIssue as CapabilityTool],
+};
diff --git a/v5/src/lib/capabilities/mcp-adapter.ts b/v5/src/lib/capabilities/mcp-adapter.ts
new file mode 100644
index 0000000..8383448
--- /dev/null
+++ b/v5/src/lib/capabilities/mcp-adapter.ts
@@ -0,0 +1,79 @@
+import { z } from "zod";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { Capability, CapabilityTool } from "./types";
+
+/**
+ * MCP adapter (design spec §3.3): expose the capability registry through the
+ * Model Context Protocol endpoint. Mirrors the chat adapter, but instead of AI
+ * SDK tools it registers each {@link CapabilityTool} on an {@link McpServer}.
+ *
+ * Each tool's `run()` is wrapped in an MCP handler that returns the structured
+ * result as pretty-printed JSON text, surfacing thrown errors via `isError`.
+ *
+ * Write gating (design spec §3.3 / §8): `kind: "write"` tools are registered
+ * **only** when `opts.allowWrites` is true. The MCP route passes `true` only
+ * when `MCP_TOKEN` is configured (which token-gates the whole endpoint). With no
+ * token set, the MCP surface stays read-only and write tools are omitted.
+ */
+
+export interface RegisterAllOptions {
+ /** Register `kind: "write"` tools. True only when MCP_TOKEN is configured. */
+ allowWrites: boolean;
+}
+
+/**
+ * `McpServer.registerTool` expects a `ZodRawShape` (a plain object of Zod
+ * fields), not a `ZodObject`. Our capability tools carry a `z.ZodType` that
+ * is, in practice, always built from `z.object({...})`. Pull the underlying
+ * shape back out so MCP can build its JSON-Schema, falling back to an empty
+ * shape for the (degenerate) non-object case.
+ */
+function toRawShape(schema: CapabilityTool["inputSchema"]): z.ZodRawShape {
+ if (schema instanceof z.ZodObject) {
+ return (schema as z.ZodObject).shape;
+ }
+ return {};
+}
+
+/** Register a single capability tool on the MCP server. */
+function registerTool(server: McpServer, tool: CapabilityTool): void {
+ server.registerTool(
+ tool.name,
+ {
+ description: tool.description,
+ inputSchema: toRawShape(tool.inputSchema),
+ },
+ async (input: unknown) => {
+ try {
+ const result = await tool.run(input, {});
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
+ isError: true,
+ };
+ }
+ }
+ );
+}
+
+/**
+ * Register every tool from every capability on the MCP server, honoring the
+ * write gate. `read` tools are always registered; `write` tools are registered
+ * only when `opts.allowWrites` is true.
+ */
+export function registerAll(
+ server: McpServer,
+ capabilities: Capability[],
+ opts: RegisterAllOptions
+): void {
+ for (const capability of capabilities) {
+ for (const tool of capability.tools) {
+ if (tool.kind === "write" && !opts.allowWrites) continue;
+ registerTool(server, tool);
+ }
+ }
+}
diff --git a/v5/src/lib/capabilities/types.ts b/v5/src/lib/capabilities/types.ts
new file mode 100644
index 0000000..3fd6964
--- /dev/null
+++ b/v5/src/lib/capabilities/types.ts
@@ -0,0 +1,266 @@
+import { z } from "zod";
+import type { UIMessageStreamWriter } from "ai";
+import type { MakerLabTool } from "../../components/catalog-types";
+
+/**
+ * Shared contract for the capability-registry architecture (design spec §3,
+ * §4.2, §5). A *capability* groups related tools plus a system-prompt fragment.
+ * Each tool is defined once as plain data + a `run()` function, then exposed by
+ * two thin adapters: the chat adapter (AI SDK tools) and the MCP adapter.
+ *
+ * This module is pure type/shape declarations and small Zod schemas. It pulls in
+ * no server-only modules so it can be imported from either adapter, the
+ * capabilities, or tests without side effects.
+ */
+
+// ── Uploaded images ────────────────────────────────────────────────
+
+/**
+ * A photo the user attached to the current chat turn. Mirrors the response
+ * shape of `/api/upload-notion` (`{ file_upload_id, name, contentType, size }`)
+ * and the client-side `PendingPhoto` tracked in `ChatFab`. The `file_upload_id`
+ * is what `create_tool` re-uses to attach the same photo to the new Notion page
+ * without re-uploading; `dataUrl` (optional) carries the image bytes so the
+ * model can actually see the picture for identification (design spec §6.1).
+ */
+export interface UploadedImage {
+ /** Notion file_upload id returned by `/api/upload-notion`. */
+ file_upload_id: string;
+ /** Original filename. */
+ name: string;
+ /** MIME type, e.g. "image/png" / "image/jpeg". */
+ contentType: string;
+ /** Optional data: URL (or remote URL) of the bytes so the model can see it. */
+ dataUrl?: string;
+}
+
+/** Zod schema for {@link UploadedImage}. */
+export const uploadedImageSchema = z.object({
+ file_upload_id: z.string(),
+ name: z.string(),
+ contentType: z.string(),
+ dataUrl: z.string().optional(),
+});
+
+// ── Capability context ─────────────────────────────────────────────
+
+/**
+ * Surface-agnostic services a tool's `run()` may use. The *chat* adapter
+ * populates `writer` / `attachments` / `locale` / `focusedToolId`. The *MCP*
+ * adapter populates only what it can (no writer, no attachments). Tools must
+ * treat every field as optional and degrade gracefully when absent.
+ */
+export interface CapabilityCtx {
+ /** Chat only — write data parts (e.g. identification cards) to the UI stream. */
+ writer?: UIMessageStreamWriter;
+ /** Chat only — photos uploaded for this turn. */
+ attachments?: UploadedImage[];
+ /** Response/UI locale, e.g. "en" / "es". */
+ locale?: string;
+ /** Notion page id of the tool the user is currently viewing, if any. */
+ focusedToolId?: string;
+}
+
+// ── Capability + tool shapes ───────────────────────────────────────
+
+/** Read tools are unrestricted; write tools are MCP-gated by `MCP_TOKEN`. */
+export type CapabilityKind = "read" | "write";
+
+/**
+ * A single tool defined once as data + behavior.
+ *
+ * @typeParam I - validated input type (inferred from `inputSchema`).
+ * @typeParam R - structured result type returned by `run()`.
+ */
+export interface CapabilityTool {
+ /** Stable tool name, e.g. "search_tools" | "report_issue" | "create_tool". */
+ name: string;
+ /** Natural-language description shown to the model / MCP client. */
+ description: string;
+ /** Zod schema validating the tool input. */
+ inputSchema: z.ZodType;
+ /** "write" tools are only registered over MCP when `MCP_TOKEN` is set. */
+ kind: CapabilityKind;
+ /** Pure-ish: data in, structured data out. */
+ run: (input: I, ctx: CapabilityCtx) => Promise;
+ /**
+ * Optional. How the chat surface renders the result as an interactive widget.
+ * When present, the chat adapter emits a `data-card` part with this payload
+ * after `run()` resolves, and returns a compact text result to the model.
+ */
+ card?: (result: R) => CardPayload;
+}
+
+/**
+ * Environment passed to each capability's `promptFragment`. Carries everything
+ * the system-prompt fragments need; mirrors the inputs of the current
+ * `buildSystemPrompt` in the chat route (catalog list, focused tool, locale).
+ */
+export interface PromptEnv {
+ /** The full resolved catalog the assistant can reference. */
+ tools: MakerLabTool[];
+ /** The tool whose detail page the user is viewing, if any. */
+ focusedTool?: MakerLabTool | null;
+ /** Response locale, e.g. "en". */
+ locale?: string;
+}
+
+/**
+ * A capability: an ordered group of tools plus a shared prompt fragment.
+ * The registry (`capabilities/index.ts`) exports an ordered array of these.
+ */
+export interface Capability {
+ /** Stable id, e.g. "catalog" | "units" | "maintenance" | "intake". */
+ id: string;
+ /** Instructions appended to the system prompt for this capability. */
+ promptFragment: (env: PromptEnv) => string;
+ /** The tools this capability contributes. */
+ // Heterogeneous tools live together, so the element type is intentionally loose.
+ tools: CapabilityTool[];
+}
+
+// ── Card payloads (chat widgets) ───────────────────────────────────
+
+/** Lifecycle state of an identification card (design spec §4.1 / §6.3). */
+export type CardState = "proposed" | "success" | "duplicate";
+
+/** A single line of spec text rendered on a card (e.g. "Bed: 256mm"). */
+export interface CardSpecLine {
+ label: string;
+ value: string;
+}
+
+/** A found manual / video / other resource surfaced on a card. */
+export interface CardResource {
+ title: string;
+ url: string;
+ type: "Manual" | "Video" | "Other";
+}
+
+/**
+ * A taxonomy / unit row the create step will also create, with a flag for
+ * whether it is new (gets a "(new)" badge) vs. matched to an existing record.
+ */
+export interface CardAlsoCreating {
+ /** Human label, e.g. "Category: 3D Printing / FDM" or "Unit: Prusa #3". */
+ label: string;
+ /** What kind of side-entity this row represents. */
+ entity: "category" | "location" | "unit" | "resource";
+ /** True when this entity will be newly created in Notion. */
+ isNew: boolean;
+}
+
+/** An action button rendered on a card; clicking seeds a follow-up message. */
+export interface CardAction {
+ /** Stable id, e.g. "confirm" | "edit" | "discard" | "add-unit" | "add-all". */
+ id: string;
+ /** Button label, e.g. "Looks right — add it". */
+ label: string;
+ /** Text seeded into the chat input / send path when clicked. */
+ seedMessage: string;
+ /** Visual emphasis hint for the renderer. */
+ variant?: "primary" | "secondary" | "danger";
+}
+
+/**
+ * The identification card (design spec §4.1, §6.3). One card per candidate;
+ * `candidateId` lets confirm/edit/discard target the right item in a batch.
+ */
+export interface IdentificationCardPayload {
+ kind: "identification";
+ /** Correlates the card with a ToolCandidate for confirm/edit/discard. */
+ candidateId: string;
+ /** Card lifecycle state. */
+ state: CardState;
+ /** Proposed (or saved) tool name. */
+ name: string;
+ /** One or more photo URLs to show on the card. */
+ photoUrls: string[];
+ /** Resolved category label, e.g. "3D Printing / FDM". */
+ category?: string;
+ /** Resolved location label, e.g. "Main Lab / Print Zone". */
+ location?: string;
+ /** Spec lines (materials, build volume, power, etc.). */
+ specLines: CardSpecLine[];
+ /** Manuals / videos / other resources found during research. */
+ foundResources: CardResource[];
+ /** Side-entities the create step will also create, with `(new)` flags. */
+ alsoCreating: CardAlsoCreating[];
+ /** Action buttons (confirm / edit / discard / add-unit / add-all). */
+ actions: CardAction[];
+ /** Populated on `state: "success"` — link to the created draft page. */
+ draftUrl?: string;
+ /** Populated on `state: "duplicate"` — the existing catalog match. */
+ duplicateOf?: { id: string; name: string };
+}
+
+/**
+ * Discriminated union of every card payload the chat can render. New card kinds
+ * (future capabilities) extend this union; renderers switch on `kind`.
+ */
+export type CardPayload = IdentificationCardPayload;
+
+// ── ToolCandidate (design spec §4.2) ───────────────────────────────
+
+/**
+ * A normalized, researched equipment listing produced by `research_tool`,
+ * shown by `propose_listing`, and written by `create_tool`. Exactly the shape
+ * in design spec §4.2.
+ */
+export interface ToolCandidate {
+ name: string;
+ description: string;
+ category?: { name: string; group: string; isNew: boolean };
+ location?: { room: string; zone: string; isNew: boolean };
+ materials: string[];
+ ppe_required: string[];
+ tags: string[];
+ training_required?: boolean;
+ use_restrictions?: string;
+ units: { label: string; status?: string; condition?: string; serial?: string }[];
+ resources: { title: string; url: string; type: "Manual" | "Video" | "Other" }[];
+ /** Notion file_upload ids from `/api/upload-notion`. */
+ image_upload_ids: string[];
+ /** Provenance: URLs the agent read. */
+ source_urls: string[];
+ /** Catalog match, if any (drives the "add a unit instead" path). */
+ duplicate_of?: { id: string; name: string } | null;
+}
+
+/** Zod schema for {@link ToolCandidate}, for tool input validation. */
+export const toolCandidateSchema: z.ZodType = z.object({
+ name: z.string(),
+ description: z.string(),
+ category: z
+ .object({ name: z.string(), group: z.string(), isNew: z.boolean() })
+ .optional(),
+ location: z
+ .object({ room: z.string(), zone: z.string(), isNew: z.boolean() })
+ .optional(),
+ materials: z.array(z.string()),
+ ppe_required: z.array(z.string()),
+ tags: z.array(z.string()),
+ training_required: z.boolean().optional(),
+ use_restrictions: z.string().optional(),
+ units: z.array(
+ z.object({
+ label: z.string(),
+ status: z.string().optional(),
+ condition: z.string().optional(),
+ serial: z.string().optional(),
+ })
+ ),
+ resources: z.array(
+ z.object({
+ title: z.string(),
+ url: z.string(),
+ type: z.enum(["Manual", "Video", "Other"]),
+ })
+ ),
+ image_upload_ids: z.array(z.string()),
+ source_urls: z.array(z.string()),
+ duplicate_of: z
+ .object({ id: z.string(), name: z.string() })
+ .nullable()
+ .optional(),
+});
diff --git a/v5/src/lib/capabilities/units.ts b/v5/src/lib/capabilities/units.ts
new file mode 100644
index 0000000..e98f359
--- /dev/null
+++ b/v5/src/lib/capabilities/units.ts
@@ -0,0 +1,169 @@
+import { z } from "zod";
+import { getCatalogTools } from "../catalog";
+import {
+ buildUnitLookup,
+ findUnit,
+ recentMaintenance,
+ type MaintenanceEntry,
+ type UnitLookupEntry,
+} from "./helpers";
+import type { Capability, CapabilityTool, PromptEnv } from "./types";
+import type { MakerLabUnit } from "../../components/catalog-types";
+
+/**
+ * The `units` capability (design spec §3.4): tools for inspecting individual
+ * physical units and their maintenance history. Ported verbatim from the
+ * `get_unit_details` tool in the chat route and the `get_unit_details` /
+ * `get_maintenance_history` tools in the MCP route, now sharing the unit-lookup
+ * and maintenance helpers from `./helpers` so chat and MCP resolve units
+ * identically.
+ *
+ * Both tools are `read`. `run()` returns plain structured data; neither emits a
+ * card. Tools are surface-agnostic — they resolve the catalog themselves rather
+ * than relying on anything in `ctx`, so they degrade fine under the MCP adapter
+ * (no writer, no attachments).
+ */
+
+// ── get_unit_details ───────────────────────────────────────────────
+
+interface GetUnitDetailsInput {
+ unit_label: string;
+}
+
+type GetUnitDetailsResult =
+ | { found: false; message: string }
+ | {
+ found: true;
+ unit_id: string;
+ unit_label: string;
+ tool_name: string;
+ tool_slug: string;
+ status: MakerLabUnit["status"];
+ condition: MakerLabUnit["condition"];
+ location: string;
+ serial: string;
+ date_acquired: string | null;
+ detail_page: string;
+ maintenance_logs: MaintenanceEntry[];
+ };
+
+const getUnitDetailsInputSchema: z.ZodType = z.object({
+ unit_label: z
+ .string()
+ .describe("The unit label, e.g. 'Prusa #1' or 'Form 2 #1'."),
+});
+
+const getUnitDetails: CapabilityTool<
+ GetUnitDetailsInput,
+ GetUnitDetailsResult
+> = {
+ name: "get_unit_details",
+ description:
+ "Fetch details for a specific physical unit, including its status, condition, location, and recent maintenance history. Use when the student names or asks about a specific unit (e.g. 'Prusa #1', 'Form 2 #2') or reports an issue tied to one.",
+ inputSchema: getUnitDetailsInputSchema,
+ kind: "read",
+ run: async ({ unit_label }: GetUnitDetailsInput): Promise => {
+ const tools = await getCatalogTools();
+ const lookup = buildUnitLookup(tools);
+ const match = findUnit(lookup, unit_label);
+ if (!match) {
+ const sample = lookup
+ .slice(0, 8)
+ .map((u: UnitLookupEntry) => u.label)
+ .join(", ");
+ return {
+ found: false,
+ message: `No unit found matching "${unit_label}". Some known units: ${sample}${lookup.length > 8 ? "…" : ""}`,
+ };
+ }
+
+ return {
+ found: true,
+ unit_id: match.id,
+ unit_label: match.label,
+ tool_name: match.toolName,
+ tool_slug: match.toolSlug,
+ status: match.status,
+ condition: match.condition,
+ location: match.location,
+ serial: match.serial,
+ date_acquired: match.dateAcquired,
+ detail_page: `/tools/${match.toolSlug}`,
+ maintenance_logs: await recentMaintenance(match.id),
+ };
+ },
+};
+
+// ── get_maintenance_history ────────────────────────────────────────
+
+interface GetMaintenanceHistoryInput {
+ unit_label: string;
+}
+
+type GetMaintenanceHistoryResult =
+ | { found: false; message: string }
+ | {
+ found: true;
+ unit_label: string;
+ unit_id: string;
+ maintenance_logs: MaintenanceEntry[];
+ };
+
+const getMaintenanceHistoryInputSchema: z.ZodType =
+ z.object({
+ unit_label: z
+ .string()
+ .describe("The unit label to fetch maintenance history for"),
+ });
+
+const getMaintenanceHistory: CapabilityTool<
+ GetMaintenanceHistoryInput,
+ GetMaintenanceHistoryResult
+> = {
+ name: "get_maintenance_history",
+ description:
+ "Get recent maintenance logs for a unit by its label (e.g. 'Prusa #1'). Returns the most recent entries with type, priority, status, date, and description.",
+ inputSchema: getMaintenanceHistoryInputSchema,
+ kind: "read",
+ run: async ({
+ unit_label,
+ }: GetMaintenanceHistoryInput): Promise => {
+ const tools = await getCatalogTools();
+ const lookup = buildUnitLookup(tools);
+ const match = findUnit(lookup, unit_label);
+ if (!match) {
+ return {
+ found: false,
+ message: `No unit found matching "${unit_label}".`,
+ };
+ }
+ return {
+ found: true,
+ unit_label: match.label,
+ unit_id: match.id,
+ maintenance_logs: await recentMaintenance(match.id),
+ };
+ },
+};
+
+// ── Prompt fragment ────────────────────────────────────────────────
+
+/**
+ * The "Unit details" guidance from the current chat system prompt. References
+ * `get_unit_details`; `get_maintenance_history` is its read-only sibling that
+ * returns only the log history.
+ */
+function promptFragment(_env: PromptEnv): string {
+ return `## Unit details\n\nWhen a student asks about a specific unit ("how is Prusa #1 doing?", "is Form 2 #2 working?", "show me the history on the Trotec"), call \`get_unit_details\` to fetch its live status and recent maintenance history. Surface the status, condition, and a short recap of the most recent log entries. If the student only wants the maintenance history, \`get_maintenance_history\` returns just the recent log entries for a unit.`;
+}
+
+// ── Capability ─────────────────────────────────────────────────────
+
+export const units: Capability = {
+ id: "units",
+ promptFragment,
+ tools: [
+ getUnitDetails as CapabilityTool,
+ getMaintenanceHistory as CapabilityTool,
+ ],
+};
diff --git a/v5/src/lib/notion.ts b/v5/src/lib/notion.ts
index 30f983b..2f37a35 100644
--- a/v5/src/lib/notion.ts
+++ b/v5/src/lib/notion.ts
@@ -450,8 +450,11 @@ type NotionWriteProperty =
| { title: { text: { content: string } }[] }
| { rich_text: { text: { content: string } }[] }
| { select: { name: string } | null }
+ | { multi_select: { name: string }[] }
| { relation: { id: string }[] }
| { date: { start: string } | null }
+ | { checkbox: boolean }
+ | { url: string | null }
| { files: NotionWriteFile[] };
function titleProp(value: string): NotionWriteProperty {
@@ -466,6 +469,14 @@ function selectProp(value: string | undefined): NotionWriteProperty {
return value ? { select: { name: value } } : { select: null };
}
+function multiSelectProp(values: string[] | undefined): NotionWriteProperty {
+ return {
+ multi_select: (values || [])
+ .filter((value) => Boolean(value))
+ .map((name) => ({ name })),
+ };
+}
+
function relationProp(ids: string[] | undefined): NotionWriteProperty {
return { relation: (ids || []).map((id) => ({ id })) };
}
@@ -474,6 +485,14 @@ function dateProp(value: string | undefined): NotionWriteProperty {
return value ? { date: { start: value } } : { date: null };
}
+function checkboxProp(value: boolean | undefined): NotionWriteProperty {
+ return { checkbox: Boolean(value) };
+}
+
+function urlProp(value: string | undefined): NotionWriteProperty {
+ return { url: value ? value : null };
+}
+
function formatTicketDescription(
fields: Partial
): string {
@@ -538,6 +557,176 @@ export async function createMaintenanceLog(
return pageToMaintenanceLog(page);
}
+async function createPage(
+ table: CatalogTable,
+ properties: Record
+): Promise {
+ const { databases } = getNotionEnv();
+ return notionFetch("/pages", {
+ method: "POST",
+ body: JSON.stringify({
+ parent: { database_id: databases[table] },
+ properties,
+ }),
+ });
+}
+
+export async function createTool(
+ fields: Partial
+): Promise {
+ const properties: Record = {
+ name: titleProp(fields.name || "Untitled tool"),
+ // Draft-by-default: always unpublished regardless of input (spec §5/§8).
+ published: checkboxProp(false),
+ };
+
+ if (fields.description) {
+ properties.description = richTextProp(fields.description);
+ }
+ if (fields.category?.length) {
+ properties.category = relationProp(fields.category);
+ }
+ if (fields.location?.length) {
+ properties.location = relationProp(fields.location);
+ }
+ if (fields.materials?.length) {
+ properties.materials = multiSelectProp(fields.materials);
+ }
+ if (fields.ppe_required?.length) {
+ properties.ppe_required = multiSelectProp(fields.ppe_required);
+ }
+ if (fields.tags?.length) {
+ properties.tags = multiSelectProp(fields.tags);
+ }
+ if (typeof fields.training_required === "boolean") {
+ properties.training_required = checkboxProp(fields.training_required);
+ }
+ if (fields.use_restrictions) {
+ properties.use_restrictions = richTextProp(fields.use_restrictions);
+ }
+ if (fields.emergency_stop) {
+ properties.emergency_stop = richTextProp(fields.emergency_stop);
+ }
+ if (fields.notes) {
+ properties.notes = richTextProp(fields.notes);
+ }
+
+ // Image uploads ride on `fields.image_uploads` (Notion file_upload ids from
+ // /api/upload-notion), mirroring MaintenanceLogFields.photo_uploads. They are
+ // attached to the tool page's `image_attachments` files property at create time.
+ if (fields.image_uploads?.length) {
+ properties.image_attachments = fileUploadsProp(fields.image_uploads);
+ }
+
+ const page = await createPage("tools", properties);
+ return pageToTool(page);
+}
+
+export async function createUnit(
+ fields: Partial
+): Promise {
+ const properties: Record = {
+ unit_label: titleProp(fields.unit_label || "Unit"),
+ status: selectProp(fields.status || "Available"),
+ };
+
+ if (fields.tool?.length) {
+ properties.tool = relationProp(fields.tool);
+ }
+ if (fields.serial_number) {
+ properties.serial_number = richTextProp(fields.serial_number);
+ }
+ if (fields.asset_tag) {
+ properties.asset_tag = richTextProp(fields.asset_tag);
+ }
+ if (fields.condition) {
+ properties.condition = selectProp(fields.condition);
+ }
+ if (fields.date_acquired) {
+ properties.date_acquired = dateProp(fields.date_acquired);
+ }
+ if (fields.notes) {
+ properties.notes = richTextProp(fields.notes);
+ }
+
+ const page = await createPage("units", properties);
+ return pageToUnit(page);
+}
+
+export async function findOrCreateCategory(
+ name: string,
+ group: string
+): Promise<{ id: string; isNew: boolean }> {
+ const target = name.trim().toLowerCase();
+ const pages = await queryDatabase("categories").catch(() => [] as NotionPage[]);
+ const match = pages.find(
+ (page) => pageToCategory(page).fields.name.trim().toLowerCase() === target
+ );
+ if (match) {
+ return { id: match.id, isNew: false };
+ }
+
+ const page = await createPage("categories", {
+ name: titleProp(name),
+ group: selectProp(group),
+ });
+ return { id: page.id, isNew: true };
+}
+
+export async function findOrCreateLocation(
+ room: string,
+ zone: string
+): Promise<{ id: string; isNew: boolean }> {
+ const targetRoom = room.trim().toLowerCase();
+ const targetZone = zone.trim().toLowerCase();
+ const pages = await queryDatabase("locations").catch(() => [] as NotionPage[]);
+ const match = pages.find((page) => {
+ const location = pageToLocation(page).fields;
+ return (
+ location.room.trim().toLowerCase() === targetRoom &&
+ location.zone.trim().toLowerCase() === targetZone
+ );
+ });
+ if (match) {
+ return { id: match.id, isNew: false };
+ }
+
+ // LocationFields.id is the title property; compose a readable label.
+ const label = [room, zone].filter(Boolean).join(" — ") || room || zone;
+ const page = await createPage("locations", {
+ id: titleProp(label),
+ room: selectProp(room),
+ zone: selectProp(zone),
+ });
+ return { id: page.id, isNew: true };
+}
+
+export async function createResource(
+ fields: Partial
+): Promise {
+ const properties: Record = {
+ title: titleProp(fields.title || "Untitled resource"),
+ // Draft-by-default: always unpublished (spec §5/§8).
+ published: checkboxProp(false),
+ };
+
+ if (fields.tool?.length) {
+ properties.tool = relationProp(fields.tool);
+ }
+ if (fields.type) {
+ properties.type = selectProp(fields.type);
+ }
+ if (fields.url) {
+ properties.url = urlProp(fields.url);
+ }
+ if (fields.notes) {
+ properties.notes = richTextProp(fields.notes);
+ }
+
+ const page = await createPage("resources", properties);
+ return pageToResource(page);
+}
+
export function resolveTools(
tools: ToolRecord[],
categories: CategoryRecord[],
diff --git a/v5/src/lib/types.ts b/v5/src/lib/types.ts
index 983682f..1607865 100644
--- a/v5/src/lib/types.ts
+++ b/v5/src/lib/types.ts
@@ -49,6 +49,14 @@ export interface ToolFields {
image_attachments?: Attachment[];
notes?: string;
published?: boolean;
+ /**
+ * Write-only: references to images already uploaded via the Notion
+ * file_upload API. The read path returns URL-based attachments in
+ * `image_attachments`; this field is consumed when creating a record so
+ * the Notion `image_attachments` files property is built with
+ * `type: "file_upload"` entries.
+ */
+ image_uploads?: Array<{ id: string; name: string }>;
}
export type ToolRecord = NotionRecord;
diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css
index e2c3153..16d2574 100644
--- a/v5/src/styles/globals.css
+++ b/v5/src/styles/globals.css
@@ -1445,6 +1445,51 @@ p {
height: 16px;
}
+.chat-mic {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--outline);
+ background: var(--surface-container-low);
+ color: var(--on-surface-muted);
+ cursor: pointer;
+ border-radius: 10px !important;
+ transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
+}
+
+.chat-mic:hover:not(:disabled) {
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+.chat-mic:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.chat-mic svg {
+ width: 16px;
+ height: 16px;
+}
+
+.chat-mic-active {
+ border-color: var(--secondary);
+ color: var(--secondary);
+ animation: chat-mic-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes chat-mic-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.45;
+ }
+}
+
.chat-file-input {
display: none;
}
@@ -1577,6 +1622,301 @@ p {
font-size: 12px;
}
+/* A message bubble that hosts an identification card needs the full column
+ width and no bubble chrome — the card supplies its own surface. */
+.chat-msg-has-card {
+ max-width: 100%;
+ align-self: stretch;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0 !important;
+}
+
+/* ── Identification card (intake) ────────────────────────────────── */
+.id-card {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 8px;
+ padding: 14px;
+ border: 1px solid var(--outline);
+ background: var(--surface-container);
+ border-radius: 14px !important;
+ font-size: 13px;
+ color: var(--on-surface);
+}
+
+.id-card:first-child {
+ margin-top: 0;
+}
+
+.id-card-success {
+ border-color: var(--primary);
+}
+
+.id-card-duplicate {
+ border-color: var(--secondary);
+}
+
+.id-card-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 8px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ border-radius: 10px !important;
+}
+
+.id-card-banner-success {
+ background: var(--surface-container-low);
+ color: var(--primary);
+ border: 1px solid var(--primary);
+}
+
+.id-card-banner-duplicate {
+ background: var(--surface-container-low);
+ color: var(--secondary);
+ border: 1px solid var(--secondary);
+}
+
+.id-card-banner-icon {
+ display: inline-flex;
+}
+
+.id-card-banner-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.id-card-head {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.id-card-photo {
+ position: relative;
+ flex: 0 0 auto;
+ width: 64px;
+ height: 64px;
+ border-radius: 10px;
+ overflow: hidden;
+ border: 1px solid var(--outline);
+ background: var(--surface-container-low);
+}
+
+.id-card-photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.id-card-photo-count {
+ position: absolute;
+ right: 3px;
+ bottom: 3px;
+ padding: 1px 5px;
+ font-size: 10px;
+ font-weight: 700;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px !important;
+}
+
+.id-card-headings {
+ min-width: 0;
+}
+
+.id-card-name {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 800;
+ line-height: 1.3;
+ color: var(--on-surface);
+}
+
+.id-card-meta {
+ margin: 3px 0 0;
+ font-size: 12px;
+ color: var(--on-surface-muted);
+}
+
+.id-card-specs {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 4px 12px;
+ margin: 0;
+ padding: 10px 0;
+ border-top: 1px solid var(--outline);
+ border-bottom: 1px solid var(--outline);
+}
+
+.id-card-spec {
+ display: contents;
+}
+
+.id-card-spec dt {
+ font-weight: 600;
+ color: var(--on-surface-muted);
+}
+
+.id-card-spec dd {
+ margin: 0;
+ color: var(--on-surface);
+}
+
+.id-card-resources {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.id-card-resource {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid var(--outline);
+ background: var(--surface-container-low);
+ border-radius: 10px !important;
+ color: var(--on-surface);
+ text-decoration: none;
+ transition: border-color 120ms ease;
+}
+
+.id-card-resource:hover {
+ border-color: var(--primary);
+}
+
+.id-card-resource-icon {
+ display: inline-flex;
+ color: var(--on-surface-muted);
+}
+
+.id-card-resource-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.id-card-resource-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+
+.id-card-resource-type {
+ flex: 0 0 auto;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--on-surface-muted);
+}
+
+.id-card-also-label {
+ margin: 0 0 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--on-surface-muted);
+}
+
+.id-card-also-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.id-card-also-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--on-surface);
+}
+
+.id-card-badge-new {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.id-card-draft-link {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--primary);
+ text-decoration: underline;
+}
+
+.id-card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.id-card-action {
+ flex: 1 1 auto;
+ min-width: 96px;
+ padding: 9px 12px;
+ font-family: var(--font-body);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ border-radius: 10px !important;
+ border: 1px solid var(--outline);
+ background: var(--surface-container-low);
+ color: var(--on-surface);
+ transition: border-color 120ms ease, background 120ms ease, transform 80ms ease,
+ opacity 120ms ease;
+}
+
+.id-card-action:hover:not(:disabled) {
+ border-color: var(--primary);
+}
+
+.id-card-action:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+.id-card-action:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.id-card-action-primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #0f0f0f;
+}
+
+.id-card-action-primary:hover:not(:disabled) {
+ background: var(--primary-dark);
+ border-color: var(--primary-dark);
+}
+
+.id-card-action-danger {
+ color: var(--secondary);
+ border-color: var(--outline);
+}
+
+.id-card-action-danger:hover:not(:disabled) {
+ border-color: var(--secondary);
+}
+
.chat-typing {
display: inline-flex;
align-items: center;
From 5e58847011fdde64e1f70ae9117fba45c21ef9c6 Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 01:12:04 -0400
Subject: [PATCH 3/7] Fix test suite after capability-registry refactor +
#25/#29 rebase
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Wrap shared test render helper in ChatLauncherProvider so component
tests (ChatFab/PrimaryNav/GlobalChrome) render — these were red on main
from the PR #25 (launcher) / #29 (test-infra) merge collision.
- Add webSearch_20250305 to the chat route test's anthropic mock (the
refactored route now wires native web_search alongside web_fetch).
- Add a `chatOnly` flag to CapabilityTool; mark intake research_tool /
propose_listing chat-only (card/orchestration tools) so the MCP surface
keeps its read-tool set and exposes only headless-meaningful tools.
- Update MCP route tests to the structured-JSON contract (design §3.3):
not-found is a `found:false` result, not isError.
v5: typecheck/lint/build green, 232/232 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/package-lock.json | 1 +
v5/src/app/api/chat/route.test.ts | 1 +
v5/src/app/api/mcp/route.test.ts | 77 +++++++++++++++-----------
v5/src/lib/capabilities/intake.ts | 5 ++
v5/src/lib/capabilities/mcp-adapter.ts | 3 +
v5/src/lib/capabilities/types.ts | 7 +++
v5/test/utils/render.tsx | 3 +-
7 files changed, 63 insertions(+), 34 deletions(-)
diff --git a/v5/package-lock.json b/v5/package-lock.json
index bd7e799..8a9710e 100644
--- a/v5/package-lock.json
+++ b/v5/package-lock.json
@@ -11574,6 +11574,7 @@
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
diff --git a/v5/src/app/api/chat/route.test.ts b/v5/src/app/api/chat/route.test.ts
index a787ea3..5537fa6 100644
--- a/v5/src/app/api/chat/route.test.ts
+++ b/v5/src/app/api/chat/route.test.ts
@@ -15,6 +15,7 @@ vi.mock("@ai-sdk/anthropic", () => {
{
tools: {
webFetch_20250910: vi.fn(() => ({ type: "web_fetch_mock" })),
+ webSearch_20250305: vi.fn(() => ({ type: "web_search_mock" })),
},
}
);
diff --git a/v5/src/app/api/mcp/route.test.ts b/v5/src/app/api/mcp/route.test.ts
index fa5675d..5cb397b 100644
--- a/v5/src/app/api/mcp/route.test.ts
+++ b/v5/src/app/api/mcp/route.test.ts
@@ -218,29 +218,32 @@ describe("JSON-RPC protocol", () => {
// ── tools/call: list_tools ───────────────────────────────────────────
describe("tools/call: list_tools", () => {
- it("returns a 'Found N tools' summary of the whole catalog", async () => {
+ // The MCP adapter serializes each tool's structured result as pretty-printed
+ // JSON (design spec §3.3), so we parse and assert on the data shape.
+ it("returns a structured summary of the whole catalog", async () => {
const { json } = await callTool("list_tools");
- const text = resultText(json);
+ const parsed = JSON.parse(resultText(json));
// mock-catalog has 2 tools (Form 4, Trotec Speedy 400).
- expect(text).toMatch(/Found 2 tools/);
- expect(text).toContain("Form 4");
- expect(text).toContain("Trotec Speedy 400");
+ expect(parsed.count).toBe(2);
+ const names = parsed.tools.map((t: { name: string }) => t.name);
+ expect(names).toContain("Form 4");
+ expect(names).toContain("Trotec Speedy 400");
});
it("narrows results with a category filter", async () => {
const { json } = await callTool("list_tools", { category: "laser" });
- const text = resultText(json);
- expect(text).toMatch(/Found 1 tools/);
- expect(text).toContain("Trotec Speedy 400");
- expect(text).not.toContain("Form 4 |");
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.count).toBe(1);
+ const names = parsed.tools.map((t: { name: string }) => t.name);
+ expect(names).toEqual(["Trotec Speedy 400"]);
});
it("narrows results with a location filter", async () => {
const { json } = await callTool("list_tools", { location: "Resin" });
- const text = resultText(json);
- expect(text).toMatch(/Found 1 tools/);
- expect(text).toContain("Form 4");
- expect(text).not.toContain("Trotec Speedy 400");
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.count).toBe(1);
+ const names = parsed.tools.map((t: { name: string }) => t.name);
+ expect(names).toEqual(["Form 4"]);
});
});
@@ -249,15 +252,17 @@ describe("tools/call: list_tools", () => {
describe("tools/call: search_tools", () => {
it("returns matching tools on a hit", async () => {
const { json } = await callTool("search_tools", { query: "resin" });
- const text = resultText(json);
- expect(text).toMatch(/Found 1 tools/);
- expect(text).toContain("Form 4");
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.count).toBe(1);
+ expect(parsed.tools.map((t: { name: string }) => t.name)).toContain("Form 4");
});
- it("returns a 'No tools found matching' message on a miss", async () => {
+ it("returns an empty result set on a miss", async () => {
const { json } = await callTool("search_tools", { query: "nonexistent-widget-xyz" });
- const text = resultText(json);
- expect(text).toMatch(/No tools found matching "nonexistent-widget-xyz"/);
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.query).toBe("nonexistent-widget-xyz");
+ expect(parsed.count).toBe(0);
+ expect(parsed.tools).toEqual([]);
});
});
@@ -283,11 +288,13 @@ describe("tools/call: get_tool_details", () => {
expect(parsed.name).toBe("Trotec Speedy 400");
});
- it("returns isError:true for an unknown id", async () => {
+ it("returns a structured not-found result for an unknown id", async () => {
const { json } = await callTool("get_tool_details", { id_or_name: "no-such-tool-id" });
- expect(json.result.isError).toBe(true);
- const text = resultText(json);
- expect(text).toMatch(/Tool not found: no-such-tool-id/);
+ // Not-found is a normal structured result (found:false), not a tool error.
+ expect(json.result.isError).toBeFalsy();
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.found).toBe(false);
+ expect(parsed.message).toMatch(/Tool not found: no-such-tool-id/);
});
});
@@ -305,10 +312,11 @@ describe("tools/call: get_unit_details", () => {
expect(parsed.maintenance_logs).toEqual([]);
});
- it("returns a 'No unit found' message for an unknown label", async () => {
+ it("returns a structured not-found result for an unknown label", async () => {
const { json } = await callTool("get_unit_details", { unit_label: "no-such-unit-999" });
- const text = resultText(json);
- expect(text).toMatch(/No unit found matching "no-such-unit-999"/);
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.found).toBe(false);
+ expect(parsed.message).toMatch(/No unit found matching "no-such-unit-999"/);
});
});
@@ -345,21 +353,24 @@ describe("tools/call: get_maintenance_history", () => {
expect(fetchLogsMock).toHaveBeenCalledWith("unit-form-4-a");
});
- it("returns 'No maintenance history' when the unit has no logs", async () => {
+ it("returns an empty log list when the unit has no logs", async () => {
fetchLogsMock.mockResolvedValue([]);
const { json } = await callTool("get_maintenance_history", {
unit_label: "Form 4 // A",
});
- const text = resultText(json);
- expect(text).toMatch(/No maintenance history for Form 4 \/\/ A/);
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.found).toBe(true);
+ expect(parsed.unit_label).toBe("Form 4 // A");
+ expect(parsed.maintenance_logs).toEqual([]);
});
- it("returns isError:true for an unknown unit label", async () => {
+ it("returns a structured not-found result for an unknown unit label", async () => {
const { json } = await callTool("get_maintenance_history", {
unit_label: "no-such-unit-999",
});
- expect(json.result.isError).toBe(true);
- const text = resultText(json);
- expect(text).toMatch(/No unit found matching "no-such-unit-999"/);
+ expect(json.result.isError).toBeFalsy();
+ const parsed = JSON.parse(resultText(json));
+ expect(parsed.found).toBe(false);
+ expect(parsed.message).toMatch(/No unit found matching "no-such-unit-999"/);
});
});
diff --git a/v5/src/lib/capabilities/intake.ts b/v5/src/lib/capabilities/intake.ts
index 13f9641..df0347a 100644
--- a/v5/src/lib/capabilities/intake.ts
+++ b/v5/src/lib/capabilities/intake.ts
@@ -108,6 +108,9 @@ const researchTool: CapabilityTool = {
"Normalize a researched equipment candidate and check the catalog for duplicates. BEFORE calling this, use the native web_search / web_fetch tools (and any attached photos) to gather the canonical name, manufacturer, specs, materials, PPE, a manual PDF URL, and a setup video URL. Pass your best-effort ToolCandidate; this tool runs a catalog search on the name and returns the candidate annotated with duplicate_of when a strong existing match is found. Read-only — it writes nothing.",
inputSchema: researchInputSchema,
kind: "read",
+ // Relies on the chat model's native web_search/web_fetch + attached photos;
+ // has no meaning as a standalone headless MCP tool.
+ chatOnly: true,
run: async ({ candidate }, ctx: CapabilityCtx): Promise => {
// Carry through any image uploads attached to this chat turn so the eventual
// create_tool can re-attach the same photos without re-uploading.
@@ -292,6 +295,8 @@ const proposeListing: CapabilityTool = {
"Show the user an identification card for each researched candidate so they can confirm before anything is written. This is the mandatory confirmation gate: ALWAYS call propose_listing and wait for an explicit user confirmation before create_tool. Pass multiple candidates to confirm a batch — each renders an independent card. When a candidate has a duplicate_of match, its card offers 'Add a unit to the existing tool' instead of creating a new tool. Read-only.",
inputSchema: proposeInputSchema,
kind: "read",
+ // Drives interactive identification cards in the chat UI; not an MCP tool.
+ chatOnly: true,
run: async ({ candidates }): Promise => {
return { candidates };
},
diff --git a/v5/src/lib/capabilities/mcp-adapter.ts b/v5/src/lib/capabilities/mcp-adapter.ts
index 8383448..af421a3 100644
--- a/v5/src/lib/capabilities/mcp-adapter.ts
+++ b/v5/src/lib/capabilities/mcp-adapter.ts
@@ -72,6 +72,9 @@ export function registerAll(
): void {
for (const capability of capabilities) {
for (const tool of capability.tools) {
+ // Chat-only tools (interactive cards / chat-native web research) have no
+ // meaning headlessly and are never exposed over MCP.
+ if (tool.chatOnly) continue;
if (tool.kind === "write" && !opts.allowWrites) continue;
registerTool(server, tool);
}
diff --git a/v5/src/lib/capabilities/types.ts b/v5/src/lib/capabilities/types.ts
index 3fd6964..a097d84 100644
--- a/v5/src/lib/capabilities/types.ts
+++ b/v5/src/lib/capabilities/types.ts
@@ -81,6 +81,13 @@ export interface CapabilityTool {
inputSchema: z.ZodType;
/** "write" tools are only registered over MCP when `MCP_TOKEN` is set. */
kind: CapabilityKind;
+ /**
+ * Optional. When true, the tool is exposed only on the chat surface and never
+ * registered over MCP. Use for chat-orchestration tools that have no meaning
+ * headlessly — e.g. tools that drive interactive cards or rely on the chat
+ * model's native web tools (intake's `research_tool` / `propose_listing`).
+ */
+ chatOnly?: boolean;
/** Pure-ish: data in, structured data out. */
run: (input: I, ctx: CapabilityCtx) => Promise;
/**
diff --git a/v5/test/utils/render.tsx b/v5/test/utils/render.tsx
index 2ad1230..580b90a 100644
--- a/v5/test/utils/render.tsx
+++ b/v5/test/utils/render.tsx
@@ -6,6 +6,7 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import enMessages from "../../messages/en.json";
+import { ChatLauncherProvider } from "../../src/components/ChatLauncherContext";
type Messages = typeof enMessages;
@@ -32,7 +33,7 @@ export function render(
function Wrapper({ children }: { children: ReactNode }) {
return (
- {children}
+ {children}
);
}
From fa4555bed9a9b3ca73d2abbbbfe72e7336c8f720 Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 04:40:04 -0400
Subject: [PATCH 4/7] Verify resource links in intake; tighten anti-fabrication
+ formatting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The agent could fabricate plausible-but-fake resource URLs (observed: an
invented YouTube video id that 404s on oEmbed). Add server-side link
verification:
- verifyUrl/verifyResourceLinks: YouTube checked via oEmbed (authoritative
for existence — a watch page 200s even for dead videos), others via an
HTTP GET treating only 404/410/DNS/malformed as invalid (so a bot-blocked
but real manual isn't false-dropped).
- research_tool prunes unverified links from the candidate and returns them
as dropped_links so the agent can tell the user.
- create_tool re-verifies as a hard gate before writing resources; dropped
links are reported in warnings, never written.
- Prompt: forbid fabricating/guessing URLs (esp. video URLs), explain that
links are verified server-side, and require tight, structured messages
instead of multi-paragraph research narration.
Resources are already created as drafts (published=false) — confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/src/lib/capabilities/intake.ts | 124 +++++++++++++++++++++++++++++-
1 file changed, 120 insertions(+), 4 deletions(-)
diff --git a/v5/src/lib/capabilities/intake.ts b/v5/src/lib/capabilities/intake.ts
index df0347a..7e902f3 100644
--- a/v5/src/lib/capabilities/intake.ts
+++ b/v5/src/lib/capabilities/intake.ts
@@ -88,6 +88,100 @@ async function detectDuplicate(
return match ? { id: match.id, name: match.name } : null;
}
+// ── Link verification ──────────────────────────────────────────────
+
+const VERIFY_UA = "Mozilla/5.0 (compatible; MakerLabBot/1.0)";
+const VERIFY_TIMEOUT_MS = 8000;
+
+function isYouTubeHost(host: string): boolean {
+ const h = host.replace(/^www\./, "");
+ return (
+ h === "youtube.com" ||
+ h === "m.youtube.com" ||
+ h === "youtu.be" ||
+ h.endsWith(".youtube.com")
+ );
+}
+
+/**
+ * Verify a single resource URL actually resolves to a real page/video, to catch
+ * the model fabricating plausible-looking URLs (e.g. invented YouTube video ids).
+ *
+ * - YouTube: the oEmbed endpoint is authoritative — it returns 404 for a video
+ * id that does not exist (a normal `watch?v=` page returns HTTP 200 even for
+ * dead videos, so a status check alone is not enough).
+ * - Everything else: a GET that only treats definitive "not found" signals
+ * (404/410, DNS/network failure, malformed URL) as invalid. 401/403/429/5xx
+ * are kept — they mean the resource exists but is gated or transiently
+ * erroring, and we'd rather not drop a real manual on a bot block.
+ */
+async function verifyUrl(url: string): Promise<{ ok: boolean; reason?: string }> {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { ok: false, reason: "malformed URL" };
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return { ok: false, reason: "not an http(s) URL" };
+ }
+
+ if (isYouTubeHost(parsed.hostname)) {
+ try {
+ const res = await fetch(
+ `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ { signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS) }
+ );
+ if (res.status === 200) return { ok: true };
+ if (res.status === 404 || res.status === 401)
+ return { ok: false, reason: "video does not exist" };
+ return { ok: true }; // transient/unknown — don't false-drop
+ } catch {
+ return { ok: false, reason: "video lookup failed" };
+ }
+ }
+
+ try {
+ const res = await fetch(url, {
+ method: "GET",
+ redirect: "follow",
+ headers: { "User-Agent": VERIFY_UA },
+ signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
+ });
+ if (res.status === 404 || res.status === 410) {
+ return { ok: false, reason: `HTTP ${res.status}` };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false, reason: "unreachable" };
+ }
+}
+
+/**
+ * Verify every resource link, preserving order. Returns the verified resources
+ * (safe to surface/write) and a human-readable note for each dropped link so the
+ * agent can tell the user instead of silently omitting it.
+ */
+async function verifyResourceLinks(
+ resources: ToolCandidate["resources"]
+): Promise<{ verified: ToolCandidate["resources"]; dropped: string[] }> {
+ const checked = await Promise.all(
+ resources.map(async (r) => ({ resource: r, result: await verifyUrl(r.url) }))
+ );
+ const verified: ToolCandidate["resources"] = [];
+ const dropped: string[] = [];
+ for (const { resource, result } of checked) {
+ if (result.ok) {
+ verified.push(resource);
+ } else {
+ dropped.push(
+ `${resource.type} "${resource.title}" (${resource.url}) — ${result.reason}`
+ );
+ }
+ }
+ return { verified, dropped };
+}
+
// ── research_tool ──────────────────────────────────────────────────
const researchInputSchema = z.object({
@@ -100,6 +194,12 @@ type ResearchInput = z.infer;
interface ResearchResult {
candidate: ToolCandidate;
duplicate: { id: string; name: string } | null;
+ /**
+ * Resource links that failed verification and were removed from the candidate
+ * (e.g. a fabricated YouTube URL). Tell the user these could not be verified
+ * and were left out — do NOT invent replacements.
+ */
+ dropped_links: string[];
}
const researchTool: CapabilityTool = {
@@ -121,19 +221,25 @@ const researchTool: CapabilityTool = {
const duplicate = await detectDuplicate(candidate.name);
+ // Verify every resource link actually resolves, dropping fabricated or dead
+ // URLs (e.g. an invented YouTube video id) so they never reach the card.
+ const { verified, dropped } = await verifyResourceLinks(
+ candidate.resources || []
+ );
+
const normalized: ToolCandidate = {
...candidate,
materials: candidate.materials || [],
ppe_required: candidate.ppe_required || [],
tags: candidate.tags || [],
units: candidate.units || [],
- resources: candidate.resources || [],
+ resources: verified,
image_upload_ids: mergedImageIds,
source_urls: candidate.source_urls || [],
duplicate_of: duplicate,
};
- return { candidate: normalized, duplicate };
+ return { candidate: normalized, duplicate, dropped_links: dropped };
},
};
@@ -448,7 +554,15 @@ const createToolTool: CapabilityTool = {
}
// 4. Create resources linked to the tool (best-effort per resource).
- for (const resource of candidate.resources) {
+ // Re-verify links here as a hard gate: even if research_tool already
+ // pruned fabricated URLs, the model could pass a fresh unverified link
+ // straight to create_tool. Dropped links are reported, never written.
+ const { verified: verifiedResources, dropped: droppedResources } =
+ await verifyResourceLinks(candidate.resources);
+ for (const link of droppedResources) {
+ warnings.push(`Skipped unverifiable link — ${link}`);
+ }
+ for (const resource of verifiedResources) {
try {
await createResource({
title: resource.title,
@@ -512,8 +626,10 @@ function errMsg(err: unknown): string {
function promptFragment(_env: PromptEnv): string {
return [
`## Adding equipment to the inventory (intake)`,
- `When someone wants to add a tool to the catalog — from a free-text description, dictated notes, attached photos, or pasted product/store/manual URLs — act as an intake agent and follow this flow:`,
+ `When someone wants to add a tool to the catalog — from a free-text description, dictated notes, attached photos, or pasted product/store/manual URLs — act as an intake agent and follow this flow.`,
+ `**Keep your messages tight.** Do not narrate every \`web_fetch\`/\`web_search\` step in long paragraphs — a single short line like "Researching the Creality Ender-3 V3…" is enough while you work. Let the identification card carry the structured result; don't restate the whole card as prose. Use clean markdown (bold labels, tight bullet lists), never a wall of text.`,
`1. **Research first.** Use the native \`web_search\` and \`web_fetch\` tools (and any attached photos) to identify the equipment: canonical name and manufacturer, a one-paragraph description, key specs, typical materials and required PPE, sensible tags, a manual PDF URL, and a setup/overview video URL. Read product pages and manuals before guessing. Track every URL you actually read so you can pass it as \`source_urls\` for provenance.`,
+ ` **Never fabricate or guess a URL.** Only include a manual or video link that you actually opened with \`web_fetch\` and confirmed is the correct item — especially video URLs (never assemble a \`youtube.com/watch?v=…\` link from memory; you must have retrieved that exact video). If you can't find a real link, omit it rather than inventing one. \`research_tool\` and \`create_tool\` independently verify every link server-side (YouTube via oEmbed, others via an HTTP check) and drop any that don't resolve, returning them as \`dropped_links\` / \`warnings\`. When a link is dropped, tell the user it couldn't be verified and was left out — do not invent a replacement.`,
`2. **Normalize.** Call \`research_tool\` with your best-effort \`ToolCandidate\`. It checks the catalog for duplicates and returns the candidate annotated with \`duplicate_of\` when a strong match already exists. Propose a \`category\` (with its group) and a \`location\` (room + zone), setting \`isNew\` to your best judgment; staff will confirm. Include at least one \`unit\` (e.g. " #1") unless the user is clearly describing a consumable.`,
`3. **Confirm — always.** Call \`propose_listing\` with the candidate(s). This renders an identification card. **Never call \`create_tool\` without first calling \`propose_listing\` and getting an explicit user confirmation** (a click on "Looks right — add it", or a typed "yes / add it"). Wait for that confirmation.`,
`4. **Handle duplicates.** If \`research_tool\` reported a \`duplicate_of\`, the card surfaces "Already in catalog". Offer to **add a unit to the existing tool** rather than creating a new tool, unless the user explicitly wants a separate listing.`,
From e445e9f7e7b8c73439755a0a40be23d2deb2dfa7 Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 04:48:58 -0400
Subject: [PATCH 5/7] Surface real chat error messages to users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chat showed a generic "Something went wrong. Try again." for every
failure, hiding what actually happened (e.g. a transient Anthropic 529
overload looked identical to a real bug).
- route: add onError (describeChatError) to createUIMessageStream and the
merged toUIMessageStream so the SDK's masked default is replaced with a
concise, user-facing reason — mapping 529/overloaded, 429/rate-limit,
timeouts, and auth errors, and otherwise passing the real message through.
- ChatFab: render error.message verbatim, falling back to the generic
translated string only when the error carries no message.
- tests updated: assert the real message is surfaced + the empty-message
fallback.
Verified live against an ongoing Anthropic 529: the chat now reads
"The AI service is temporarily overloaded… please try again."
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/src/app/api/chat/route.ts | 37 +++++++++++++++++++++++++++++-
v5/src/components/ChatFab.test.tsx | 22 ++++++++++++++++--
v5/src/components/ChatFab.tsx | 2 +-
3 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts
index e5f6d68..3116b8f 100644
--- a/v5/src/app/api/chat/route.ts
+++ b/v5/src/app/api/chat/route.ts
@@ -86,6 +86,9 @@ export async function POST(req: Request) {
const modelMessages = attachManualsToFirstUserMessage(baseMessages, manuals);
const stream = createUIMessageStream({
+ // Surface a useful, user-facing reason instead of the SDK's masked default
+ // (e.g. distinguish an Anthropic "overloaded" 529 from a real bug).
+ onError: describeChatError,
execute: ({ writer }) => {
if (manuals.length > 0) {
writer.write({
@@ -138,13 +141,45 @@ export async function POST(req: Request) {
stopWhen: stepCountIs(10),
});
- writer.merge(result.toUIMessageStream());
+ writer.merge(result.toUIMessageStream({ onError: describeChatError }));
},
});
return createUIMessageStreamResponse({ stream });
}
+/**
+ * Map a streaming/model error to a concise, user-facing message. The AI SDK
+ * masks error text by default ("An error occurred"); this surfaces the actual
+ * reason so users aren't left with a dead-end "Something went wrong" — most
+ * importantly distinguishing a transient Anthropic overload (HTTP 529) from a
+ * genuine bug. Returned text is shown verbatim in the chat error row.
+ */
+function describeChatError(error: unknown): string {
+ const err = error as
+ | { statusCode?: number; status?: number; message?: string; name?: string }
+ | undefined;
+ const status = err?.statusCode ?? err?.status;
+ const message = (err?.message || "").toLowerCase();
+
+ if (status === 529 || message.includes("overloaded")) {
+ return "The AI service is temporarily overloaded (this is on the provider's side, not your request). Please try again in a few moments.";
+ }
+ if (status === 429 || message.includes("rate limit") || message.includes("too many requests")) {
+ return "Too many requests right now — please wait a moment and try again.";
+ }
+ if (message.includes("timeout") || message.includes("timed out") || err?.name === "TimeoutError") {
+ return "The request took too long and timed out. Please try again.";
+ }
+ if (status === 401 || status === 403 || message.includes("api key") || message.includes("authentication")) {
+ return "The assistant is misconfigured (authentication failed). Please let a lab admin know.";
+ }
+ const detail = err?.message?.trim();
+ return detail
+ ? `Something went wrong: ${detail}`
+ : "Something went wrong. Please try again.";
+}
+
// ── Attachments / vision (design spec §6.1) ────────────────────────
/**
diff --git a/v5/src/components/ChatFab.test.tsx b/v5/src/components/ChatFab.test.tsx
index d95fe17..9f2e7cf 100644
--- a/v5/src/components/ChatFab.test.tsx
+++ b/v5/src/components/ChatFab.test.tsx
@@ -224,11 +224,29 @@ describe("ChatFab", () => {
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
- it("renders the error row when useChat returns an error", async () => {
+ it("surfaces the actual error message when useChat returns an error", async () => {
const user = userEvent.setup();
useChatReturn = baseReturn({
messages: [userMsg("u1", "hi"), assistantMsg("a1", "hello")],
- error: new Error("boom"),
+ error: new Error("The AI service is temporarily overloaded."),
+ });
+ render( );
+
+ await user.click(
+ screen.getByRole("button", { name: "Open MakerLab assistant" })
+ );
+
+ // The real message is shown verbatim, not the generic fallback.
+ expect(
+ screen.getByText("The AI service is temporarily overloaded.")
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to the generic error text when the error has no message", async () => {
+ const user = userEvent.setup();
+ useChatReturn = baseReturn({
+ messages: [userMsg("u1", "hi"), assistantMsg("a1", "hello")],
+ error: new Error(""),
});
render( );
diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx
index c8c1d37..37e9bb2 100644
--- a/v5/src/components/ChatFab.tsx
+++ b/v5/src/components/ChatFab.tsx
@@ -643,7 +643,7 @@ export function ChatFab() {
) : null}
{error ? (
- {t("error")}
+ {error.message?.trim() ? error.message : t("error")}
) : null}
From 882515ca93a3fc14d8443acd3dbd13267c03b589 Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 05:07:22 -0400
Subject: [PATCH 6/7] Fix multi-select comma write failure; honest error card
state
Live testing surfaced a real write bug (now visible thanks to the error
surfacing): Notion rejects commas in multi-select option names, so a
material like "Wood (plywood, hardwood, veneer)" 400'd the whole tool
create, yet the success card still claimed "saved".
- notion.ts: multiSelectProp rewrites commas to " / " and de-dupes, so
materials/ppe/tags always satisfy Notion's constraint.
- intake.ts: create_tool card uses a new "error" state when the write
didn't actually land (success===false || no tool_id) instead of a
misleading "Saved as a draft" badge; prompt now asks for short discrete
tag values (no embedded commas).
- types/IdentificationCard/globals: add the "error" card state + banner.
Verified live: Glowforge Aura now writes cleanly (tool+unit+2 resources,
all published=false, both links live-verified 200).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/src/components/IdentificationCard.tsx | 6 ++++++
v5/src/lib/capabilities/intake.ts | 6 +++++-
v5/src/lib/capabilities/types.ts | 2 +-
v5/src/lib/notion.ts | 17 ++++++++++++-----
v5/src/styles/globals.css | 6 ++++++
5 files changed, 30 insertions(+), 7 deletions(-)
diff --git a/v5/src/components/IdentificationCard.tsx b/v5/src/components/IdentificationCard.tsx
index 7520bc2..e360a38 100644
--- a/v5/src/components/IdentificationCard.tsx
+++ b/v5/src/components/IdentificationCard.tsx
@@ -129,6 +129,7 @@ function Identification({
}) {
const isSuccess = card.state === "success";
const isDuplicate = card.state === "duplicate";
+ const isError = card.state === "error";
const photo = card.photoUrls[0];
return (
@@ -144,6 +145,11 @@ function Identification({
Saved as a draft — staff will publish it.
) : null}
+ {isError ? (
+
+ Couldn’t be saved — see the details below.
+
+ ) : null}
{isDuplicate && card.duplicateOf ? (
Already in catalog: {card.duplicateOf.name}
diff --git a/v5/src/lib/capabilities/intake.ts b/v5/src/lib/capabilities/intake.ts
index 7e902f3..dc4c108 100644
--- a/v5/src/lib/capabilities/intake.ts
+++ b/v5/src/lib/capabilities/intake.ts
@@ -591,7 +591,10 @@ const createToolTool: CapabilityTool = {
card: (result: CreateResult): IdentificationCardPayload => ({
kind: "identification",
candidateId: candidateId(result.name),
- state: "success",
+ // Honest state: only "success" when the tool actually landed. A failed or
+ // partial write (e.g. a Notion validation error) shows the "error" banner
+ // with the warnings as detail lines, never a misleading "saved" badge.
+ state: result.success && result.tool_id ? "success" : "error",
name: result.name,
photoUrls: [],
specLines: result.warnings.map((w) => ({ label: "Note", value: w })),
@@ -630,6 +633,7 @@ function promptFragment(_env: PromptEnv): string {
`**Keep your messages tight.** Do not narrate every \`web_fetch\`/\`web_search\` step in long paragraphs — a single short line like "Researching the Creality Ender-3 V3…" is enough while you work. Let the identification card carry the structured result; don't restate the whole card as prose. Use clean markdown (bold labels, tight bullet lists), never a wall of text.`,
`1. **Research first.** Use the native \`web_search\` and \`web_fetch\` tools (and any attached photos) to identify the equipment: canonical name and manufacturer, a one-paragraph description, key specs, typical materials and required PPE, sensible tags, a manual PDF URL, and a setup/overview video URL. Read product pages and manuals before guessing. Track every URL you actually read so you can pass it as \`source_urls\` for provenance.`,
` **Never fabricate or guess a URL.** Only include a manual or video link that you actually opened with \`web_fetch\` and confirmed is the correct item — especially video URLs (never assemble a \`youtube.com/watch?v=…\` link from memory; you must have retrieved that exact video). If you can't find a real link, omit it rather than inventing one. \`research_tool\` and \`create_tool\` independently verify every link server-side (YouTube via oEmbed, others via an HTTP check) and drop any that don't resolve, returning them as \`dropped_links\` / \`warnings\`. When a link is dropped, tell the user it couldn't be verified and was left out — do not invent a replacement.`,
+ ` **Materials, PPE, and tags are short discrete labels**, not sentences — e.g. materials \`["Wood", "Acrylic", "Leather"]\`, not \`["Wood (plywood, hardwood, veneer)"]\`. Avoid commas inside any single value (Notion multi-select options can't contain them; they'll be rewritten to " / " on write).`,
`2. **Normalize.** Call \`research_tool\` with your best-effort \`ToolCandidate\`. It checks the catalog for duplicates and returns the candidate annotated with \`duplicate_of\` when a strong match already exists. Propose a \`category\` (with its group) and a \`location\` (room + zone), setting \`isNew\` to your best judgment; staff will confirm. Include at least one \`unit\` (e.g. " #1") unless the user is clearly describing a consumable.`,
`3. **Confirm — always.** Call \`propose_listing\` with the candidate(s). This renders an identification card. **Never call \`create_tool\` without first calling \`propose_listing\` and getting an explicit user confirmation** (a click on "Looks right — add it", or a typed "yes / add it"). Wait for that confirmation.`,
`4. **Handle duplicates.** If \`research_tool\` reported a \`duplicate_of\`, the card surfaces "Already in catalog". Offer to **add a unit to the existing tool** rather than creating a new tool, unless the user explicitly wants a separate listing.`,
diff --git a/v5/src/lib/capabilities/types.ts b/v5/src/lib/capabilities/types.ts
index a097d84..c73dbb4 100644
--- a/v5/src/lib/capabilities/types.ts
+++ b/v5/src/lib/capabilities/types.ts
@@ -129,7 +129,7 @@ export interface Capability {
// ── Card payloads (chat widgets) ───────────────────────────────────
/** Lifecycle state of an identification card (design spec §4.1 / §6.3). */
-export type CardState = "proposed" | "success" | "duplicate";
+export type CardState = "proposed" | "success" | "duplicate" | "error";
/** A single line of spec text rendered on a card (e.g. "Bed: 256mm"). */
export interface CardSpecLine {
diff --git a/v5/src/lib/notion.ts b/v5/src/lib/notion.ts
index 2f37a35..fc99562 100644
--- a/v5/src/lib/notion.ts
+++ b/v5/src/lib/notion.ts
@@ -470,11 +470,18 @@ function selectProp(value: string | undefined): NotionWriteProperty {
}
function multiSelectProp(values: string[] | undefined): NotionWriteProperty {
- return {
- multi_select: (values || [])
- .filter((value) => Boolean(value))
- .map((name) => ({ name })),
- };
+ // Notion rejects commas in multi-select option names ("commas not allowed").
+ // Replace any commas (e.g. "Wood (plywood, hardwood, veneer)") with " /" so the
+ // write succeeds without losing readability, then de-dupe and drop empties.
+ const seen = new Set();
+ const options: { name: string }[] = [];
+ for (const raw of values || []) {
+ const name = (raw || "").replace(/\s*,\s*/g, " / ").trim();
+ if (!name || seen.has(name)) continue;
+ seen.add(name);
+ options.push({ name });
+ }
+ return { multi_select: options };
}
function relationProp(ids: string[] | undefined): NotionWriteProperty {
diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css
index 16d2574..fc09888 100644
--- a/v5/src/styles/globals.css
+++ b/v5/src/styles/globals.css
@@ -1682,6 +1682,12 @@ p {
border: 1px solid var(--secondary);
}
+.id-card-banner-error {
+ background: var(--surface-container-low);
+ color: #d9534f;
+ border: 1px solid #d9534f;
+}
+
.id-card-banner-icon {
display: inline-flex;
}
From 9996f46cba8abeb08a3a075fa89f61ba3a986478 Mon Sep 17 00:00:00 2001
From: Isaac S
Date: Tue, 2 Jun 2026 23:44:12 -0400
Subject: [PATCH 7/7] Show a tool's resources whenever the tool is visible
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resource links added via intake are created as drafts (published=false)
alongside the tool. groupResourcesByTool also filtered out published=false
resources, so even after staff published a tool, its manuals/SOP/wiki
links stayed invisible. Drop that per-resource filter: the catalog only
surfaces published tools, so tool-level publishing already gates
visibility — a tool's resources now show with it.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
v5/src/lib/catalog.test.ts | 18 +++++++++++-------
v5/src/lib/catalog.ts | 6 +++++-
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/v5/src/lib/catalog.test.ts b/v5/src/lib/catalog.test.ts
index 2589baf..323e9dc 100644
--- a/v5/src/lib/catalog.test.ts
+++ b/v5/src/lib/catalog.test.ts
@@ -421,27 +421,31 @@ describe("resourceLinks (via Notion path)", () => {
expect(pngLink).toBeDefined();
});
- it("excludes resources where published === false", async () => {
+ it("includes a resource even when published === false (gated by its tool's visibility)", async () => {
+ // Resources are created as drafts alongside the tool; tool-level publishing
+ // already gates the catalog, so a tool's links show with it regardless of
+ // the resource's own published flag.
stubNotionEnv();
- const unpublished: NotionPageFixture = {
+ const draftResource: NotionPageFixture = {
object: "page",
- id: "res-unpublished",
+ id: "res-draft",
created_time: "2024-08-12T10:00:00.000Z",
last_edited_time: "2024-08-12T10:00:00.000Z",
properties: {
- title: titleProp("Hidden SOP"),
+ title: titleProp("Draft SOP"),
tool: relationProp(["tool-1"]),
type: selectProp("SOP"),
- url: urlProp("https://example.com/hidden"),
+ url: urlProp("https://example.com/draft-sop"),
files: filesProp([]),
published: checkboxProp(false),
},
};
- routeCatalog({ resources: [unpublished] });
+ routeCatalog({ resources: [draftResource] });
const { getCatalogTool } = await importCatalog();
const tool = await getCatalogTool("tool-1");
- expect(tool!.links).toEqual([]);
+ expect(tool!.links).toHaveLength(1);
+ expect(tool!.links[0].href).toBe("https://example.com/draft-sop");
});
it("emits only file links when a resource has files but no url", async () => {
diff --git a/v5/src/lib/catalog.ts b/v5/src/lib/catalog.ts
index 0b54cb4..c9fc0d1 100644
--- a/v5/src/lib/catalog.ts
+++ b/v5/src/lib/catalog.ts
@@ -66,7 +66,11 @@ function groupUnitsByTool(units: UnitRecord[]): Map {
function groupResourcesByTool(resources: ResourceRecord[]): Map {
const map = new Map();
for (const resource of resources) {
- if (resource.fields.published === false) continue;
+ // Resources are gated by their parent tool's visibility, not their own
+ // `published` flag: the catalog only surfaces published tools, so an
+ // unpublished tool already hides its resources. Filtering resources here too
+ // meant intake-created links (created as drafts alongside the tool) stayed
+ // invisible even after staff published the tool — so we show them with it.
for (const toolId of resource.fields.tool || []) {
const list = map.get(toolId);
if (list) list.push(resource);