From e762cb5b9ea198b1c819d3284b48aa65f1e6289b Mon Sep 17 00:00:00 2001 From: gogocat Date: Mon, 6 Jul 2026 17:39:39 +0300 Subject: [PATCH 01/17] =?UTF-8?q?feat(idef0):=20Pillar=20C=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20camera-bus=20seam=20+=20Tier-0=20chat=20(RFC-034)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-only foundation for the live onboarding agent (RFC-034), shipping value with NO daemon: a chat that answers from map.json and drives the map. - camera-bus.svelte.ts (rune store): the ONE seam a chat (Tier 0 or Tier 1) uses to move the existing RFC-033 tour camera. showOnMap({kind,id}) bumps a monotonic seq so re-asking about the same zone/node/flow still recentres; ComposedMapView consumes it via a seq-keyed $effect → fitToRect (zone) / select (node) / activeFlow (flow). No camera redesign. - widgets/map-chat/ — the chat shell + Tier 0 (client-grounded, model-free, offline): tier0.ts answers purely from the loaded MapDocument (zone/node/ flow match → grounded text + a CameraTarget), honest (no fabrication when description_ru absent); chat-store.svelte.ts drives it + camera-bus; MapChat.svelte composes shared/ui (rule 24). Tier 1 is a Phase-3 stub. - ComposedMapView mounts an "Ask" toggle → the chat overlay. Pure client (rule 22: no WebSocket, no /api/* — those are Phase 2/3). This is the container the live agent (Tier 1) plugs into by swapping the answer source from map.json to a live local Claude Code session. vitest 123/123 on the composed-map + map-chat surface; svelte-check 0 errors. Refs: RFC-034, PRD-038 --- .../composed-map/model/camera-bus.svelte.ts | 39 +++ .../composed-map/model/camera-bus.test.ts | 61 +++++ .../composed-map/ui/ComposedMapView.svelte | 110 +++++++++ .../map-chat/model/chat-store.svelte.ts | 54 +++++ .../widgets/map-chat/model/chat-store.test.ts | 122 ++++++++++ .../src/widgets/map-chat/model/tier0.test.ts | 223 ++++++++++++++++++ template/src/widgets/map-chat/model/tier0.ts | 221 +++++++++++++++++ .../map-chat/ui/MapChat.render.test.ts | 191 +++++++++++++++ .../src/widgets/map-chat/ui/MapChat.svelte | 190 +++++++++++++++ 9 files changed, 1211 insertions(+) create mode 100644 template/src/widgets/composed-map/model/camera-bus.svelte.ts create mode 100644 template/src/widgets/composed-map/model/camera-bus.test.ts create mode 100644 template/src/widgets/map-chat/model/chat-store.svelte.ts create mode 100644 template/src/widgets/map-chat/model/chat-store.test.ts create mode 100644 template/src/widgets/map-chat/model/tier0.test.ts create mode 100644 template/src/widgets/map-chat/model/tier0.ts create mode 100644 template/src/widgets/map-chat/ui/MapChat.render.test.ts create mode 100644 template/src/widgets/map-chat/ui/MapChat.svelte diff --git a/template/src/widgets/composed-map/model/camera-bus.svelte.ts b/template/src/widgets/composed-map/model/camera-bus.svelte.ts new file mode 100644 index 0000000..f22b4d4 --- /dev/null +++ b/template/src/widgets/composed-map/model/camera-bus.svelte.ts @@ -0,0 +1,39 @@ +// RFC-034 (Pillar C) — the ONE seam a chat (Tier 0 or Tier 1) uses to drive +// ComposedMapView's existing camera (RFC-033 Invariant 2: fitToRect stays +// the only camera-move primitive; this module never touches the DOM or the +// zoom behaviour itself). Mirrors node-tabs.svelte.ts's plain module-level +// $state store shape — no class, no context, one shared instance per page. + +export type CameraTarget = { + kind: "zone" | "node" | "flow"; + id: string; +}; + +export interface CameraRequest { + target: CameraTarget | null; + /** + * Monotonically increasing per `showOnMap` call. The view keys its + * consuming $effect on this counter rather than on `target` identity, so + * asking to look at the SAME zone/node/flow twice in a row still re-fires + * the camera move (e.g. re-asking "where is X" recentres the view instead + * of being a silent no-op because the target object looks unchanged). + */ + seq: number; +} + +let request = $state({ target: null, seq: 0 }); + +/** Chat writes: request the view's camera move to the given target. */ +export function showOnMap(target: CameraTarget): void { + request = { target, seq: request.seq + 1 }; +} + +/** View reads: the current camera request (target + seq to key off of). */ +export function currentCameraRequest(): CameraRequest { + return request; +} + +/** Clears the current target without bumping `seq` (no new camera move). */ +export function clearCameraTarget(): void { + request = { target: null, seq: request.seq }; +} diff --git a/template/src/widgets/composed-map/model/camera-bus.test.ts b/template/src/widgets/composed-map/model/camera-bus.test.ts new file mode 100644 index 0000000..28c5da1 --- /dev/null +++ b/template/src/widgets/composed-map/model/camera-bus.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + showOnMap, + currentCameraRequest, + clearCameraTarget, + type CameraTarget, +} from "./camera-bus.svelte"; + +// Module-level $state persists across tests in this file (same singleton +// the real view consumes) — reset it before every test so cases don't leak +// into each other, mirroring the isolation node-tabs.test.ts gets for free +// from per-test-unique ids (a single current-target store has no such luxury). +beforeEach(() => { + clearCameraTarget(); +}); + +describe("camera-bus", () => { + it("starts with no target", () => { + expect(currentCameraRequest().target).toBeNull(); + }); + + it("round-trips a target through showOnMap / currentCameraRequest", () => { + const target: CameraTarget = { kind: "zone", id: "zone-a" }; + showOnMap(target); + expect(currentCameraRequest().target).toEqual(target); + }); + + it("increments seq on every showOnMap call", () => { + const before = currentCameraRequest().seq; + showOnMap({ kind: "node", id: "node-a" }); + const afterFirst = currentCameraRequest().seq; + expect(afterFirst).toBe(before + 1); + showOnMap({ kind: "node", id: "node-a" }); + const afterSecond = currentCameraRequest().seq; + expect(afterSecond).toBe(afterFirst + 1); + }); + + it("increments seq even when the SAME target is requested twice", () => { + const target: CameraTarget = { kind: "flow", id: "flow-a" }; + showOnMap(target); + const firstSeq = currentCameraRequest().seq; + showOnMap(target); + const secondSeq = currentCameraRequest().seq; + expect(secondSeq).toBe(firstSeq + 1); + expect(currentCameraRequest().target).toEqual(target); + }); + + it("clearCameraTarget resets the target to null", () => { + showOnMap({ kind: "zone", id: "zone-b" }); + expect(currentCameraRequest().target).not.toBeNull(); + clearCameraTarget(); + expect(currentCameraRequest().target).toBeNull(); + }); + + it("clearCameraTarget does not bump seq", () => { + showOnMap({ kind: "zone", id: "zone-c" }); + const seqAfterShow = currentCameraRequest().seq; + clearCameraTarget(); + expect(currentCameraRequest().seq).toBe(seqAfterShow); + }); +}); diff --git a/template/src/widgets/composed-map/ui/ComposedMapView.svelte b/template/src/widgets/composed-map/ui/ComposedMapView.svelte index 0738e7a..588d972 100644 --- a/template/src/widgets/composed-map/ui/ComposedMapView.svelte +++ b/template/src/widgets/composed-map/ui/ComposedMapView.svelte @@ -69,6 +69,10 @@ type TourState, type TourStop, } from "@/widgets/composed-map/model/tour-state"; + import { + currentCameraRequest, + type CameraTarget, + } from "@/widgets/composed-map/model/camera-bus.svelte"; import type { ArtifactSummary } from "@/entities/artifact"; import type { GraphEdge } from "@/entities/graph"; import type { ScoreEntry } from "@/entities/score"; @@ -80,6 +84,7 @@ import LevelBreadcrumb from "./LevelBreadcrumb.svelte"; import ZoneDetailCard from "./ZoneDetailCard.svelte"; import OnboardTour from "./OnboardTour.svelte"; + import MapChat from "@/widgets/map-chat/ui/MapChat.svelte"; let { selectedId = null, @@ -403,6 +408,15 @@ let tour = $state({ active: false, index: 0 }); let reducedMotion = $state(false); + // RFC-034 (Pillar C, Phase 1b) — the Tier-0 chat drawer. View-local toggle + // only; the transcript itself lives in chat-store.svelte.ts so it survives + // the panel being closed/reopened. + let chatOpen = $state(false); + + function toggleChat() { + chatOpen = !chatOpen; + } + $effect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); @@ -453,6 +467,56 @@ if (rect) fitToRect(rect, !reducedMotion); }); + // RFC-034 (Pillar C, Phase 1a) — camera-bus consumption. The ONE seam a + // chat (Tier 0 today, Tier 1 later) uses to drive this view's existing + // camera; reuses fitToRect / onSelect / activeFlow verbatim (Invariant 2: + // no second camera controller). A target that doesn't resolve against the + // CURRENT level's document/layout (rect missing, node/flow not found) is a + // silent no-op — the chat's grounding is best-effort (RFC-034 OQ4). + function applyCameraTarget(target: CameraTarget): void { + if (!activeDoc || !layout) return; + if (target.kind === "zone") { + const rect = layout.zoneRects.get(target.id); + if (rect) fitToRect(rect, !reducedMotion); + return; + } + if (target.kind === "node") { + const node = activeDoc.nodes.find((n) => n.id === target.id); + if (!node) return; + // Mirrors handleNodeClick's non-descend branches: a real artifact + // selects by artifact_id, a plain code node opens its detail tab. + if (node.artifact_id) { + onSelect?.({ id: node.artifact_id }); + } else { + setNodeTab(node.id, { + node, + connections: buildNodeConnections(activeDoc, node.id), + }); + onSelect?.({ id: `node:${node.id}` }); + } + const rect = layout.zoneRects.get(node.zone); + if (rect) fitToRect(rect, !reducedMotion); + return; + } + const flowExists = + activeDoc.flows?.some((f) => f.id === target.id) ?? false; + if (flowExists) activeFlow = target.id; + } + + // Plain (non-reactive) bookkeeping, same idiom as prevRatio/cooldownUntil + // above — tracks the last-consumed request so a re-render that doesn't + // touch camera-bus (e.g. a layout recompute) never re-applies a stale + // target, while a genuinely new `showOnMap` (bumped `seq`) always does, + // even when it targets the same zone/node/flow as before. + let lastCameraSeq = 0; + + $effect(() => { + const req = currentCameraRequest(); + if (req.seq === lastCameraSeq) return; + lastCameraSeq = req.seq; + if (req.target) applyCameraTarget(req.target); + }); + // Zoom-to-fit only the FIRST non-empty layout (didFit latches); later // meta.version recomputes must not disturb the user's pan/zoom. The // queueMicrotask callback can outlive this effect (e.g. the view is @@ -513,6 +577,7 @@ hoveredZoneId = null; detailZoneId = null; tour = exitTour(tour); + chatOpen = false; } }); @@ -805,6 +870,12 @@ } return; } + // RFC-034 (Pillar C, Phase 1b) — the chat drawer owns Escape while open, + // same "topmost overlay first" ordering as the tour branch above. + if (chatOpen && event.key === "Escape") { + chatOpen = false; + return; + } if (event.key !== "Escape") return; if (levelStack.length > 1) { ascend(); @@ -1046,6 +1117,26 @@ activeFlowId={activeFlow} onToggle={(id) => (activeFlow = id)} /> + +
+ +
+ {#if chatOpen && okDoc} +
+ (chatOpen = false)} /> +
+ {/if} {#if detailZone} {@const zone = detailZone} ([]); +let tier = $state("tier0"); + +/** View reads: the current transcript, oldest first. */ +export function getMessages(): ChatMessage[] { + return messages; +} + +/** View reads: which tier is currently answering (Phase 1b is always Tier 0). */ +export function getTier(): ChatTier { + return tier; +} + +/** + * Sends a user question: pushes the user message, answers it (Tier 0 today — + * client-grounded, model-free), pushes the assistant reply, and — when the + * answer names a zone/node/flow — drives the map camera via camera-bus. + */ +export function send(doc: MapDocument, question: string): void { + const trimmed = question.trim(); + if (!trimmed) return; + messages = [...messages, { role: "user", text: trimmed }]; + + // TODO(pillar-c-phase3-tier1): once the daemon (@forgeplan/web-agent) is + // probed and connected, a "tier1" tier should route through + // agent-client.ts's WebSocket session instead of answerFromMap. Tier 0 + // remains the offline fallback whenever the daemon is absent/unreachable. + const { text, target } = answerFromMap(doc, trimmed); + messages = [...messages, { role: "assistant", text }]; + if (target) showOnMap(target); +} + +/** Test/dev helper: resets the shared store to its initial state. */ +export function resetChat(): void { + messages = []; + tier = "tier0"; +} diff --git a/template/src/widgets/map-chat/model/chat-store.test.ts b/template/src/widgets/map-chat/model/chat-store.test.ts new file mode 100644 index 0000000..c0c4e85 --- /dev/null +++ b/template/src/widgets/map-chat/model/chat-store.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { send, getMessages, getTier, resetChat } from "./chat-store.svelte"; +import { + currentCameraRequest, + clearCameraTarget, +} from "@/widgets/composed-map/model/camera-bus.svelte"; +import type { MapDocument, MapZone } from "@/entities/map"; + +// RFC-034 Test Strategy Hooks — send() pushes user+assistant messages, and +// drives camera-bus.showOnMap exactly when the tier0 answer carries a +// target. Module-level state (messages/tier here, the camera request in +// camera-bus) persists across tests in this file — reset both before every +// test, mirroring camera-bus.test.ts's own isolation. +beforeEach(() => { + resetChat(); + clearCameraTarget(); +}); + +function zone(overrides: Partial = {}): MapZone { + return { + id: "z.a", + label: "Zone A", + kind: "surface", + accent: "--map-accent-cyan", + treatment: "neutral-dashed", + rule_edge: "off", + layout_rule: "grid", + cols: 2, + ...overrides, + }; +} + +function fixtureDoc(): MapDocument { + return { + schema: "forgeplan.map/v1", + meta: { + map_id: "test", + status: "confirmed", + project_type: "generic", + composition_id: "c1", + source_fingerprint: "fp", + version: 1, + }, + canvas: { + grid: { cols: 1, rows: 1 }, + gap: { x: 88, y: 70 }, + margin: 40, + cell: { + card_w: 190, + card_h: 60, + card_gap: 36, + zpad: { top: 50, side: 24, bottom: 24 }, + }, + }, + composition: { + template: "generic", + arrangement: "stack-ttb", + entry_zone: "z.a", + placements: [{ zone: "z.a", cell: { row: 0, col: 0 } }], + zone_connectors: [], + }, + zones: [zone({ id: "z.a", label: "CLI Surfaces" })], + nodes: [], + edges: [], + }; +} + +describe("chat-store — send", () => { + it("pushes a user message followed by a grounded assistant message", () => { + send(fixtureDoc(), "Tell me about CLI Surfaces"); + const messages = getMessages(); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ + role: "user", + text: "Tell me about CLI Surfaces", + }); + expect(messages[1]!.role).toBe("assistant"); + expect(messages[1]!.text).toContain("CLI Surfaces"); + }); + + it("drives the camera via camera-bus when the tier0 answer has a target", () => { + const before = currentCameraRequest().seq; + send(fixtureDoc(), "Tell me about CLI Surfaces"); + const after = currentCameraRequest(); + expect(after.seq).toBe(before + 1); + expect(after.target).toEqual({ kind: "zone", id: "z.a" }); + }); + + it("does not move the camera when the tier0 answer has no target (fallback)", () => { + const before = currentCameraRequest().seq; + send(fixtureDoc(), "asdkjqwlekj nonsense zzz"); + expect(getMessages()).toHaveLength(2); + expect(currentCameraRequest().seq).toBe(before); + }); + + it("ignores a blank/whitespace-only question — no messages pushed", () => { + send(fixtureDoc(), " "); + expect(getMessages()).toHaveLength(0); + }); + + it("accumulates messages across multiple sends", () => { + send(fixtureDoc(), "Tell me about CLI Surfaces"); + send(fixtureDoc(), "asdkjqwlekj nonsense zzz"); + expect(getMessages()).toHaveLength(4); + }); +}); + +describe("chat-store — tier", () => { + it("defaults to tier0", () => { + expect(getTier()).toBe("tier0"); + }); +}); + +describe("chat-store — resetChat", () => { + it("clears the transcript and restores the default tier", () => { + send(fixtureDoc(), "Tell me about CLI Surfaces"); + expect(getMessages().length).toBeGreaterThan(0); + resetChat(); + expect(getMessages()).toEqual([]); + expect(getTier()).toBe("tier0"); + }); +}); diff --git a/template/src/widgets/map-chat/model/tier0.test.ts b/template/src/widgets/map-chat/model/tier0.test.ts new file mode 100644 index 0000000..70c5cb3 --- /dev/null +++ b/template/src/widgets/map-chat/model/tier0.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import { answerFromMap } from "./tier0"; +import type { + MapDocument, + MapNode, + MapZone, + MapFlow, + MapEdge, +} from "@/entities/map"; + +// RFC-034 Test Strategy Hooks — question -> answer over a fixture doc: +// matches a zone by label; a node by label; a node by provenance path; +// a flow by name; returns a target; never throws; model-free; never +// fabricates a description_ru that isn't present on the entity. + +function zone(overrides: Partial = {}): MapZone { + return { + id: "z.a", + label: "Zone A", + kind: "surface", + accent: "--map-accent-cyan", + treatment: "neutral-dashed", + rule_edge: "off", + layout_rule: "grid", + cols: 2, + ...overrides, + }; +} + +function node(overrides: Partial = {}): MapNode { + return { + id: "n1", + label: "Node 1", + kind: "component", + zone: "z.a", + found_at: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function baseDoc(overrides: Partial = {}): MapDocument { + return { + schema: "forgeplan.map/v1", + meta: { + map_id: "test", + status: "confirmed", + project_type: "generic", + composition_id: "c1", + source_fingerprint: "fp", + version: 1, + }, + canvas: { + grid: { cols: 2, rows: 1 }, + gap: { x: 88, y: 70 }, + margin: 40, + cell: { + card_w: 190, + card_h: 60, + card_gap: 36, + zpad: { top: 50, side: 24, bottom: 24 }, + }, + }, + composition: { + template: "generic", + arrangement: "stack-ttb", + entry_zone: "z.a", + placements: [{ zone: "z.a", cell: { row: 0, col: 0 } }], + zone_connectors: [], + }, + zones: [zone()], + nodes: [node()], + edges: [], + ...overrides, + }; +} + +const zones: MapZone[] = [ + zone({ + id: "z.cli", + label: "CLI Surfaces", + description_ru: "Публичные точки входа.", + }), + zone({ id: "z.web", label: "Web Widgets" }), // no description_ru — honesty case +]; + +const nodes: MapNode[] = [ + node({ + id: "n.bin", + label: "forgeplan-web.mjs", + zone: "z.cli", + description_ru: "Точка входа CLI.", + provenance: { + source: "file", + ref: "bin/forgeplan-web.mjs", + confidence: 0.9, + }, + }), + node({ id: "n.init", label: "init command", zone: "z.cli" }), + node({ + id: "n.core", + label: "Core Bootstrap", + zone: "z.web", + provenance: { source: "file", ref: "bin/lib/core.mjs", confidence: 0.8 }, + }), // no description_ru, no edges — honesty + empty-connections case +]; + +const edges: MapEdge[] = [{ from: "n.bin", to: "n.init", relation: "calls" }]; + +const flows: MapFlow[] = [ + { + id: "f.onboard", + name: "Onboarding Flow", + node_ids: ["n.bin", "n.init"], + steps: ["Step one", "Step two"], + }, +]; + +function fixtureDoc(): MapDocument { + return baseDoc({ zones, nodes, edges, flows }); +} + +describe("answerFromMap — zone match", () => { + it("matches a zone by label, carries description_ru verbatim, returns a zone target", () => { + const result = answerFromMap(fixtureDoc(), "Tell me about CLI Surfaces"); + expect(result.target).toEqual({ kind: "zone", id: "z.cli" }); + expect(result.text).toContain("CLI Surfaces"); + expect(result.text).toContain("Публичные точки входа."); + }); + + it("includes a what's-inside member summary for the matched zone", () => { + const result = answerFromMap(fixtureDoc(), "Tell me about CLI Surfaces"); + expect(result.text).toContain("What's inside"); + expect(result.text).toContain("forgeplan-web.mjs"); + expect(result.text).toContain("init command"); + }); + + it("never fabricates a description_ru sentence when the zone has none", () => { + const result = answerFromMap(fixtureDoc(), "What is Web Widgets?"); + expect(result.target).toEqual({ kind: "zone", id: "z.web" }); + expect(result.text).toContain("Web Widgets"); + expect(result.text).not.toContain("Публичные"); + }); +}); + +describe("answerFromMap — node match", () => { + it("matches a node by label, carries its description_ru, and lists out-connections", () => { + const result = answerFromMap( + fixtureDoc(), + "What does forgeplan-web.mjs do?", + ); + expect(result.target).toEqual({ kind: "node", id: "n.bin" }); + expect(result.text).toContain("forgeplan-web.mjs"); + expect(result.text).toContain("Точка входа CLI."); + expect(result.text).toContain("Connects to: init command"); + }); + + it("matches a node by its provenance path when the label alone isn't asked for", () => { + const result = answerFromMap( + fixtureDoc(), + "what happens in bin/lib/core.mjs", + ); + expect(result.target).toEqual({ kind: "node", id: "n.core" }); + expect(result.text).toContain("Core Bootstrap"); + }); + + it("never fabricates description or connections for a node that has neither", () => { + const result = answerFromMap( + fixtureDoc(), + "what happens in bin/lib/core.mjs", + ); + expect(result.text).not.toContain("Connects to"); + expect(result.text).not.toContain("Connected from"); + }); +}); + +describe("answerFromMap — flow match", () => { + it("matches a flow by name, returns a flow target, and numbers its steps", () => { + const result = answerFromMap( + fixtureDoc(), + "Walk me through the Onboarding Flow", + ); + expect(result.target).toEqual({ kind: "flow", id: "f.onboard" }); + expect(result.text).toContain("Onboarding Flow"); + expect(result.text).toContain("1. Step one"); + expect(result.text).toContain("2. Step two"); + }); +}); + +describe("answerFromMap — no match", () => { + it("falls back to a sample of zone labels and leaves target undefined", () => { + const result = answerFromMap(fixtureDoc(), "asdkjqwlekj nonsense zzz"); + expect(result.target).toBeUndefined(); + expect(result.text).toContain("CLI Surfaces"); + }); + + it("returns an honest empty-map fallback when the document has no zones", () => { + const result = answerFromMap(baseDoc({ zones: [], nodes: [] }), "hello"); + expect(result.target).toBeUndefined(); + expect(result.text).toBe("I don't have a loaded map to answer from yet."); + }); +}); + +describe("answerFromMap — never throws", () => { + it("handles an empty question without throwing", () => { + expect(() => answerFromMap(fixtureDoc(), "")).not.toThrow(); + expect(answerFromMap(fixtureDoc(), "").target).toBeUndefined(); + }); + + it("handles a whitespace-only question without throwing", () => { + expect(() => answerFromMap(fixtureDoc(), " ")).not.toThrow(); + }); + + it("handles a degenerate document (no zones/nodes/flows) without throwing", () => { + const empty = baseDoc({ zones: [], nodes: [], edges: [], flows: [] }); + expect(() => answerFromMap(empty, "anything")).not.toThrow(); + }); + + it("is deterministic — the same (doc, question) always yields the same answer", () => { + const a = answerFromMap(fixtureDoc(), "Tell me about CLI Surfaces"); + const b = answerFromMap(fixtureDoc(), "Tell me about CLI Surfaces"); + expect(a).toEqual(b); + }); +}); diff --git a/template/src/widgets/map-chat/model/tier0.ts b/template/src/widgets/map-chat/model/tier0.ts new file mode 100644 index 0000000..facf1da --- /dev/null +++ b/template/src/widgets/map-chat/model/tier0.ts @@ -0,0 +1,221 @@ +// RFC-034 (Pillar C, Phase 1b) — Tier 0: client-grounded, model-free +// answering. `answerFromMap` is a pure function of (doc, question): no +// network, no model, no DOM, never throws. It matches the lowercased +// question against zone/node/flow text already loaded in the `MapDocument` +// and, on a match, also returns a `CameraTarget` so the chat store can drive +// the existing camera (camera-bus.svelte.ts). Reuses +// node-tabs.svelte.ts#buildNodeConnections for node in/out neighbours — the +// same derivation `MapNodePanel` renders — rather than re-deriving it here. +// +// Honesty (MASTER-SPEC §15 / RFC-033 precedent): a missing `description_ru` +// is omitted, never fabricated as a placeholder sentence. + +import type { MapDocument, MapNode, MapZone, MapFlow } from "@/entities/map"; +import { buildNodeConnections } from "@/widgets/composed-map/model/node-tabs.svelte"; +import type { CameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; + +export interface Tier0Answer { + text: string; + target?: CameraTarget; +} + +const MEMBER_SUMMARY_LIMIT = 6; +const FALLBACK_ZONE_SAMPLE = 3; +const MIN_KEYWORD_LENGTH = 3; + +// Common English question scaffolding — stripped before keyword scoring so +// "where is the CLI Surfaces zone" scores on "cli"/"surfaces", not on +// "where"/"the"/"is". +const STOPWORDS = new Set([ + "the", + "a", + "an", + "is", + "are", + "was", + "were", + "do", + "does", + "did", + "how", + "what", + "where", + "which", + "who", + "whom", + "tell", + "me", + "about", + "of", + "in", + "on", + "to", + "for", + "and", + "or", + "this", + "that", + "it", + "its", + "with", + "from", + "by", + "can", + "you", + "please", + "show", + "explain", + "describe", + "map", +]); + +/** Splits on any run of non-letter/non-digit chars (Unicode-aware, so this + * also tokenizes RU narration and path-shaped provenance refs). */ +function tokenize(input: string): string[] { + return input + .split(/[^\p{L}\p{N}]+/u) + .map((t) => t.toLowerCase()) + .filter((t) => t.length >= MIN_KEYWORD_LENGTH && !STOPWORDS.has(t)); +} + +/** A literal label/name match (either direction) is a strong signal; each + * keyword found in the entity's text is a weaker, additive one. */ +function scoreMatch( + qLower: string, + keywords: readonly string[], + matchText: string, + primaryKey: string, +): number { + if (!matchText) return 0; + let score = 0; + if ( + primaryKey && + (qLower.includes(primaryKey) || matchText.includes(qLower)) + ) { + score += 5; + } + for (const kw of keywords) { + if (matchText.includes(kw)) score += 1; + } + return score; +} + +function describeZone(doc: MapDocument, zone: MapZone): string { + const parts: string[] = [zone.label]; + if (zone.description_ru) parts.push(zone.description_ru); + const members = doc.nodes.filter((n) => n.zone === zone.id && !n.is_mega); + if (members.length > 0) { + const labels = members.slice(0, MEMBER_SUMMARY_LIMIT).map((n) => n.label); + const remaining = members.length - labels.length; + const suffix = remaining > 0 ? ` (+${remaining} more)` : ""; + parts.push(`What's inside: ${labels.join(", ")}${suffix}`); + } + return parts.join(" — "); +} + +function describeNode(doc: MapDocument, node: MapNode): string { + const parts: string[] = [node.label]; + if (node.description_ru) parts.push(node.description_ru); + const connections = buildNodeConnections(doc, node.id); + const out = connections.filter((c) => c.dir === "out").map((c) => c.label); + const inbound = connections.filter((c) => c.dir === "in").map((c) => c.label); + if (out.length > 0) parts.push(`Connects to: ${out.join(", ")}`); + if (inbound.length > 0) parts.push(`Connected from: ${inbound.join(", ")}`); + return parts.join(" — "); +} + +function describeFlow(flow: MapFlow): string { + const parts: string[] = [flow.name]; + if (flow.steps && flow.steps.length > 0) { + parts.push(flow.steps.map((step, i) => `${i + 1}. ${step}`).join(" ")); + } + return parts.join(" — "); +} + +function fallbackText(doc: MapDocument): string { + const sample = doc.zones.slice(0, FALLBACK_ZONE_SAMPLE).map((z) => z.label); + if (sample.length === 0) { + return "I don't have a loaded map to answer from yet."; + } + return `I couldn't find a match for that on the map. Try asking about one of: ${sample.join(", ")}.`; +} + +/** + * Model-free, client-grounded answering: matches `question` against + * zone.label + zone.description_ru, node.label + node path (provenance.ref) + * + node.description_ru, and flow.name — never throws, never fabricates. + */ +export function answerFromMap(doc: MapDocument, question: string): Tier0Answer { + try { + const qLower = (question ?? "").toLowerCase().trim(); + if (!qLower) return { text: fallbackText(doc) }; + const keywords = tokenize(qLower); + + let best: { score: number; kind: CameraTarget["kind"]; id: string } | null = + null; + const consider = ( + score: number, + kind: CameraTarget["kind"], + id: string, + ) => { + if (score > 0 && (!best || score > best.score)) + best = { score, kind, id }; + }; + + for (const zone of doc.zones) { + const matchText = [zone.label, zone.description_ru] + .filter(Boolean) + .join(" ") + .toLowerCase(); + consider( + scoreMatch(qLower, keywords, matchText, zone.label.toLowerCase()), + "zone", + zone.id, + ); + } + for (const node of doc.nodes) { + const matchText = [node.label, node.description_ru, node.provenance?.ref] + .filter(Boolean) + .join(" ") + .toLowerCase(); + consider( + scoreMatch(qLower, keywords, matchText, node.label.toLowerCase()), + "node", + node.id, + ); + } + for (const flow of doc.flows ?? []) { + const matchText = flow.name.toLowerCase(); + consider( + scoreMatch(qLower, keywords, matchText, flow.name.toLowerCase()), + "flow", + flow.id, + ); + } + + if (!best) return { text: fallbackText(doc) }; + const picked: { score: number; kind: CameraTarget["kind"]; id: string } = + best; + + const target: CameraTarget = { kind: picked.kind, id: picked.id }; + if (picked.kind === "zone") { + const zone = doc.zones.find((z) => z.id === picked.id); + if (!zone) return { text: fallbackText(doc) }; + return { text: describeZone(doc, zone), target }; + } + if (picked.kind === "node") { + const node = doc.nodes.find((n) => n.id === picked.id); + if (!node) return { text: fallbackText(doc) }; + return { text: describeNode(doc, node), target }; + } + const flow = (doc.flows ?? []).find((f) => f.id === picked.id); + if (!flow) return { text: fallbackText(doc) }; + return { text: describeFlow(flow), target }; + } catch { + // Never throw (RFC-034 contract) — a malformed doc or unexpected input + // degrades to an honest, generic notice rather than crashing the chat. + return { + text: "Something went wrong answering that — try rephrasing your question.", + }; + } +} diff --git a/template/src/widgets/map-chat/ui/MapChat.render.test.ts b/template/src/widgets/map-chat/ui/MapChat.render.test.ts new file mode 100644 index 0000000..9f9857c --- /dev/null +++ b/template/src/widgets/map-chat/ui/MapChat.render.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +/** + * RFC-034 (Pillar C, Phase 1b) render-proof for MapChat.svelte. Harness: + * happy-dom + Svelte's built-in mount() — same pattern as + * OnboardTour.render.test.ts / nav-contract.render.test.ts. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, unmount, flushSync } from "svelte"; +import MapChat from "./MapChat.svelte"; +import { resetChat } from "../model/chat-store.svelte"; +import { clearCameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; +import type { MapDocument, MapZone } from "@/entities/map"; + +let host: HTMLElement | null = null; +let instance: unknown = null; + +function zone(overrides: Partial = {}): MapZone { + return { + id: "z.a", + label: "Zone A", + kind: "surface", + accent: "--map-accent-cyan", + treatment: "neutral-dashed", + rule_edge: "off", + layout_rule: "grid", + cols: 2, + ...overrides, + }; +} + +function fixtureDoc(): MapDocument { + return { + schema: "forgeplan.map/v1", + meta: { + map_id: "test", + status: "confirmed", + project_type: "generic", + composition_id: "c1", + source_fingerprint: "fp", + version: 1, + }, + canvas: { + grid: { cols: 1, rows: 1 }, + gap: { x: 88, y: 70 }, + margin: 40, + cell: { + card_w: 190, + card_h: 60, + card_gap: 36, + zpad: { top: 50, side: 24, bottom: 24 }, + }, + }, + composition: { + template: "generic", + arrangement: "stack-ttb", + entry_zone: "z.a", + placements: [{ zone: "z.a", cell: { row: 0, col: 0 } }], + zone_connectors: [], + }, + zones: [zone({ id: "z.a", label: "CLI Surfaces" })], + nodes: [], + edges: [], + }; +} + +function mountChat(props: { + doc: MapDocument; + onClose?: () => void; +}): HTMLElement { + host = document.createElement("div"); + document.body.appendChild(host); + instance = mount(MapChat, { target: host, props }); + flushSync(); + return host; +} + +function getInput(root: HTMLElement): HTMLInputElement { + const input = root.querySelector( + '[aria-label="Ask the map a question"]', + ); + expect(input).not.toBeNull(); + return input!; +} + +function getSendButton(root: HTMLElement): HTMLButtonElement { + const btn = Array.from(root.querySelectorAll("button")).find((b) => + b.textContent?.includes("Send"), + ); + expect(btn).toBeDefined(); + return btn as HTMLButtonElement; +} + +function typeInto(input: HTMLInputElement, text: string): void { + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + flushSync(); +} + +beforeEach(() => { + resetChat(); + clearCameraTarget(); +}); + +afterEach(() => { + if (instance) { + unmount(instance as object); + instance = null; + } + host?.remove(); + host = null; + vi.restoreAllMocks(); +}); + +describe("MapChat", () => { + it("renders the empty-transcript hint, an input, and a disabled Send button", () => { + const root = mountChat({ doc: fixtureDoc() }); + expect(root.textContent).toContain("Ask about a zone"); + getInput(root); + expect(getSendButton(root).disabled).toBe(true); + }); + + it("shows the Tier 0 offline badge", () => { + const root = mountChat({ doc: fixtureDoc() }); + expect(root.textContent).toContain("Offline"); + expect(root.textContent).toContain("Tier 0"); + }); + + it("renders prior messages already in the store", () => { + const root = mountChat({ doc: fixtureDoc() }); + // Simulate an already-populated transcript by driving the store + // directly, then re-render. + typeInto(getInput(root), "Tell me about CLI Surfaces"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + expect(root.textContent).toContain("CLI Surfaces"); + expect(root.textContent).toContain("You"); + expect(root.textContent).toContain("Map"); + }); + + it("enables Send once the input has non-whitespace text", () => { + const root = mountChat({ doc: fixtureDoc() }); + const input = getInput(root); + typeInto(input, " "); + expect(getSendButton(root).disabled).toBe(true); + typeInto(input, "hello"); + expect(getSendButton(root).disabled).toBe(false); + }); + + it("pressing Enter in the input sends the message and clears it", () => { + const root = mountChat({ doc: fixtureDoc() }); + const input = getInput(root); + typeInto(input, "Tell me about CLI Surfaces"); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + flushSync(); + expect(root.textContent).toContain("CLI Surfaces"); + expect(input.value).toBe(""); + }); + + it("clicking Send sends the message and clears the input", () => { + const root = mountChat({ doc: fixtureDoc() }); + const input = getInput(root); + typeInto(input, "Tell me about CLI Surfaces"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + expect(root.textContent).toContain("CLI Surfaces"); + expect(input.value).toBe(""); + }); + + it("renders a close button and fires onClose when clicked", () => { + const onClose = vi.fn(); + const root = mountChat({ doc: fixtureDoc(), onClose }); + const closeBtn = root.querySelector( + '[aria-label="Close chat"]', + ); + expect(closeBtn).not.toBeNull(); + closeBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + flushSync(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("omits the close button when onClose is not provided", () => { + const root = mountChat({ doc: fixtureDoc() }); + expect(root.querySelector('[aria-label="Close chat"]')).toBeNull(); + }); +}); diff --git a/template/src/widgets/map-chat/ui/MapChat.svelte b/template/src/widgets/map-chat/ui/MapChat.svelte new file mode 100644 index 0000000..42489bd --- /dev/null +++ b/template/src/widgets/map-chat/ui/MapChat.svelte @@ -0,0 +1,190 @@ + + + + {#snippet header()} +
+ Ask the map +
+ + {tier === "tier0" ? "Offline · Tier 0" : "Live · Tier 1"} + + {#if onClose} + + {/if} +
+
+ {/snippet} + +
+ {#if messages.length === 0} +

+ Ask about a zone, module, or flow — answers come straight from the + loaded map. +

+ {/if} + {#each messages as msg, i (i)} +
+ {msg.role === "user" ? "You" : "Map"} +

{msg.text}

+
+ {/each} +
+ + {#snippet footer()} +
+
+ +
+ +
+ {/snippet} +
+ + From f7726f57fe0b9f2be320b6a264c8fafbaaa2bb8d Mon Sep 17 00:00:00 2001 From: gogocat Date: Mon, 6 Jul 2026 18:29:43 +0300 Subject: [PATCH 02/17] =?UTF-8?q?feat(idef0):=20Pillar=20C=20daemon=20+=20?= =?UTF-8?q?Tier-1=20=E2=80=94=20live=20onboarding=20agent=20(RFC-034/ADR-0?= =?UTF-8?q?10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live agent: talk to your project via your LOCAL Claude Code, and the map moves as it explains. Phases 2-3 of RFC-034, on top of the Phase-1 shell. agent/ — NEW separate optional package @forgeplan/web-agent (ADR-010: its own deps @anthropic-ai/claude-agent-sdk + ws + zod, never in the core): - bin/agent.mjs: localhost (127.0.0.1) WebSocket daemon. Boots a persistent Agent SDK query() session in a READ-ONLY profile (allowedTools Read/Glob/Grep + the show_on_map tool; disallowedTools Write/Edit/Bash), cwd = project root. Registers one in-process createSdkMcpServer tool show_on_map(kind,id) whose handler relays a {show_on_map} frame to the browser and returns a text ack. Streams assistant text as {token} frames; {ready}/{done}/{error}; GET /health for the probe. realpathSync main-module guard so the npx symlink still boots. - lib/protocol.mjs (versioned WS schema), lib/profile.mjs (read-only options + onboarding-guide systemPrompt), scripts/smoke.mjs (protocol + read-only + bind + /health + ready, no live-model turn). bin/commands/onboard-agent.mjs — NEW spawn-only subcommand (rule 23: node:* + citty + siblings only; child_process.spawn the agent package, NEVER imports it; actionable install hint when absent) + cli.mjs registration. template/src/widgets/map-chat/ — Tier-1 wiring: agent-client.ts (read-only WS client: probe → connect → stream tokens → dispatch show_on_map to camera-bus); chat-store Tier-1 send (streams into the assistant message, degrades to Tier 0 when the daemon is down); MapChat "● live — " vs "offline (Tier 0)". Rule 22 intact (the live path is browser↔daemon, never /api/*). Rule 23 intact (bin spawn-only; root package.json untouched). vitest 153/153, svelte-check 0, daemon smoke exit 0, rule-23 grep OK. Refs: RFC-034, ADR-010, PRD-038 --- agent/README.md | 23 + agent/bin/agent.mjs | 303 ++++ agent/lib/profile.mjs | 47 + agent/lib/protocol.mjs | 121 ++ agent/package-lock.json | 1501 +++++++++++++++++ agent/package.json | 42 + agent/scripts/smoke.mjs | 290 ++++ bin/cli.mjs | 2 + bin/commands/onboard-agent.mjs | 164 ++ .../map-chat/model/agent-client.test.ts | 244 +++ .../widgets/map-chat/model/agent-client.ts | 215 +++ .../map-chat/model/chat-store.svelte.ts | 175 +- .../widgets/map-chat/model/chat-store.test.ts | 179 +- .../map-chat/ui/MapChat.render.test.ts | 152 +- .../src/widgets/map-chat/ui/MapChat.svelte | 41 +- 15 files changed, 3472 insertions(+), 27 deletions(-) create mode 100644 agent/README.md create mode 100755 agent/bin/agent.mjs create mode 100644 agent/lib/profile.mjs create mode 100644 agent/lib/protocol.mjs create mode 100644 agent/package-lock.json create mode 100644 agent/package.json create mode 100644 agent/scripts/smoke.mjs create mode 100644 bin/commands/onboard-agent.mjs create mode 100644 template/src/widgets/map-chat/model/agent-client.test.ts create mode 100644 template/src/widgets/map-chat/model/agent-client.ts diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..c227264 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,23 @@ +# @forgeplan/web-agent + +`@forgeplan/web-agent` is the optional, separately-published daemon-bridge +behind forgeplan-web's live onboarding chat (RFC-034 Pillar C / ADR-010). It +boots a persistent Claude Agent SDK session in a **read-only** profile +(`Read`/`Glob`/`Grep` + one in-process `show_on_map` tool; `Write`/`Edit`/ +`Bash` denied), rooted at the project's `cwd`, and binds a WebSocket **on +`127.0.0.1` only**. The browser's `map-chat` widget talks to this daemon +directly — never through forgeplan-web's `/api/*` (which stays a read-only +proxy per rule 22) — streaming assistant prose back as `token` frames and +relaying each `show_on_map` tool call as a frame the map camera reacts to. + +It is launched via `npx @forgeplan/web onboard-agent`, a spawn-only +subcommand in the core `@forgeplan/web` package (`bin/` never imports this +package — it only `child_process.spawn`s the binary shipped here, per +ADR-010, so the core package's `npx` weight is unaffected for the 99% of +users who only view the map). Directly: `npx @forgeplan/web-agent --cwd + --port 7431`. + +Guarantees: localhost-bind only (no `--host` flag exists by design), a +read-only Agent SDK profile enforced in `lib/profile.mjs`, and the daemon +uses the invoking user's own local Claude Code authentication — no API key +is baked in or required. diff --git a/agent/bin/agent.mjs b/agent/bin/agent.mjs new file mode 100755 index 0000000..a67a7c5 --- /dev/null +++ b/agent/bin/agent.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node +// RFC-034 (Pillar C, Phase 2) / ADR-010 — the onboard-agent daemon. Boots a +// persistent, read-only Claude Agent SDK session per WebSocket connection, +// binds 127.0.0.1 ONLY, registers the in-process `show_on_map` tool, and +// relays SDK stream events <-> WS frames using agent/lib/protocol.mjs's +// versioned schema. Launched exclusively via the core package's spawn-only +// `bin/ onboard-agent` subcommand (Phase 3) — never imported by it (rule 23 / +// ADR-010: the SDK dependency lives ONLY in this separate package). +// +// Health/probe choice (documented per RFC-034 task hand-off): this daemon +// exposes BOTH a plain `GET /health` (via the same http.Server the +// WebSocketServer attaches to) AND a per-connection `{type:"ready"}` WS +// frame. `/health` is what the browser's cheap Tier-0→Tier-1 upgrade probe +// (agent-client.ts#probeDaemon, Phase 3) uses — a plain fetch with no +// socket lifecycle to manage, safe to poll on an interval. The `{ready}` +// frame is what a CONNECTED client uses to confirm protocol/model +// compatibility before sending its first `user_message`. Two signals, two +// purposes: liveness (HTTP) vs. session-ready (WS). + +import { createServer } from "node:http"; +import { existsSync, statSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer } from "ws"; +import { + query, + tool, + createSdkMcpServer, +} from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import { buildOptions } from "../lib/profile.mjs"; +import { + PROTOCOL_VERSION, + decodeClientMessage, + encode, + readyMessage, + tokenMessage, + showOnMapMessage, + doneMessage, + errorMessage, +} from "../lib/protocol.mjs"; + +const DEFAULT_PORT = 7431; +// Localhost-bind is an ADR-010 invariant, not a runtime option — there is no +// --host flag by design (see RFC-034 Risks: "Localhost WS reachable by any +// local process / other browser tab"). +const HOST = "127.0.0.1"; +const AGENT_LABEL = "forgeplan-web-agent (claude-agent-sdk)"; + +function fail(line, code = 1) { + process.stderr.write(`onboard-agent: ${line}\n`); + process.exit(code); +} + +export function parseArgs(argv) { + const args = { cwd: process.cwd(), port: DEFAULT_PORT }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--cwd") { + const v = argv[++i]; + if (!v) throw new Error("--cwd requires a value"); + args.cwd = v; + } else if (a === "--port") { + const v = Number(argv[++i]); + if (!Number.isFinite(v) || v < 1 || v > 65_535) { + throw new RangeError(`invalid --port value; expected 1..65535`); + } + args.port = v; + } + } + return args; +} + +/** + * A per-connection async message queue: `enqueue(text)` is called from the + * WS `message` handler; `generator()` is the async generator handed to + * `query({ prompt })` as its STREAMING INPUT. The generator stays open for + * the life of the connection — it awaits a promise that resolves the moment + * a new message is enqueued, so the SDK session accrues context across every + * question on this connection instead of being torn down per-turn. + */ +export function createMessageQueue() { + const pending = []; + let wake = null; + + function enqueue(text) { + pending.push(text); + if (wake) { + const resolve = wake; + wake = null; + resolve(); + } + } + + async function* generator() { + for (;;) { + while (pending.length === 0) { + await new Promise((resolve) => { + wake = resolve; + }); + } + const text = pending.shift(); + yield { + type: "user", + session_id: "", + parent_tool_use_id: null, + message: { role: "user", content: text }, + }; + } + } + + return { enqueue, generator }; +} + +/** + * Builds the ONE registered SDK tool for this connection: `show_on_map`. + * Bound to `socket` so its handler can relay the call to the browser as a + * `{type:"show_on_map"}` WS frame — this is the entire RFC-034 camera relay. + */ +export function buildOnboardServer(socket) { + return createSdkMcpServer({ + name: "onboard", + version: "1.0.0", + tools: [ + tool( + "show_on_map", + "Move the map camera to a zone, node, or flow so the user can see what you are explaining", + { + kind: z.enum(["zone", "node", "flow"]), + id: z.string(), + }, + async (args) => { + try { + socket.send( + encode(showOnMapMessage({ kind: args.kind, id: args.id })), + ); + } catch { + // TODO(socket-closed-mid-tool-call): the WS may have closed + // between the tool call starting and this send. The SDK still + // gets its ack below so the model's turn completes normally — + // the browser simply misses that one camera move. + } + return { + content: [ + { + type: "text", + text: `Shown ${args.kind} ${args.id} on the map.`, + }, + ], + }; + }, + ), + ], + }); +} + +function handleConnection(socket, { cwd }) { + const { enqueue, generator } = createMessageQueue(); + const onboardServer = buildOnboardServer(socket); + const options = { + ...buildOptions({ cwd }), + mcpServers: { onboard: onboardServer }, + }; + + let closed = false; + socket.on("close", () => { + closed = true; + }); + socket.on("error", () => { + // TODO(ws-error-swallow): a transport-level error already implies the + // connection is going away; the subsequent `close` event does cleanup. + // Never let a per-connection transport fault crash the daemon. + }); + + socket.send(encode(readyMessage(AGENT_LABEL))); + + (async () => { + try { + for await (const message of query({ prompt: generator(), options })) { + if (closed) break; + if (message.type === "assistant") { + const blocks = message.message?.content ?? []; + for (const block of blocks) { + if (block?.type === "text" && typeof block.text === "string") { + socket.send(encode(tokenMessage(block.text))); + } + } + } else if (message.type === "result") { + socket.send(encode(doneMessage())); + } + } + } catch (err) { + if (!closed) { + try { + socket.send(encode(errorMessage(err?.message ?? String(err)))); + } catch { + // socket already gone — nothing left to notify. + } + } + } + })(); + + socket.on("message", (raw) => { + const msg = decodeClientMessage(raw.toString()); + if (!msg) return; // malformed/unknown frame — dropped per protocol contract + if (msg.type === "user_message") { + enqueue(msg.text); + } + // TODO(cancel-not-wired): {type:"cancel"} has no cancellation hook into + // the streaming generator yet — the in-flight SDK turn runs to + // completion. Wiring a real abort is deferred to a follow-up (Phase 4 + // hardening); it does not block the Phase 2 smoke contract. + }); +} + +export function createDaemon({ cwd }) { + const httpServer = createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + protocolVersion: PROTOCOL_VERSION, + model: AGENT_LABEL, + }), + ); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "not found" })); + }); + + const wss = new WebSocketServer({ server: httpServer }); + wss.on("connection", (socket) => { + handleConnection(socket, { cwd }); + }); + wss.on("error", (err) => { + process.stderr.write( + `onboard-agent: WS server error: ${err?.message ?? err}\n`, + ); + }); + + return { httpServer, wss }; +} + +function main() { + let cwd; + let port; + try { + ({ cwd, port } = parseArgs(process.argv.slice(2))); + } catch (err) { + fail(err?.message ?? String(err)); + return; + } + + if (!existsSync(cwd) || !statSync(cwd).isDirectory()) { + fail(`--cwd "${cwd}" is not an existing directory`); + return; + } + + const { httpServer } = createDaemon({ cwd }); + + // A per-connection SDK/WS fault must never take the whole daemon down + // (RFC-034 contract: "Handle errors as {error} frames, never crash the + // daemon"). These are the last-resort safety nets above the per-connection + // try/catch in handleConnection. + process.on("uncaughtException", (err) => { + process.stderr.write( + `onboard-agent: uncaught exception: ${err?.stack ?? err}\n`, + ); + }); + process.on("unhandledRejection", (reason) => { + process.stderr.write(`onboard-agent: unhandled rejection: ${reason}\n`); + }); + + httpServer.on("error", (err) => { + if (err && err.code === "EADDRINUSE") { + fail( + `port ${port} is already in use on ${HOST}. Pass a different --port.`, + ); + return; + } + fail(`http server error: ${err?.message ?? err}`); + }); + + httpServer.listen(port, HOST, () => { + process.stdout.write( + `onboard-agent live on ws://${HOST}:${port} (cwd ${cwd})\n`, + ); + }); +} + +// `process.argv[1]` is the path npm/npx invoked, which for an installed +// package's node_modules/.bin/ is a SYMLINK to this file. A strict +// `===` against the resolved import.meta.url path silently fails through +// that symlink (npx never reaches the daemon-boot branch below), so this +// guard compares the REAL path on both sides. +const invokedPath = process.argv[1]; +const isMainModule = + invokedPath !== undefined && + realpathSync(invokedPath) === fileURLToPath(import.meta.url); +if (isMainModule) { + main(); +} diff --git a/agent/lib/profile.mjs b/agent/lib/profile.mjs new file mode 100644 index 0000000..bce653d --- /dev/null +++ b/agent/lib/profile.mjs @@ -0,0 +1,47 @@ +// RFC-034 (Pillar C, Phase 2) / ADR-010 — the read-only Agent SDK profile. +// This is the ONLY place the onboarding session's permission surface is +// defined: Read/Glob/Grep + the in-process `show_on_map` tool are allowed; +// Write/Edit/Bash are explicitly denied. `mcpServers` is intentionally NOT +// set here — the daemon (bin/agent.mjs) owns the per-connection `onboard` +// MCP server instance (it needs a reference to that connection's socket) and +// merges it into the options object returned by `buildOptions`. + +export const ALLOWED_TOOLS = [ + "Read", + "Glob", + "Grep", + "mcp__onboard__show_on_map", +]; + +export const DISALLOWED_TOOLS = ["Write", "Edit", "Bash"]; + +export const SYSTEM_PROMPT = + "You are an onboarding guide for this software project. You have " + + "READ-ONLY access to the repo, its .forgeplan/ workspace, and " + + ".forgeplan/map/map.json (a forgeplan.map/v1 document describing the " + + "project as zones/nodes/edges/flows). Answer the newcomer concisely, in " + + "the language they ask in. Whenever you reference a zone, module, or " + + "flow, CALL the show_on_map tool so the map camera moves to it. Never " + + "invent — use Read/Glob/Grep to check. Prefer map.json + .forgeplan/ for " + + "the big picture."; + +/** + * Builds the Agent SDK `options` object for a persistent onboarding session + * rooted at `cwd` (the project root). Callers (bin/agent.mjs) MUST merge in + * `mcpServers: { onboard: }` before passing this to + * `query()` — this module has no socket to relay tool calls through. + */ +export function buildOptions({ cwd }) { + if (!cwd || typeof cwd !== "string") { + throw new TypeError( + "buildOptions({ cwd }) requires a non-empty string cwd", + ); + } + return { + cwd, + permissionMode: "default", + allowedTools: [...ALLOWED_TOOLS], + disallowedTools: [...DISALLOWED_TOOLS], + systemPrompt: SYSTEM_PROMPT, + }; +} diff --git a/agent/lib/protocol.mjs b/agent/lib/protocol.mjs new file mode 100644 index 0000000..87d9d8c --- /dev/null +++ b/agent/lib/protocol.mjs @@ -0,0 +1,121 @@ +// RFC-034 (Pillar C, Phase 2) — the versioned WebSocket message schema shared +// by both ends of the onboard-agent bridge: this daemon (agent/bin/agent.mjs) +// and the browser's Tier-1 client (template/src/widgets/map-chat/model/ +// agent-client.ts, Phase 3). This is the ONE source of truth for the wire +// shape; bump PROTOCOL_VERSION on any breaking change so the browser can +// detect skew via the `ready` frame and fall back to Tier 0 gracefully. +// +// ClientMsg: { type: "user_message", text } | { type: "cancel" } +// ServerMsg: { type: "ready", protocolVersion, model } +// | { type: "token", delta } +// | { type: "show_on_map", target: { kind, id } } +// | { type: "done" } +// | { type: "error", message } + +export const PROTOCOL_VERSION = 1; + +export const CAMERA_TARGET_KINDS = ["zone", "node", "flow"]; + +export function encode(message) { + return JSON.stringify(message); +} + +/** + * Decodes a raw client-sent string into a ClientMsg. Returns `null` on any + * malformed JSON or unrecognised shape — callers MUST silently ignore a + * `null` result (protocol contract: unknown/malformed frames are dropped, + * never crash the connection). + */ +export function decodeClientMessage(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + + if (parsed.type === "user_message") { + if (typeof parsed.text !== "string") return null; + return { type: "user_message", text: parsed.text }; + } + if (parsed.type === "cancel") { + return { type: "cancel" }; + } + return null; +} + +/** + * Decodes a raw server-sent string into a ServerMsg. Exposed for the + * browser client and for this package's own smoke test — the daemon itself + * only ever encodes (never decodes) ServerMsg frames. + */ +export function decodeServerMessage(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + + switch (parsed.type) { + case "ready": + if ( + typeof parsed.protocolVersion !== "number" || + typeof parsed.model !== "string" + ) { + return null; + } + return { + type: "ready", + protocolVersion: parsed.protocolVersion, + model: parsed.model, + }; + case "token": + if (typeof parsed.delta !== "string") return null; + return { type: "token", delta: parsed.delta }; + case "show_on_map": { + const target = parsed.target; + if ( + !target || + typeof target !== "object" || + !CAMERA_TARGET_KINDS.includes(target.kind) || + typeof target.id !== "string" + ) { + return null; + } + return { + type: "show_on_map", + target: { kind: target.kind, id: target.id }, + }; + } + case "done": + return { type: "done" }; + case "error": + if (typeof parsed.message !== "string") return null; + return { type: "error", message: parsed.message }; + default: + return null; + } +} + +export function readyMessage(model) { + return { type: "ready", protocolVersion: PROTOCOL_VERSION, model }; +} + +export function tokenMessage(delta) { + return { type: "token", delta }; +} + +export function showOnMapMessage(target) { + return { type: "show_on_map", target }; +} + +export function doneMessage() { + return { type: "done" }; +} + +export function errorMessage(message) { + return { type: "error", message }; +} diff --git a/agent/package-lock.json b/agent/package-lock.json new file mode 100644 index 0000000..e50ea38 --- /dev/null +++ b/agent/package-lock.json @@ -0,0 +1,1501 @@ +{ + "name": "@forgeplan/web-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@forgeplan/web-agent", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "ws": "^8.21.0", + "zod": "^4.4.3" + }, + "bin": { + "forgeplan-web-agent": "bin/agent.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", + "integrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.201.tgz", + "integrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.201.tgz", + "integrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.201.tgz", + "integrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.201.tgz", + "integrity": "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.201.tgz", + "integrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.201.tgz", + "integrity": "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.201.tgz", + "integrity": "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.201", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.201.tgz", + "integrity": "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..2794b0c --- /dev/null +++ b/agent/package.json @@ -0,0 +1,42 @@ +{ + "name": "@forgeplan/web-agent", + "version": "0.1.0", + "description": "Optional localhost daemon-bridge that boots a persistent, read-only Claude Agent SDK session for forgeplan-web's onboarding chat (RFC-034 Pillar C / ADR-010). Never a dependency of @forgeplan/web's core bin/ — spawned as a separate process.", + "type": "module", + "bin": { + "forgeplan-web-agent": "./bin/agent.mjs" + }, + "files": [ + "bin", + "lib", + "README.md" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "forgeplan", + "claude-agent-sdk", + "onboarding" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ForgePlan/forgeplan-web.git" + }, + "homepage": "https://github.com/ForgePlan/forgeplan-web#readme", + "bugs": { + "url": "https://github.com/ForgePlan/forgeplan-web/issues" + }, + "scripts": { + "smoke": "node scripts/smoke.mjs" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "ws": "^8.21.0", + "zod": "^4.4.3" + } +} diff --git a/agent/scripts/smoke.mjs b/agent/scripts/smoke.mjs new file mode 100644 index 0000000..ef57170 --- /dev/null +++ b/agent/scripts/smoke.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// RFC-034 (Pillar C, Phase 2) — smoke test for @forgeplan/web-agent that +// runs WITHOUT a live model turn (no Claude Code session needs to actually +// answer a question). Covers exactly what the task hand-off asked for: +// 1. protocol.mjs encode/decode round-trips for every message shape. +// 2. profile.mjs#buildOptions denies Write/Edit/Bash and allows the +// onboard tool. +// 3. The daemon module imports cleanly, its message queue generator +// yields the documented shape, and its `show_on_map` tool relays over +// a fake socket. +// 4. The daemon process actually binds 127.0.0.1:, answers +// `GET /health`, and sends a `{type:"ready"}` frame on WS connect. +// A full live-model turn needs the user's own Claude Code session and is +// verified later (Phase 4) — this script deliberately does not attempt one. + +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import WebSocket from "ws"; + +import { + PROTOCOL_VERSION, + decodeClientMessage, + decodeServerMessage, + doneMessage, + encode, + errorMessage, + readyMessage, + showOnMapMessage, + tokenMessage, +} from "../lib/protocol.mjs"; +import { + ALLOWED_TOOLS, + DISALLOWED_TOOLS, + buildOptions, +} from "../lib/profile.mjs"; +import { buildOnboardServer, createMessageQueue } from "../bin/agent.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const AGENT_BIN = join(ROOT, "bin", "agent.mjs"); + +let failed = false; + +function log(line) { + process.stdout.write(`[agent-smoke] ${line}\n`); +} + +function assert(cond, message) { + if (!cond) { + failed = true; + process.stderr.write(`[agent-smoke] FAIL: ${message}\n`); + } +} + +function checkProtocolRoundTrip() { + log("protocol: encode/decode round-trip"); + + const ready = readyMessage("test-model"); + assert( + decodeServerMessage(encode(ready))?.model === "test-model", + "ready message did not round-trip", + ); + + const token = tokenMessage("hello"); + assert( + decodeServerMessage(encode(token))?.delta === "hello", + "token message did not round-trip", + ); + + const show = showOnMapMessage({ kind: "zone", id: "z1" }); + const decodedShow = decodeServerMessage(encode(show)); + assert( + decodedShow?.type === "show_on_map" && + decodedShow.target.kind === "zone" && + decodedShow.target.id === "z1", + "show_on_map message did not round-trip", + ); + + const done = doneMessage(); + assert( + decodeServerMessage(encode(done))?.type === "done", + "done message did not round-trip", + ); + + const err = errorMessage("boom"); + assert( + decodeServerMessage(encode(err))?.message === "boom", + "error message did not round-trip", + ); + + const userMsg = decodeClientMessage( + encode({ type: "user_message", text: "where is X" }), + ); + assert( + userMsg?.type === "user_message" && userMsg.text === "where is X", + "user_message did not round-trip", + ); + + const cancelMsg = decodeClientMessage(encode({ type: "cancel" })); + assert(cancelMsg?.type === "cancel", "cancel message did not round-trip"); + + assert( + decodeClientMessage("not json") === null, + "malformed client JSON should decode to null", + ); + assert( + decodeClientMessage(encode({ type: "unknown_type" })) === null, + "unknown client message type should decode to null", + ); + assert( + decodeServerMessage( + encode({ type: "show_on_map", target: { kind: "bogus", id: "x" } }), + ) === null, + "show_on_map with an invalid kind should decode to null", + ); + + assert( + typeof PROTOCOL_VERSION === "number", + "PROTOCOL_VERSION must be a number", + ); +} + +function checkProfileDeniesWriteEditBash() { + log("profile: buildOptions denies Write/Edit/Bash, allows the onboard tool"); + + const options = buildOptions({ cwd: ROOT }); + assert(options.cwd === ROOT, "buildOptions did not thread cwd through"); + assert( + options.permissionMode === "default", + "permissionMode should be default", + ); + assert( + Array.isArray(options.disallowedTools) && + ["Write", "Edit", "Bash"].every((t) => + options.disallowedTools.includes(t), + ), + "disallowedTools must include Write, Edit, and Bash", + ); + assert( + Array.isArray(options.allowedTools) && + options.allowedTools.includes("mcp__onboard__show_on_map"), + "allowedTools must include mcp__onboard__show_on_map", + ); + assert( + !("mcpServers" in options), + "buildOptions must not set mcpServers itself", + ); + assert( + DISALLOWED_TOOLS.includes("Write") && + DISALLOWED_TOOLS.includes("Edit") && + DISALLOWED_TOOLS.includes("Bash"), + "DISALLOWED_TOOLS constant drifted from the read-only contract", + ); + assert( + ALLOWED_TOOLS.includes("Read") && + ALLOWED_TOOLS.includes("Glob") && + ALLOWED_TOOLS.includes("Grep"), + "ALLOWED_TOOLS constant missing a read-only primitive", + ); + + let threw = false; + try { + buildOptions({}); + } catch { + threw = true; + } + assert(threw, "buildOptions({}) (no cwd) must throw, not silently proceed"); +} + +async function checkMessageQueueAndToolRelay() { + log("daemon module: message queue shape + show_on_map tool relay"); + + const { enqueue, generator } = createMessageQueue(); + const gen = generator(); + const pending = gen.next(); // starts awaiting — queue is empty + enqueue("hello agent"); + const { value, done } = await pending; + assert(done !== true, "generator should not be done after first message"); + assert(value?.type === "user", "queued message should have type 'user'"); + assert( + value?.message?.role === "user" && + value?.message?.content === "hello agent", + "queued message content did not match what was enqueued", + ); + + const sent = []; + const fakeSocket = { send: (raw) => sent.push(raw) }; + const server = buildOnboardServer(fakeSocket); + assert( + server?.name === "onboard" || server?.type != null, + "buildOnboardServer should return an SDK MCP server config object", + ); +} + +async function waitForLine(child, predicate, timeoutMs = 15_000) { + return new Promise((resolvePromise, rejectPromise) => { + let buf = ""; + const timer = setTimeout(() => { + rejectPromise(new Error(`timed out waiting for daemon stdout: ${buf}`)); + }, timeoutMs); + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + if (predicate(buf)) { + clearTimeout(timer); + resolvePromise(buf); + } + }); + child.stderr.on("data", (chunk) => { + buf += chunk.toString(); + }); + }); +} + +async function checkDaemonProcess() { + log("daemon process: binds 127.0.0.1, /health responds, WS sends ready"); + + const scratch = mkdtempSync(join(tmpdir(), "fpw-agent-smoke-")); + const port = 17400 + Math.floor(Math.random() * 200); + + const child = spawn( + process.execPath, + [AGENT_BIN, "--cwd", scratch, "--port", String(port)], + { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }, + ); + + try { + await waitForLine(child, (buf) => buf.includes("onboard-agent live on")); + log(`daemon reported live on port ${port}`); + + const health = await fetch(`http://127.0.0.1:${port}/health`).then((r) => + r.json(), + ); + assert(health.ok === true, "/health should report ok: true"); + assert( + health.protocolVersion === PROTOCOL_VERSION, + "/health protocolVersion should match PROTOCOL_VERSION", + ); + + const readyFrame = await new Promise((resolvePromise, rejectPromise) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const timer = setTimeout(() => { + ws.terminate(); + rejectPromise(new Error("timed out waiting for {type:ready} frame")); + }, 5_000); + ws.on("message", (raw) => { + clearTimeout(timer); + const msg = decodeServerMessage(raw.toString()); + ws.close(); + resolvePromise(msg); + }); + ws.on("error", (err) => { + clearTimeout(timer); + rejectPromise(err); + }); + }); + assert( + readyFrame?.type === "ready", + "first WS frame should be {type:'ready'}", + ); + assert( + readyFrame?.protocolVersion === PROTOCOL_VERSION, + "ready frame protocolVersion should match PROTOCOL_VERSION", + ); + log(`WS ready frame: model="${readyFrame?.model}"`); + } finally { + child.kill("SIGTERM"); + rmSync(scratch, { recursive: true, force: true }); + } +} + +async function main() { + checkProtocolRoundTrip(); + checkProfileDeniesWriteEditBash(); + await checkMessageQueueAndToolRelay(); + await checkDaemonProcess(); + + if (failed) { + process.stderr.write("[agent-smoke] FAIL — see above\n"); + process.exit(1); + } + log("ALL CHECKS PASS (no live-model turn exercised — see file header)"); +} + +main().catch((err) => { + process.stderr.write(`[agent-smoke] unhandled: ${err?.stack ?? err}\n`); + process.exit(1); +}); diff --git a/bin/cli.mjs b/bin/cli.mjs index 68f812f..b30aa79 100644 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -12,5 +12,7 @@ export default defineCommand({ init: () => import("./commands/init.mjs").then((m) => m.default), update: () => import("./commands/update.mjs").then((m) => m.default), start: () => import("./commands/start.mjs").then((m) => m.default), + "onboard-agent": () => + import("./commands/onboard-agent.mjs").then((m) => m.default), }, }); diff --git a/bin/commands/onboard-agent.mjs b/bin/commands/onboard-agent.mjs new file mode 100644 index 0000000..3b247a9 --- /dev/null +++ b/bin/commands/onboard-agent.mjs @@ -0,0 +1,164 @@ +import { defineCommand } from "citty"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +// RFC-034 (Pillar C, Phase 3a) / ADR-010: this subcommand is SPAWN-ONLY. It +// never imports `@forgeplan/web-agent` — only `child_process.spawn`s its +// binary once resolved. The heavy Agent SDK dependency tree lives entirely +// in that separate, optional package (rule 23 / ADR-003 invariant: bin/ +// stays `node:*` + citty + relative siblings only). + +const AGENT_PKG = "@forgeplan/web-agent"; +const AGENT_BIN_NAME = "forgeplan-web-agent"; +const DEFAULT_PORT = 7431; + +function fail(line, code = 1) { + process.stderr.write(`forgeplan-web: ${line}\n`); + process.exit(code); +} + +function printInstallHint() { + process.stderr.write( + "forgeplan-web: the onboarding agent is an optional package.\n" + + ` Install it with: npx ${AGENT_PKG}\n` + + ` (or: npm i -g ${AGENT_PKG})\n`, + ); +} + +function localBinCandidates(cwd) { + const base = join(cwd, "node_modules", ".bin", AGENT_BIN_NAME); + return process.platform === "win32" + ? [`${base}.cmd`, `${base}.ps1`, base] + : [base]; +} + +/** + * Resolves an already-installed `@forgeplan/web-agent` binary without ever + * loading the package's code. Two lookup strategies, in order: + * 1. Node's own module resolution (`require.resolve`) walking up from + * `cwd` — resolves the package's `package.json#bin` entry to a real + * filesystem path and invokes it as `node `. This is the + * preferred strategy: it always launches via a fully-resolved path + * regardless of how the package was linked into `node_modules`, so it + * is robust to `node_modules/.bin` being a symlink (the standard npm + * layout on POSIX). It only resolves a filesystem PATH; it never + * executes or imports the package itself (rule 23). + * 2. `node_modules/.bin/` next to `cwd` — a plain fallback for + * the (rare) case where module resolution above fails to locate the + * package's `package.json` even though a `.bin` entry exists. + * Returns `null` when the package cannot be found locally at all — callers + * fall back to `npx` on-demand resolution. + */ +function resolvePackageBin(cwd) { + try { + // createRequire's argument only anchors the resolution directory; it + // does not need to exist on disk. + const requireFromCwd = createRequire(join(cwd, "noop.cjs")); + const pkgJsonPath = requireFromCwd.resolve(`${AGENT_PKG}/package.json`); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + const binField = pkgJson.bin; + const binRelative = + typeof binField === "string" ? binField : binField?.[AGENT_BIN_NAME]; + if (binRelative) { + const resolvedBin = join(dirname(pkgJsonPath), binRelative); + if (existsSync(resolvedBin)) { + return { cmd: process.execPath, args: [resolvedBin] }; + } + } + } catch { + // Not resolvable via node module resolution — fall through to the + // node_modules/.bin check, then to the npx fallback in run(). + } + + for (const candidate of localBinCandidates(cwd)) { + if (existsSync(candidate)) return { cmd: candidate, args: [] }; + } + + return null; +} + +export default defineCommand({ + meta: { + name: "onboard-agent", + description: + "Launch the optional @forgeplan/web-agent daemon (RFC-034 Pillar C / ADR-010): a localhost-only live onboarding agent the web chat upgrades to when present. Spawn-only — never imports the agent package.", + }, + args: { + port: { + type: "string", + default: String(DEFAULT_PORT), + description: "port the daemon binds on 127.0.0.1", + valueHint: String(DEFAULT_PORT), + }, + cwd: { + type: "string", + description: + "project root the agent reads from (default: current directory)", + valueHint: "/path/to/project", + }, + }, + async run({ args }) { + const cwd = + typeof args.cwd === "string" && args.cwd.length > 0 + ? args.cwd + : process.cwd(); + + const portNum = Number(args.port); + if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65_535) { + fail(`invalid --port value "${args.port}"; expected 1..65535.`); + } + const port = String(portNum); + const agentArgs = ["--cwd", cwd, "--port", port]; + + const resolved = resolvePackageBin(cwd); + + let cmd; + let cmdArgs; + if (resolved) { + cmd = resolved.cmd; + cmdArgs = [...resolved.args, ...agentArgs]; + } else { + // Not installed locally — fall back to on-demand resolution via npx. + // npx performs its own "is it published/cached" check; we only guard + // the spawn() boundary below against ENOENT (e.g. npx itself missing + // from PATH), never surfacing a raw ENOENT to the user. + cmd = "npx"; + cmdArgs = ["--yes", AGENT_PKG, ...agentArgs]; + } + + const isDirectNodeInvocation = cmd === process.execPath; + const useShell = process.platform === "win32" && !isDirectNodeInvocation; + + const child = spawn(cmd, cmdArgs, { + stdio: "inherit", + shell: useShell, + }); + + const forward = (sig) => { + if (!child.killed) child.kill(sig); + }; + process.on("SIGINT", () => forward("SIGINT")); + process.on("SIGTERM", () => forward("SIGTERM")); + + return new Promise((resolvePromise) => { + child.on("error", (err) => { + if (err && err.code === "ENOENT") { + printInstallHint(); + process.exit(1); + } else { + fail(`failed to launch onboarding agent: ${err?.message ?? err}`); + } + resolvePromise(); + }); + child.on("exit", (code, signal) => { + if (signal) { + process.exit(1); + } else { + process.exit(code ?? 0); + } + }); + }); + }, +}); diff --git a/template/src/widgets/map-chat/model/agent-client.test.ts b/template/src/widgets/map-chat/model/agent-client.test.ts new file mode 100644 index 0000000..26029ca --- /dev/null +++ b/template/src/widgets/map-chat/model/agent-client.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { probeDaemon, connectAgent, type AgentHandlers } from "./agent-client"; +import type { CameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; + +// RFC-034 Test Strategy Hooks — probe up/down; token stream assembles; +// `show_on_map` frame -> `onShowOnMap`; close -> `onClose`. A hand-rolled +// mock WebSocket stands in for the real thing: it records what was sent +// and lets a test fire `message`/`error`/`close` events on demand. + +const OPEN = 1; +const CLOSED = 3; + +class MockSocket { + static CONNECTING = 0; + static OPEN = OPEN; + static CLOSING = 2; + static CLOSED = CLOSED; + + readyState = MockSocket.CONNECTING; + url: string; + sent: string[] = []; + closed = false; + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + instances.push(this); + } + + addEventListener(type: string, cb: (event: unknown) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(cb); + } + + removeEventListener(type: string, cb: (event: unknown) => void): void { + this.listeners.get(type)?.delete(cb); + } + + send(data: string): void { + if (this.readyState !== OPEN) throw new Error("socket not open"); + this.sent.push(data); + } + + close(): void { + this.closed = true; + this.readyState = CLOSED; + } + + /** Test helper: simulate the socket reaching OPEN. */ + open(): void { + this.readyState = OPEN; + } + + /** Test helper: fire a listener as the real WebSocket would. */ + emit(type: string, event: unknown = {}): void { + for (const cb of this.listeners.get(type) ?? []) cb(event); + } + + emitMessage(payload: unknown): void { + this.emit("message", { data: JSON.stringify(payload) }); + } +} + +let instances: MockSocket[] = []; + +function lastSocket(): MockSocket { + const socket = instances[instances.length - 1]; + expect(socket).toBeDefined(); + return socket!; +} + +beforeEach(() => { + instances = []; + vi.stubGlobal("WebSocket", MockSocket); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("probeDaemon", () => { + it("resolves up:true with the model when a ready frame arrives", async () => { + const result = probeDaemon(7431); + const socket = lastSocket(); + socket.emitMessage({ + type: "ready", + protocolVersion: 1, + model: "claude-x", + }); + await expect(result).resolves.toEqual({ up: true, model: "claude-x" }); + }); + + it("closes the probe socket after resolving", async () => { + const result = probeDaemon(7431); + const socket = lastSocket(); + socket.emitMessage({ type: "ready", model: "claude-x" }); + await result; + expect(socket.closed).toBe(true); + }); + + it("resolves up:false on a socket error", async () => { + const result = probeDaemon(7431); + const socket = lastSocket(); + socket.emit("error"); + await expect(result).resolves.toEqual({ up: false }); + }); + + it("resolves up:false on a socket close with no ready frame", async () => { + const result = probeDaemon(7431); + const socket = lastSocket(); + socket.emit("close"); + await expect(result).resolves.toEqual({ up: false }); + }); + + it("resolves up:false after the timeout when nothing arrives", async () => { + const result = probeDaemon(7431); + await vi.advanceTimersByTimeAsync(5000); + await expect(result).resolves.toEqual({ up: false }); + }); + + it("ignores malformed JSON frames instead of throwing", async () => { + const result = probeDaemon(7431); + const socket = lastSocket(); + socket.emit("message", { data: "{not json" }); + // Malformed frame is ignored — only the later ready frame settles it. + socket.emitMessage({ type: "ready" }); + await expect(result).resolves.toEqual({ up: true, model: undefined }); + }); + + it("resolves up:false immediately with no global WebSocket (SSR)", async () => { + vi.stubGlobal("WebSocket", undefined); + await expect(probeDaemon(7431)).resolves.toEqual({ up: false }); + }); +}); + +function handlers(): AgentHandlers & + Record> { + return { + onToken: vi.fn<(delta: string) => void>(), + onShowOnMap: vi.fn<(target: CameraTarget) => void>(), + onDone: vi.fn<() => void>(), + onError: vi.fn<(message: string) => void>(), + onClose: vi.fn<() => void>(), + }; +} + +describe("connectAgent", () => { + it("assembles a token stream by forwarding each delta in order", () => { + const h = handlers(); + connectAgent(7431, h); + const socket = lastSocket(); + socket.emitMessage({ type: "token", delta: "Hel" }); + socket.emitMessage({ type: "token", delta: "lo" }); + expect(h.onToken.mock.calls).toEqual([["Hel"], ["lo"]]); + }); + + it("routes a show_on_map frame to onShowOnMap", () => { + const h = handlers(); + connectAgent(7431, h); + const socket = lastSocket(); + const target = { kind: "zone" as const, id: "z.a" }; + socket.emitMessage({ type: "show_on_map", target }); + expect(h.onShowOnMap).toHaveBeenCalledWith(target); + }); + + it("routes a done frame to onDone", () => { + const h = handlers(); + connectAgent(7431, h); + lastSocket().emitMessage({ type: "done" }); + expect(h.onDone).toHaveBeenCalledTimes(1); + }); + + it("routes an error frame to onError with the message", () => { + const h = handlers(); + connectAgent(7431, h); + lastSocket().emitMessage({ type: "error", message: "boom" }); + expect(h.onError).toHaveBeenCalledWith("boom"); + }); + + it("routes an unsolicited close to onClose", () => { + const h = handlers(); + connectAgent(7431, h); + lastSocket().emit("close"); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose again when the caller itself closes the connection", () => { + const h = handlers(); + const conn = connectAgent(7431, h); + const socket = lastSocket(); + conn.close(); + // The real WebSocket fires its own close event once the underlying + // socket actually terminates -- simulate that arriving after close(). + socket.emit("close"); + expect(h.onClose).not.toHaveBeenCalled(); + }); + + it("sends a user_message frame only once the socket is open", () => { + const h = handlers(); + const conn = connectAgent(7431, h); + const socket = lastSocket(); + conn.send("hello"); + expect(socket.sent).toEqual([]); + socket.open(); + conn.send("hello again"); + expect(socket.sent).toEqual([ + JSON.stringify({ type: "user_message", text: "hello again" }), + ]); + }); + + it("sends a cancel frame", () => { + const h = handlers(); + const conn = connectAgent(7431, h); + const socket = lastSocket(); + socket.open(); + conn.cancel(); + expect(socket.sent).toEqual([JSON.stringify({ type: "cancel" })]); + }); + + it("ignores malformed frames instead of throwing", () => { + const h = handlers(); + connectAgent(7431, h); + const socket = lastSocket(); + expect(() => socket.emit("message", { data: "{not json" })).not.toThrow(); + expect(h.onToken).not.toHaveBeenCalled(); + }); + + it("degrades to a no-op connection plus an async onClose with no global WebSocket", async () => { + vi.stubGlobal("WebSocket", undefined); + const h = handlers(); + const conn = connectAgent(7431, h); + expect(() => conn.send("x")).not.toThrow(); + expect(() => conn.cancel()).not.toThrow(); + expect(() => conn.close()).not.toThrow(); + // The degraded connection reports via a queued microtask, not a timer + // -- flush microtasks directly rather than reaching for a real-timer + // poll (vi.waitFor) while fake timers are active in this suite. + await Promise.resolve(); + await Promise.resolve(); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/template/src/widgets/map-chat/model/agent-client.ts b/template/src/widgets/map-chat/model/agent-client.ts new file mode 100644 index 0000000..e512d3a --- /dev/null +++ b/template/src/widgets/map-chat/model/agent-client.ts @@ -0,0 +1,215 @@ +// RFC-034 (Pillar C, Phase 3b) — read-only WebSocket client for the +// onboarding daemon (@forgeplan/web-agent). The browser talks to +// ws://127.0.0.1: DIRECTLY — never through /api/* (rule 22: the +// SvelteKit server is a read-only mirror and structurally cannot proxy +// this). Every export here is defensive by contract: a missing daemon, a +// dropped connection, or an unparseable frame degrades to a callback (or +// a resolved `{ up: false }`), never a thrown exception — chat-store's +// Tier 1 must be able to fail silently back to Tier 0. + +import type { CameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; + +const PROBE_TIMEOUT_MS = 1500; + +// Mirrors the daemon's lib/protocol.mjs wire schema (RFC-034 Function +// Signatures/Contracts) — one source of truth split across two packages. +type ServerMsg = + | { type: "ready"; protocolVersion?: number; model?: string } + | { type: "token"; delta: string } + | { type: "show_on_map"; target: CameraTarget } + | { type: "done" } + | { type: "error"; message: string }; + +type ClientMsg = { type: "user_message"; text: string } | { type: "cancel" }; + +export interface ProbeResult { + up: boolean; + model?: string; +} + +export interface AgentHandlers { + onToken(delta: string): void; + onShowOnMap(target: CameraTarget): void; + onDone(): void; + onError(message: string): void; + onClose(): void; +} + +export interface AgentConnection { + send(text: string): void; + cancel(): void; + close(): void; +} + +function daemonUrl(port: number): string { + return `ws://127.0.0.1:${port}`; +} + +function isServerMsgShape(value: unknown): value is { type: string } { + return ( + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" + ); +} + +/** Parses one WS text frame as a `ServerMsg`. Unknown `type`s and + * malformed JSON both degrade to `null` rather than throwing — a future + * daemon protocol bump must not crash an older web build. */ +function parseServerMsg(raw: unknown): ServerMsg | null { + if (typeof raw !== "string") return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!isServerMsgShape(parsed)) return null; + switch (parsed.type) { + case "ready": + case "token": + case "show_on_map": + case "done": + case "error": + return parsed as ServerMsg; + default: + return null; + } + } catch { + return null; + } +} + +/** + * Briefly opens the daemon's WebSocket and resolves once a `ready` frame + * arrives, the socket errors/closes, or `PROBE_TIMEOUT_MS` elapses — + * whichever comes first. Always closes the probe socket itself before + * resolving. Never throws; an environment with no global `WebSocket` + * (e.g. SSR) resolves `{ up: false }` immediately without attempting a + * connection. + */ +export function probeDaemon(port: number): Promise { + return new Promise((resolve) => { + if (typeof WebSocket === "undefined") { + resolve({ up: false }); + return; + } + + let socket: WebSocket; + try { + socket = new WebSocket(daemonUrl(port)); + } catch { + resolve({ up: false }); + return; + } + + let settled = false; + const finish = (result: ProbeResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + try { + socket.close(); + } catch { + // Already closed/closing — nothing left to clean up. + } + resolve(result); + }; + + const onMessage = (event: MessageEvent): void => { + const msg = parseServerMsg(event.data); + if (msg?.type === "ready") finish({ up: true, model: msg.model }); + }; + const onError = (): void => finish({ up: false }); + const onClose = (): void => finish({ up: false }); + const timer = setTimeout(() => finish({ up: false }), PROBE_TIMEOUT_MS); + + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + }); +} + +/** + * Opens a persistent WebSocket session to the daemon and routes every + * frame to `handlers`. Returns immediately without waiting for `ready` — + * a daemon that never answers surfaces through `onError`/`onClose`, the + * same path a daemon that answers and later drops takes. Never throws; a + * missing global `WebSocket` degrades to a no-op connection plus an + * async `onClose` so the caller's fallback path runs uniformly either way. + */ +export function connectAgent( + port: number, + handlers: AgentHandlers, +): AgentConnection { + const noop = (): void => {}; + + if (typeof WebSocket === "undefined") { + queueMicrotask(() => handlers.onClose()); + return { send: noop, cancel: noop, close: noop }; + } + + let socket: WebSocket; + try { + socket = new WebSocket(daemonUrl(port)); + } catch { + queueMicrotask(() => handlers.onClose()); + return { send: noop, cancel: noop, close: noop }; + } + + let closedByCaller = false; + const dispatch = (payload: ClientMsg): void => { + if (socket.readyState !== WebSocket.OPEN) return; + try { + socket.send(JSON.stringify(payload)); + } catch { + // Dropped between the readyState check and send — the close/error + // event already in flight will notify the caller. + } + }; + + socket.addEventListener("message", (event: MessageEvent) => { + const msg = parseServerMsg(event.data); + if (!msg) return; + switch (msg.type) { + case "ready": + return; + case "token": + handlers.onToken(msg.delta); + return; + case "show_on_map": + handlers.onShowOnMap(msg.target); + return; + case "done": + handlers.onDone(); + return; + case "error": + handlers.onError(msg.message); + return; + } + }); + socket.addEventListener("error", () => { + if (!closedByCaller) { + handlers.onError("Connection to the live agent failed."); + } + }); + socket.addEventListener("close", () => { + if (!closedByCaller) handlers.onClose(); + }); + + return { + send(text: string): void { + dispatch({ type: "user_message", text }); + }, + cancel(): void { + dispatch({ type: "cancel" }); + }, + close(): void { + closedByCaller = true; + try { + socket.close(); + } catch { + // Already closed/closing. + } + }, + }; +} diff --git a/template/src/widgets/map-chat/model/chat-store.svelte.ts b/template/src/widgets/map-chat/model/chat-store.svelte.ts index c71a67d..c05859f 100644 --- a/template/src/widgets/map-chat/model/chat-store.svelte.ts +++ b/template/src/widgets/map-chat/model/chat-store.svelte.ts @@ -1,12 +1,20 @@ -// RFC-034 (Pillar C, Phase 1b) — the chat's message/tier store. Mirrors -// node-tabs.svelte.ts / camera-bus.svelte.ts's plain module-level `$state` -// shape: no class, no context, one shared instance per page; state stays -// module-private and is only ever read/written through the exported -// functions below. +// RFC-034 (Pillar C, Phase 3b) — the chat's message/tier/live-connection +// store. Mirrors node-tabs.svelte.ts / camera-bus.svelte.ts's plain +// module-level `$state` shape: no class, no context, one shared instance +// per page; state stays module-private and is only ever read/written +// through the exported functions below. +// +// Tier 0 (client-grounded, model-free) is the permanent fallback. Tier 1 +// (the live daemon, @forgeplan/web-agent) is opportunistic: `checkDaemon` +// probes it and upgrades the tier on success; a live connection that +// errors or closes degrades back to Tier 0 (RFC-034 graceful-degradation +// NFR) rather than leaving the chat stuck mid-answer. import type { MapDocument } from "@/entities/map"; import { answerFromMap } from "./tier0"; import { showOnMap } from "@/widgets/composed-map/model/camera-bus.svelte"; +import { probeDaemon, connectAgent } from "./agent-client"; +import type { AgentConnection } from "./agent-client"; export interface ChatMessage { role: "user" | "assistant"; @@ -15,40 +23,181 @@ export interface ChatMessage { export type ChatTier = "tier0" | "tier1"; +/** RFC-034 ADI cycle A (A1) — fixed default port + probe for the MVP. */ +export const DEFAULT_AGENT_PORT = 7431; +const PROBE_INTERVAL_MS = 15_000; + let messages = $state([]); let tier = $state("tier0"); +let model = $state(null); +let pending = $state(false); + +let connection: AgentConnection | null = null; +let activeAssistantIndex: number | null = null; +let probeTimer: ReturnType | null = null; +let agentPort = DEFAULT_AGENT_PORT; /** View reads: the current transcript, oldest first. */ export function getMessages(): ChatMessage[] { return messages; } -/** View reads: which tier is currently answering (Phase 1b is always Tier 0). */ +/** View reads: which tier is currently answering. */ export function getTier(): ChatTier { return tier; } +/** View reads: the live daemon's advertised model name (Tier 1 only). */ +export function getModel(): string | null { + return model; +} + +/** View reads: true while a Tier-1 answer is still streaming in. */ +export function isPending(): boolean { + return pending; +} + +function appendMessage(role: ChatMessage["role"], text: string): number { + messages = [...messages, { role, text }]; + return messages.length - 1; +} + +function appendDelta(index: number, delta: string): void { + const existing = messages[index]; + if (!existing) return; + const next = messages.slice(); + next[index] = { ...existing, text: existing.text + delta }; + messages = next; +} + +/** Tears down any live connection and reverts to the offline tier. A + * still-empty placeholder assistant bubble (no tokens ever arrived) is + * dropped rather than left dangling; a partial answer is kept as-is. */ +function fallBackToTier0(): void { + if ( + activeAssistantIndex !== null && + messages[activeAssistantIndex]?.text === "" + ) { + const dropIndex = activeAssistantIndex; + messages = messages.filter((_, i) => i !== dropIndex); + } + connection?.close(); + connection = null; + tier = "tier0"; + model = null; + pending = false; + activeAssistantIndex = null; +} + +function handleError(message: string): void { + if (activeAssistantIndex !== null) { + const existing = messages[activeAssistantIndex]?.text ?? ""; + appendDelta( + activeAssistantIndex, + existing.length > 0 ? `\n\n${message}` : message, + ); + } + fallBackToTier0(); +} + +function handleDone(): void { + pending = false; + activeAssistantIndex = null; +} + +function ensureConnection(): AgentConnection { + if (connection) return connection; + connection = connectAgent(agentPort, { + onToken: (delta) => { + if (activeAssistantIndex !== null) + appendDelta(activeAssistantIndex, delta); + }, + onShowOnMap: showOnMap, + onDone: handleDone, + onError: handleError, + onClose: fallBackToTier0, + }); + return connection; +} + +function sendTier1(question: string): void { + pending = true; + activeAssistantIndex = appendMessage("assistant", ""); + ensureConnection().send(question); +} + /** - * Sends a user question: pushes the user message, answers it (Tier 0 today — - * client-grounded, model-free), pushes the assistant reply, and — when the - * answer names a zone/node/flow — drives the map camera via camera-bus. + * Sends a user question. Tier 0 (default/fallback): answers instantly, + * client-grounded, from the loaded `MapDocument`. Tier 1 (daemon + * connected): pushes an empty assistant message and streams the live + * agent's answer into it, relaying any `show_on_map` call to the camera + * the same way Tier 0 does. */ export function send(doc: MapDocument, question: string): void { const trimmed = question.trim(); if (!trimmed) return; + if (tier === "tier1" && pending) return; // one in-flight Tier-1 answer at a time + messages = [...messages, { role: "user", text: trimmed }]; - // TODO(pillar-c-phase3-tier1): once the daemon (@forgeplan/web-agent) is - // probed and connected, a "tier1" tier should route through - // agent-client.ts's WebSocket session instead of answerFromMap. Tier 0 - // remains the offline fallback whenever the daemon is absent/unreachable. + if (tier === "tier1") { + sendTier1(trimmed); + return; + } + const { text, target } = answerFromMap(doc, trimmed); messages = [...messages, { role: "assistant", text }]; if (target) showOnMap(target); } +/** + * Probes the daemon once and updates tier/model on success. Exposed + * directly (not just via the interval) so callers — including tests — + * can await a single check without waiting on `PROBE_INTERVAL_MS`. A + * down result only reverts to Tier 0 when there's no live connection + * already open — an established Tier-1 session's own onError/onClose is + * the source of truth for *that* session dropping, not a parallel probe. + */ +export async function checkDaemon( + port: number = DEFAULT_AGENT_PORT, +): Promise { + agentPort = port; + const result = await probeDaemon(port); + if (result.up) { + tier = "tier1"; + model = result.model ?? null; + } else if (!connection) { + tier = "tier0"; + model = null; + } +} + +/** View lifecycle (MapChat onMount): start probing for the daemon. + * Idempotent — a second call while a timer is already running is a + * no-op. */ +export function startAgentProbe(port: number = DEFAULT_AGENT_PORT): void { + if (probeTimer) return; + void checkDaemon(port); + probeTimer = setInterval(() => void checkDaemon(port), PROBE_INTERVAL_MS); +} + +/** View lifecycle (MapChat onDestroy): stop probing and close any live + * connection. */ +export function stopAgentProbe(): void { + if (probeTimer) { + clearInterval(probeTimer); + probeTimer = null; + } + connection?.close(); + connection = null; +} + /** Test/dev helper: resets the shared store to its initial state. */ export function resetChat(): void { + stopAgentProbe(); messages = []; tier = "tier0"; + model = null; + pending = false; + activeAssistantIndex = null; } diff --git a/template/src/widgets/map-chat/model/chat-store.test.ts b/template/src/widgets/map-chat/model/chat-store.test.ts index c0c4e85..ef8a316 100644 --- a/template/src/widgets/map-chat/model/chat-store.test.ts +++ b/template/src/widgets/map-chat/model/chat-store.test.ts @@ -1,19 +1,40 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { send, getMessages, getTier, resetChat } from "./chat-store.svelte"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + send, + getMessages, + getModel, + getTier, + isPending, + checkDaemon, + resetChat, +} from "./chat-store.svelte"; import { currentCameraRequest, clearCameraTarget, } from "@/widgets/composed-map/model/camera-bus.svelte"; import type { MapDocument, MapZone } from "@/entities/map"; +import { probeDaemon, connectAgent, type AgentHandlers } from "./agent-client"; // RFC-034 Test Strategy Hooks — send() pushes user+assistant messages, and // drives camera-bus.showOnMap exactly when the tier0 answer carries a // target. Module-level state (messages/tier here, the camera request in // camera-bus) persists across tests in this file — reset both before every // test, mirroring camera-bus.test.ts's own isolation. +// +// agent-client is mocked file-wide: the Tier-0-only describe blocks below +// never call checkDaemon/send-in-tier1, so the mock is inert for them; the +// "tier1" block reassigns probeDaemon/connectAgent per test to drive the +// store's live-agent branch deterministically, without a real socket. +vi.mock("./agent-client", () => ({ + probeDaemon: vi.fn(), + connectAgent: vi.fn(), +})); + beforeEach(() => { resetChat(); clearCameraTarget(); + vi.mocked(probeDaemon).mockReset(); + vi.mocked(connectAgent).mockReset(); }); function zone(overrides: Partial = {}): MapZone { @@ -120,3 +141,157 @@ describe("chat-store — resetChat", () => { expect(getTier()).toBe("tier0"); }); }); + +// RFC-034 Phase 3b Test Strategy Hooks — checkDaemon upgrades the tier on a +// successful probe; send() in tier1 streams tokens into the assistant +// message via a mocked agent-client and drives camera-bus the same way +// tier0 does; onError/onClose fall back to tier0 gracefully. +describe("chat-store — tier1", () => { + function mockConnection() { + const conn = { send: vi.fn(), cancel: vi.fn(), close: vi.fn() }; + let handlers: AgentHandlers | undefined; + vi.mocked(connectAgent).mockImplementation((_port, h) => { + handlers = h; + return conn; + }); + return { + conn, + handlers: () => { + expect(handlers).toBeDefined(); + return handlers!; + }, + }; + } + + it("upgrades to tier1 and records the model once the daemon probe succeeds", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + expect(getTier()).toBe("tier1"); + expect(getModel()).toBe("claude-mock"); + }); + + it("stays on tier0 when the probe reports the daemon down", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ up: false }); + await checkDaemon(7431); + expect(getTier()).toBe("tier0"); + expect(getModel()).toBeNull(); + }); + + it("streams tokens into a progressively-updated assistant message", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + send(fixtureDoc(), "Where does artifact recording live?"); + expect(getMessages()).toEqual([ + { role: "user", text: "Where does artifact recording live?" }, + { role: "assistant", text: "" }, + ]); + expect(isPending()).toBe(true); + + handlers().onToken("Arti"); + handlers().onToken("facts live in .forgeplan/"); + expect(getMessages()[1]).toEqual({ + role: "assistant", + text: "Artifacts live in .forgeplan/", + }); + + handlers().onDone(); + expect(isPending()).toBe(false); + }); + + it("relays a show_on_map call to camera-bus during a tier1 answer", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + const before = currentCameraRequest().seq; + send(fixtureDoc(), "Where does artifact recording live?"); + handlers().onShowOnMap({ kind: "zone", id: "z.a" }); + + const after = currentCameraRequest(); + expect(after.seq).toBe(before + 1); + expect(after.target).toEqual({ kind: "zone", id: "z.a" }); + }); + + it("ignores a second send while a tier1 answer is still pending", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { conn } = mockConnection(); + + send(fixtureDoc(), "First question"); + send(fixtureDoc(), "Second question"); + expect(conn.send).toHaveBeenCalledTimes(1); + expect(getMessages()).toHaveLength(2); + }); + + it("falls back to tier0 and surfaces the message on onError", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + send(fixtureDoc(), "Where does artifact recording live?"); + handlers().onError("daemon crashed"); + + expect(getTier()).toBe("tier0"); + expect(getModel()).toBeNull(); + expect(isPending()).toBe(false); + expect(getMessages()[1]).toEqual({ + role: "assistant", + text: "daemon crashed", + }); + }); + + it("falls back to tier0 and drops the empty placeholder on an unsolicited close", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + send(fixtureDoc(), "Where does artifact recording live?"); + handlers().onClose(); + + expect(getTier()).toBe("tier0"); + // No tokens ever arrived — the dangling empty assistant bubble is + // dropped rather than left in the transcript. + expect(getMessages()).toEqual([ + { role: "user", text: "Where does artifact recording live?" }, + ]); + }); + + it("keeps a partial answer intact when the connection drops mid-stream", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + send(fixtureDoc(), "Where does artifact recording live?"); + handlers().onToken("Partial answer"); + handlers().onClose(); + + expect(getTier()).toBe("tier0"); + expect(getMessages()[1]).toEqual({ + role: "assistant", + text: "Partial answer", + }); + }); +}); diff --git a/template/src/widgets/map-chat/ui/MapChat.render.test.ts b/template/src/widgets/map-chat/ui/MapChat.render.test.ts index 9f9857c..4cd72ee 100644 --- a/template/src/widgets/map-chat/ui/MapChat.render.test.ts +++ b/template/src/widgets/map-chat/ui/MapChat.render.test.ts @@ -7,9 +7,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mount, unmount, flushSync } from "svelte"; import MapChat from "./MapChat.svelte"; -import { resetChat } from "../model/chat-store.svelte"; -import { clearCameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; +import { checkDaemon, resetChat } from "../model/chat-store.svelte"; +import { + clearCameraTarget, + currentCameraRequest, +} from "@/widgets/composed-map/model/camera-bus.svelte"; import type { MapDocument, MapZone } from "@/entities/map"; +import { + probeDaemon, + connectAgent, + type AgentHandlers, +} from "../model/agent-client"; + +// Phase 3b: agent-client is mocked so the pre-existing Tier-0 assertions +// below stay deterministic (no real socket, no real daemon on the test +// host) and so Tier-1 rendering can be driven explicitly per test. +vi.mock("../model/agent-client", () => ({ + probeDaemon: vi.fn(), + connectAgent: vi.fn(), +})); let host: HTMLElement | null = null; let instance: unknown = null; @@ -99,6 +115,8 @@ function typeInto(input: HTMLInputElement, text: string): void { beforeEach(() => { resetChat(); clearCameraTarget(); + vi.mocked(probeDaemon).mockReset().mockResolvedValue({ up: false }); + vi.mocked(connectAgent).mockReset(); }); afterEach(() => { @@ -189,3 +207,133 @@ describe("MapChat", () => { expect(root.querySelector('[aria-label="Close chat"]')).toBeNull(); }); }); + +// Phase 3b — Tier 1: the daemon probe (mocked) reports up before mount, so +// the store is already in "tier1" by the time MapChat reads it; a mocked +// agent-client connection drives the streaming/relay behaviour explicitly. +describe("MapChat — tier1", () => { + function mockConnection() { + const conn = { send: vi.fn(), cancel: vi.fn(), close: vi.fn() }; + let handlers: AgentHandlers | undefined; + vi.mocked(connectAgent).mockImplementation((_port, h) => { + handlers = h; + return conn; + }); + return { + conn, + handlers: () => { + expect(handlers).toBeDefined(); + return handlers!; + }, + }; + } + + it("shows the live badge with the daemon's model once the probe succeeds", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const root = mountChat({ doc: fixtureDoc() }); + expect(root.textContent).toContain("live"); + expect(root.textContent).toContain("claude-mock"); + }); + + it("streams a live answer into the chat progressively", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + const root = mountChat({ doc: fixtureDoc() }); + const input = getInput(root); + typeInto(input, "Where does artifact recording live?"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + expect(input.value).toBe(""); + + handlers().onToken("Arti"); + flushSync(); + expect(root.textContent).toContain("Arti"); + + handlers().onToken("facts live in .forgeplan/"); + flushSync(); + expect(root.textContent).toContain("Artifacts live in .forgeplan/"); + + handlers().onDone(); + flushSync(); + expect(root.textContent).toContain("Artifacts live in .forgeplan/"); + }); + + it("disables Send while pending and re-enables once the answer completes", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + const root = mountChat({ doc: fixtureDoc() }); + const input = getInput(root); + typeInto(input, "Where does artifact recording live?"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + + typeInto(input, "another question"); + expect(getSendButton(root).disabled).toBe(true); + + handlers().onDone(); + flushSync(); + expect(getSendButton(root).disabled).toBe(false); + }); + + it("relays a show_on_map call to camera-bus during a tier1 answer", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + const root = mountChat({ doc: fixtureDoc() }); + typeInto(getInput(root), "Where does artifact recording live?"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + + const before = currentCameraRequest().seq; + handlers().onShowOnMap({ kind: "zone", id: "z.a" }); + expect(currentCameraRequest().seq).toBe(before + 1); + expect(currentCameraRequest().target).toEqual({ kind: "zone", id: "z.a" }); + }); + + it("falls back to the offline badge when the connection drops", async () => { + vi.mocked(probeDaemon).mockResolvedValue({ + up: true, + model: "claude-mock", + }); + await checkDaemon(7431); + const { handlers } = mockConnection(); + + const root = mountChat({ doc: fixtureDoc() }); + expect(root.textContent).toContain("claude-mock"); + + typeInto(getInput(root), "Where does artifact recording live?"); + getSendButton(root).dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + flushSync(); + + handlers().onClose(); + flushSync(); + expect(root.textContent).toContain("Offline"); + expect(root.textContent).toContain("Tier 0"); + }); +}); diff --git a/template/src/widgets/map-chat/ui/MapChat.svelte b/template/src/widgets/map-chat/ui/MapChat.svelte index 42489bd..9229c1f 100644 --- a/template/src/widgets/map-chat/ui/MapChat.svelte +++ b/template/src/widgets/map-chat/ui/MapChat.svelte @@ -1,16 +1,28 @@ + + + + {@render children?.()} + + + + + + + + diff --git a/template/src/shared/ui/scroll-area/index.ts b/template/src/shared/ui/scroll-area/index.ts new file mode 100644 index 0000000..10814c9 --- /dev/null +++ b/template/src/shared/ui/scroll-area/index.ts @@ -0,0 +1 @@ +export { default as ScrollArea } from "./ScrollArea.svelte"; diff --git a/template/src/widgets/map-chat/model/chat-store.svelte.ts b/template/src/widgets/map-chat/model/chat-store.svelte.ts index c05859f..41e1d1f 100644 --- a/template/src/widgets/map-chat/model/chat-store.svelte.ts +++ b/template/src/widgets/map-chat/model/chat-store.svelte.ts @@ -15,28 +15,125 @@ import { answerFromMap } from "./tier0"; import { showOnMap } from "@/widgets/composed-map/model/camera-bus.svelte"; import { probeDaemon, connectAgent } from "./agent-client"; import type { AgentConnection } from "./agent-client"; +import type { CameraTarget } from "@/widgets/composed-map/model/camera-bus.svelte"; export interface ChatMessage { role: "user" | "assistant"; text: string; + /** Set when this message's answer drove camera-bus — lets the view render + * a "→ moved to