From a0d8a1770061608044a82afee5715690ee5c3568 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 27 Aug 2026 19:36:00 +0200 Subject: [PATCH 1/2] Prove idempotent capture sweep on the live Flue chat path. Re-enter capture as a harness-side pipe so a named settled range can be applied twice without duplication or a model extraction call. Co-authored-by: Cursor --- apps/brunch-agent/README.md | 13 ++- apps/brunch-agent/package.json | 1 + apps/brunch-agent/src/agents/chat-agent.ts | 20 +++- apps/brunch-agent/src/capture-sweep.ts | 102 ++++++++++++++++++ apps/brunch-agent/src/db-path.ts | 22 ++++ apps/brunch-agent/test/db-path.test.ts | 32 +++++- .../test/petrinaut-chat-result.ts | 11 ++ .../test/petrinaut-chat.integration.ts | 56 +++++++++- apps/brunch-agent/test/petrinaut-chat.test.ts | 22 ++++ .../core/test/architecture/boundaries.test.ts | 6 +- yarn.lock | 3 +- 11 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 apps/brunch-agent/src/capture-sweep.ts diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md index 0b7a0d607d7..d31d1761cfd 100644 --- a/apps/brunch-agent/README.md +++ b/apps/brunch-agent/README.md @@ -11,11 +11,18 @@ yarn dev:brunch The first step builds the Petrinaut libraries the panel imports (`dist/` and design-system codegen). Then it starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut website at `http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch. The panel talks to one plain -Flue chat agent: streamed text and reasoning, one server `ping` tool, and the existing Petrinaut -`readPetrinautDoc` client tool. There is no elicitation, capture, or `brunch_ask` on this path. +Flue chat agent: streamed text and reasoning, one server `ping` tool, one stub +skill (`confirm-path`, activated via `activate_skill`), and the existing Petrinaut +`readPetrinautDoc` client tool. There is no elicitation loop, sweep tool, or +`brunch_ask` on this path. Capture is a harness-side pipe: an explicit settled +range of Flue history is applied into a JSON store beside the conversation +database, not by the interviewer. Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. `BRUNCH_DEV_DB_PATH` -overrides that local path. Flue history is the conversation log; the browser may cache messages +overrides that local path. Capture envelopes for one Flue conversation sit beside that sqlite +file, named by the hashed instance id (`.json`). The hermetic `/api/chat` test uses +`BRUNCH_CHAT_DB_PATH` and writes the capture file in that same directory. Flue history is the +conversation log; the capture store is not a second transcript. The browser may cache messages but reload hydrates from `GET /api/chat?id=`. The mounted Flue URL `/agents/chat/:id` requires the same principal and conversation identity (`x-brunch-principal` and `x-brunch-conversation`) as `/api/chat`; the path id is the hash of those, not a bearer token. diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index 1ded0f47b35..fb22a4e054a 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -20,6 +20,7 @@ "@flue/react": "2.0.3", "@flue/runtime": "2.0.3", "@flue/sdk": "2.0.3", + "@hashintel/brunch-agent-binding-flue": "workspace:*", "@hashintel/brunch-agent-transport-aisdk": "workspace:*", "@hashintel/petrinaut-core": "workspace:*", "@opentelemetry/api": "1.9.1", diff --git a/apps/brunch-agent/src/agents/chat-agent.ts b/apps/brunch-agent/src/agents/chat-agent.ts index 42ace805b07..01768fcbdad 100644 --- a/apps/brunch-agent/src/agents/chat-agent.ts +++ b/apps/brunch-agent/src/agents/chat-agent.ts @@ -2,11 +2,11 @@ /** * One plain Flue chat agent for the Petrinaut panel throughline. * - * No elicitation, capture, or plugin. The model can call a server-side ping - * and a browser-executed Petrinaut doc reader; Flue history is the session log. + * Capture is a harness-side pipe, not an interviewer tool. One stub skill is + * mounted so activation can appear in Flue history. */ -import { useModel, useTool } from "@flue/runtime"; +import { defineSkill, useModel, useSkill, useTool } from "@flue/runtime"; import { ping } from "../tools/ping.ts"; import { readPetrinautDoc } from "../tools/read-petrinaut-doc.ts"; @@ -14,13 +14,27 @@ import { readPetrinautDoc } from "../tools/read-petrinaut-doc.ts"; export const CHAT_MODEL_ID = process.env["BRUNCH_CHAT_MODEL"] || "claude-haiku-4-5"; +export const STUB_SKILL_NAME = "confirm-path"; + +export const ACTIVATE_SKILL_TOOL_NAME = "activate_skill"; + +const confirmPath = defineSkill({ + name: STUB_SKILL_NAME, + description: + "Confirm how this assistant is mounted. Use when checking the server path or tool layout.", + instructions: + "Say that ping confirms the server tool path. Then continue helping the user.", +}); + export function ChatAgent() { useModel(`anthropic/${CHAT_MODEL_ID}`); + useSkill(confirmPath); useTool(ping); useTool(readPetrinautDoc); return [ "You are a concise assistant inside the Petrinaut editor.", "Call ping when you need to confirm the server tool path.", + `Activate the \`${STUB_SKILL_NAME}\` skill before calling ping.`, "When the user asks how Petrinaut's UI works, call readPetrinautDoc.", "A client-tool-result signal is JSON [{ toolCallId, toolName, output }]. Treat output as the browser's result for that call and continue helping the user.", ].join("\n"); diff --git a/apps/brunch-agent/src/capture-sweep.ts b/apps/brunch-agent/src/capture-sweep.ts new file mode 100644 index 00000000000..0b991c0c5b6 --- /dev/null +++ b/apps/brunch-agent/src/capture-sweep.ts @@ -0,0 +1,102 @@ +/** + * Harness-side apply-sweep over a named Flue history range. + * + * The interviewer does not call this. A test or harness fact names the range. + * Stub extraction: one envelope per user utterance, quote = that text, payload {}. + */ + +import { + createFlueHistoryReader, + createLocalCaptureStore, + projectFlueHistoryForSweep, +} from "@hashintel/brunch-agent-binding-flue"; + +import { + agentOwnershipHeaders, + flueConversationIdFrom, + type ConversationIdentity, +} from "./conversation-identity.ts"; +import { captureStorePath } from "./db-path.ts"; +import { CHAT_AGENT_ROUTE } from "./routes.ts"; + +export interface CaptureSweepCapture { + readonly id: string; + readonly excerpt: string; + readonly payload: unknown; +} + +export interface CaptureSweepResult { + readonly appliedCaptureIds: readonly string[]; + readonly skippedDedupKeys: readonly string[]; + readonly captures: readonly CaptureSweepCapture[]; +} + +const conversationUrl = (instanceId: string): string => + `http://brunch.local/agents/${CHAT_AGENT_ROUTE}/${instanceId}`; + +const ownedTransport = (identity: ConversationIdentity): typeof fetch => { + const ownership = agentOwnershipHeaders(identity); + return async (input, init) => { + const { default: app } = await import("./app.ts"); + const headers = new Headers(init?.headers); + for (const [key, value] of Object.entries(ownership)) { + headers.set(key, value); + } + return app.fetch( + input instanceof Request + ? new Request(input, { headers }) + : new Request(input, { ...init, headers }), + ); + }; +}; + +export const applyCaptureSweep = async ( + identity: ConversationIdentity, + userEntryIds: readonly string[], +): Promise => { + const instanceId = flueConversationIdFrom(identity); + const store = createLocalCaptureStore(captureStorePath(instanceId), { + ownerKey: identity.principalKey, + }); + const historyReader = createFlueHistoryReader({ + resolveConversationUrl: conversationUrl, + transport: ownedTransport(identity), + archive: store, + }); + const snapshot = await historyReader.read(instanceId); + const range = new Set(userEntryIds); + const proposals = projectFlueHistoryForSweep(snapshot) + .filter( + (entry) => + entry.kind === "user" && range.has(entry.id) && entry.text.length > 0, + ) + .map((entry) => ({ + evidence: [{ excerpt: entry.text }], + epistemicStatus: "explicit" as const, + confidence: "high", + content: { value: {} }, + })); + const applied = await store.execute( + { type: "apply-sweep", proposals }, + { sessionId: instanceId }, + ); + if (!applied.ok) { + throw new Error( + `apply-sweep refused: ${applied.refusal.code}: ${applied.refusal.message}`, + ); + } + if (!("appliedCaptureIds" in applied.value)) { + throw new Error("apply-sweep did not return a sweep value."); + } + return { + appliedCaptureIds: applied.value.appliedCaptureIds, + skippedDedupKeys: applied.value.skippedDedupKeys, + captures: applied.snapshot.captures.map((capture) => ({ + id: capture.id, + excerpt: + "evidence" in capture ? (capture.evidence[0]?.excerpt ?? "") : "", + payload: + "value" in capture.content ? capture.content.value : capture.content, + })), + }; +}; diff --git a/apps/brunch-agent/src/db-path.ts b/apps/brunch-agent/src/db-path.ts index faaec3fcec7..44639f85422 100644 --- a/apps/brunch-agent/src/db-path.ts +++ b/apps/brunch-agent/src/db-path.ts @@ -12,8 +12,12 @@ * Flue Node runtime and SQLite adapter. */ +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +const conversationDbFileFrom = (override: string): string => + override.endsWith(".db") ? override : join(override, "conversations.db"); + export function conversationDbPath(): string { // Truthiness, not nullish, on purpose: a set-but-empty override would pass // '' through to sqlite(), which opens an anonymous temporary database @@ -25,3 +29,21 @@ export function conversationDbPath(): string { new URL("../.data-wipe-me/conversations.db", import.meta.url), ); } + +/** + * Capture JSON lives beside the Flue sqlite file, named by Flue instance id. + * The hermetic chat test sets `BRUNCH_CHAT_DB_PATH` (not `BRUNCH_DEV_DB_PATH`), + * so that directory wins when present. + */ +export function captureStorePath(instanceId: string): string { + if (instanceId.length === 0) { + throw new TypeError( + "A Flue instance id is required for the capture store path.", + ); + } + const chatDb = process.env.BRUNCH_CHAT_DB_PATH; + const directory = dirname( + chatDb ? conversationDbFileFrom(chatDb) : conversationDbPath(), + ); + return join(directory, `${instanceId}.json`); +} diff --git a/apps/brunch-agent/test/db-path.test.ts b/apps/brunch-agent/test/db-path.test.ts index 40987a9647f..eed41e45e21 100644 --- a/apps/brunch-agent/test/db-path.test.ts +++ b/apps/brunch-agent/test/db-path.test.ts @@ -16,18 +16,21 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { conversationDbPath } from "../src/db-path"; +import { conversationDbPath, captureStorePath } from "../src/db-path"; const appDir = fileURLToPath(new URL("..", import.meta.url)); describe("the conversation store path", () => { const originalCwd = process.cwd(); const originalOverride = process.env.BRUNCH_DEV_DB_PATH; + const originalChatDb = process.env.BRUNCH_CHAT_DB_PATH; afterEach(() => { process.chdir(originalCwd); if (originalOverride === undefined) delete process.env.BRUNCH_DEV_DB_PATH; else process.env.BRUNCH_DEV_DB_PATH = originalOverride; + if (originalChatDb === undefined) delete process.env.BRUNCH_CHAT_DB_PATH; + else process.env.BRUNCH_CHAT_DB_PATH = originalChatDb; }); test("is anchored to the package, wherever the process was launched from", () => { @@ -55,3 +58,30 @@ describe("the conversation store path", () => { ); }); }); + +describe("the capture store path", () => { + const originalChatDb = process.env.BRUNCH_CHAT_DB_PATH; + const originalOverride = process.env.BRUNCH_DEV_DB_PATH; + + afterEach(() => { + if (originalChatDb === undefined) delete process.env.BRUNCH_CHAT_DB_PATH; + else process.env.BRUNCH_CHAT_DB_PATH = originalChatDb; + if (originalOverride === undefined) delete process.env.BRUNCH_DEV_DB_PATH; + else process.env.BRUNCH_DEV_DB_PATH = originalOverride; + }); + + test("sits beside the conversation database, named by Flue instance id", () => { + delete process.env.BRUNCH_CHAT_DB_PATH; + delete process.env.BRUNCH_DEV_DB_PATH; + expect(captureStorePath("flue-instance-1")).toBe( + join(appDir, ".data-wipe-me", "flue-instance-1.json"), + ); + }); + + test("follows the hermetic chat database directory", () => { + process.env.BRUNCH_CHAT_DB_PATH = join(tmpdir(), "conversations.db"); + expect(captureStorePath("flue-instance-1")).toBe( + join(tmpdir(), "flue-instance-1.json"), + ); + }); +}); diff --git a/apps/brunch-agent/test/petrinaut-chat-result.ts b/apps/brunch-agent/test/petrinaut-chat-result.ts index 9e4fe7db9bf..4e2a1ff9358 100644 --- a/apps/brunch-agent/test/petrinaut-chat-result.ts +++ b/apps/brunch-agent/test/petrinaut-chat-result.ts @@ -28,6 +28,17 @@ export interface PetrinautChatResult { readonly transcript: string; readonly instanceId: string; readonly dbPath: string; + readonly activateSkillCall: Extract< + UIMessageChunk, + { type: "tool-input-available" } + > | null; + readonly interviewerToolNames: readonly string[]; + readonly captureUserText: string; + readonly captureIds: readonly string[]; + readonly recaptureIds: readonly string[]; + readonly skippedDedupKeys: readonly string[]; + readonly capturePayloads: readonly unknown[]; + readonly captureExcerpts: readonly string[]; } export interface PetrinautResumeResult { diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 16d213b5118..8a7ec1bfebf 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -13,7 +13,13 @@ import { import { sqlite, start } from "@flue/runtime/node"; import { createFlueClient, FlueApiError } from "@flue/sdk"; -import { CHAT_MODEL_ID, ChatAgent } from "../src/agents/chat-agent.ts"; +import { + ACTIVATE_SKILL_TOOL_NAME, + CHAT_MODEL_ID, + ChatAgent, + STUB_SKILL_NAME, +} from "../src/agents/chat-agent.ts"; +import { applyCaptureSweep } from "../src/capture-sweep.ts"; import { agentOwnershipHeaders, flueConversationIdFrom, @@ -108,6 +114,17 @@ try { process.stdout.write(`PETRINAUT_RESUME_RESULT ${JSON.stringify(result)}\n`); } else { faux.setResponses([ + fauxAssistantMessage( + [ + fauxThinking("Load the mount confirmation skill."), + fauxToolCall( + ACTIVATE_SKILL_TOOL_NAME, + { name: STUB_SKILL_NAME }, + { id: "tool-skill-1" }, + ), + ], + { stopReason: "toolUse" }, + ), fauxAssistantMessage( [ fauxThinking("Confirm the server path, then read the guide."), @@ -177,6 +194,14 @@ try { chunk.type === "tool-input-available" && chunk.toolName === PING_TOOL_NAME, ) ?? null; + const activateSkillCall = + initialChunks.find( + ( + chunk, + ): chunk is Extract => + chunk.type === "tool-input-available" && + chunk.toolName === ACTIVATE_SKILL_TOOL_NAME, + ) ?? null; const pingOutputChunk = initialChunks.find( (chunk) => chunk.type === "tool-output-available" && @@ -225,6 +250,22 @@ try { ); const resumedChunks = chunksFrom(await resumeResponse.text()); const snapshot = await historyClient.history(); + const userEntryIds = snapshot.messages + .filter( + (message) => message.role === "user" && message.purpose === "user", + ) + .map((message) => message.id); + const firstSweep = await applyCaptureSweep(identity, userEntryIds); + const secondSweep = await applyCaptureSweep(identity, userEntryIds); + const interviewerToolNames = [ + ...new Set( + snapshot.messages.flatMap((message) => + message.parts + .filter((part) => part.type === "dynamic-tool") + .map((part) => part.toolName), + ), + ), + ]; let unauthenticatedHistoryStatus = 0; try { await createFlueClient({ @@ -320,6 +361,19 @@ try { transcript: formatFlueTranscript(snapshot), instanceId, dbPath: dbFile, + activateSkillCall, + interviewerToolNames, + captureUserText: userTextFromHistory( + snapshot.messages.map((message) => ({ + role: message.role, + parts: message.parts, + })), + ), + captureIds: firstSweep.captures.map((capture) => capture.id), + recaptureIds: secondSweep.captures.map((capture) => capture.id), + skippedDedupKeys: secondSweep.skippedDedupKeys, + capturePayloads: firstSweep.captures.map((capture) => capture.payload), + captureExcerpts: firstSweep.captures.map((capture) => capture.excerpt), }; process.stdout.write(`PETRINAUT_CHAT_RESULT ${JSON.stringify(result)}\n`); } diff --git a/apps/brunch-agent/test/petrinaut-chat.test.ts b/apps/brunch-agent/test/petrinaut-chat.test.ts index c7ab2979b92..2f0ff4a76a8 100644 --- a/apps/brunch-agent/test/petrinaut-chat.test.ts +++ b/apps/brunch-agent/test/petrinaut-chat.test.ts @@ -93,9 +93,30 @@ test("the committed /api/chat door streams a plain Flue agent through server and expect(result.transcript).toContain("Checking the server, then the docs."); expect(result.transcript).toContain("tool ping"); expect(result.transcript).toContain("tool readPetrinautDoc"); + expect(result.transcript).toContain("tool activate_skill"); expect(result.transcript).toContain( "The assistant can read its own documentation pages.", ); + expect(result.activateSkillCall).toMatchObject({ + type: "tool-input-available", + toolName: "activate_skill", + input: { name: "confirm-path" }, + }); + expect(result.interviewerToolNames).toContain("activate_skill"); + expect(result.interviewerToolNames).toContain("ping"); + expect(result.interviewerToolNames).toContain("readPetrinautDoc"); + expect(result.interviewerToolNames).not.toContain("sweep"); + expect(result.interviewerToolNames).not.toContain("brunch_sweep"); + expect(result.captureIds.length).toBe(1); + expect(result.captureExcerpts).toEqual([ + "Run the FE-1435 transport probe.", + ]); + expect(result.capturePayloads).toEqual([{}]); + expect(result.recaptureIds).toEqual(result.captureIds); + expect(result.skippedDedupKeys.length).toBeGreaterThan(0); + expect(result.captureUserText).toContain( + "Run the FE-1435 transport probe.", + ); expect(inspectionLines[0]).toMatchObject({ type: "request-start", @@ -144,6 +165,7 @@ test("the committed /api/chat door streams a plain Flue agent through server and ); expect(resumeResult.transcript).toContain("tool ping"); expect(resumeResult.transcript).toContain("tool readPetrinautDoc"); + expect(resumeResult.transcript).toContain("tool activate_skill"); } finally { await rm(dbDirectory, { recursive: true, force: true }); } diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts index c7186d8269e..749fbaccecd 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts @@ -486,8 +486,12 @@ describe("the HASH smoke is runnable without a model key or a network (spec §12 * path enters here by review only. */ const SUBSTRATE_INTEGRATION_ENTRY_POINTS: Readonly> = { + "apps/brunch-agent/test/flue-transcript.test.ts": + "Types Flue's public conversation snapshot so the transcript projector can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", + "apps/brunch-agent/test/flue-ui-stream.test.ts": + "Types Flue conversation-stream chunks so the AI SDK projector can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", "apps/brunch-agent/test/petrinaut-chat.integration.ts": - "Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the committed /api/chat door over app.fetch, and proves streamed reasoning/text, one server tool, one read-only client-tool resume, GET history ownership, and SQLite restart — no provider key, no socket, no model call. Run as a child process by petrinaut-chat.test.ts.", + "Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the committed /api/chat door over app.fetch, and proves streamed reasoning/text, one server tool, one stub skill activation, one read-only client-tool resume, GET history ownership, SQLite restart, and harness-side idempotent apply-sweep into a capture store keyed by Flue conversation identity — no provider key, no socket, no extraction model call. Run as a child process by petrinaut-chat.test.ts.", "apps/brunch-agent/test/turn-timing.test.ts": "Types recorded Flue observations and model requests so the condition-5 purpose splitter can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", }; diff --git a/yarn.lock b/yarn.lock index 812bd99ecc5..7ff22b0f3ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -440,6 +440,7 @@ __metadata: "@flue/runtime": "npm:2.0.3" "@flue/sdk": "npm:2.0.3" "@flue/vite": "npm:2.0.3" + "@hashintel/brunch-agent-binding-flue": "workspace:*" "@hashintel/brunch-agent-transport-aisdk": "workspace:*" "@hashintel/petrinaut-core": "workspace:*" "@opentelemetry/api": "npm:1.9.1" @@ -7545,7 +7546,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-binding-flue@workspace:libs/@hashintel/brunch-agent/packages/binding-flue": +"@hashintel/brunch-agent-binding-flue@workspace:*, @hashintel/brunch-agent-binding-flue@workspace:libs/@hashintel/brunch-agent/packages/binding-flue": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-binding-flue@workspace:libs/@hashintel/brunch-agent/packages/binding-flue" dependencies: From 7616004bfeee876d1530ff3d9d7c5495f426e14c Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 27 Aug 2026 19:36:11 +0200 Subject: [PATCH 2/2] Archive Mission 2 and cut the runbook-template headless drive. The capture pipe is proven; the next live mission is the independent prompting experiment, not a join onto the ledger. Co-authored-by: Cursor --- libs/@hashintel/brunch-agent/MISSION.md | 134 ++++++++-------- libs/@hashintel/brunch-agent/MISSION.next.md | 26 +--- .../2-mechanical-capture-sweep.md | 145 ++++++++++++++++++ .../docs/mission-archive/README.md | 2 + 4 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 libs/@hashintel/brunch-agent/docs/mission-archive/2-mechanical-capture-sweep.md diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index 22a9ccfe5fe..1bd9fb64ba8 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -1,101 +1,111 @@ -# Mission 2 — mechanical capture sweep +# Mission 3 — runbook, template, headless PN ## Status Live. This file is execution authority. Later concerns are clustered in [`MISSION.next.md`](MISSION.next.md). That file is a scratchpad, -not a mission; do not implement it. Host-trunk work, the runbook/IR path, and any join between -capture and runbooks are not this mission. +not a mission; do not implement it. Host-trunk work, Petrinaut read/write tools, typed IR maps, +observer-triggered sweeps, and any join to Mission 2's capture store are not this mission. ## Imperative -Re-enter durable, source-linked capture as a pipe, not as elicitation intelligence. Condition 5 -showed the old kernel becoming untenable: typed mapping and in-loop LLM judgment, question turns -on the order of minutes. This mission proves that an explicit settled range can be applied into -the capture store and re-applied without duplication, without asking a model to extract, type, or -schedule. It does not improve extraction quality, and it does not feed an IR. +Prove that a comprehensive runbook and IR template can teach a model through the live Flue +chat door, that the filled template can be driven headless (no GUI), and that the filled +document contains enough to generate a Petri net Petrinaut will accept. Condition 5's runner +is broken on this app (deleted elicitor imports); restore the JS-API drive pattern, not a TUI +and not the old SDCPN elicitor. This is a prompting experiment. It does not improve capture +quality, and it does not fold Mission 2's ledger. ## Throughline -One harness-side pass over a real Flue conversation already on the Mission 1 door: +One headless pass on the Mission 1 door, with a runbook and IR template mounted on the +production `ChatAgent`: -`settled Flue history range → harness apply-sweep → capture store keyed by Flue conversation identity → same range applied again → same capture identities` +`createFlueClient → send → wait → history() → filled IR template → structured (not strictly typed) IR → PN JSON → petrinaut-core parse/validate` -On the same production agent, mount one stub Flue skill (`useSkill`) so `activate_skill` can -appear in that conversation's history. The skill is not a runbook and not the IR template. - -The interviewer does not call a sweep tool and does not decide when to sweep. A test or harness -fact names the range. Stub extraction: one envelope per user utterance, quote = that text, -payload `{}`. +Generate the net without canvas mutation tools. Manual load into the app is enough to score +whether the template contained enough to draw. Template fill is not a sweep: sweep means +capture-store apply. Do not join this path to Mission 2's store. ## Proof -This proof establishes that the capture pipe works on the live chat path. It does not establish -extraction quality, a typed IR, a runbook, session-as-net, or two brains. - -From the real brunch-agent entrypoint (same `ChatAgent` / `/api/chat` door as Mission 1), one -production-path test observes all of the following: +This proof establishes that a headless teaching loop can fill a template and yield a +validatable Petri net. It does not establish a typed map, canvas write tools, capture +improvement, session-as-net, or two brains. -1. After an explicit settled range, apply-sweep writes capture envelopes with evidence spans and - empty payload, one per user utterance in that range. -2. Applying the same range again yields the same capture identities and does not duplicate. -3. The stub skill is mounted; `activate_skill` appears in Flue history for that conversation. -4. The interviewer never called a sweep tool; producing the captures did not require a model - call. +From the real brunch-agent entrypoint (same `ChatAgent` / `/api/chat` door as Missions 1–2), +one production-path test or documented JS-API script observes all of the following: -Prefer that one throughline test over a broad suite. Unit tests may pin envelope/idempotency -invariants that failed or proved easy to regress. +1. A headless client drives the live agent with `createFlueClient` → `send` → `wait` → + `history()` (the Flue routing-table loop, not a PTY/TUI). +2. A comprehensive runbook and IR template are mounted on that agent (system prompt, skill + body, supporting file — placement is fog; bundling in the skill is allowed). +3. The conversation fills the template; the filled document is recoverable from that + conversation's outputs without opening the Petrinaut GUI. +4. Inference from that filled document produces PN JSON that `parseSDCPNFile` (or the current + petrinaut-core import equivalent) accepts. Missing canvas positions are allowed if the + parser already treats them as recoverable. +5. The interviewer never called a sweep tool; the capture store was not written as part of + producing the net. -This proof does not require a human panel run unless the throughline cannot be observed from the -service entrypoint the demo uses. +Prefer that one throughline over a broad suite. A human panel run is not required; manual +load of the JSON into the app is enough to inspect the drawing. ## Constraints -- Mission 1's chat door stays the door: Petrinaut panel → `transport-aisdk` → Flue `ChatAgent`. - Do not rewrite the panel onto `@flue/react`. The adapter still must not depend on core, - binding, or plugins. -- The app may depend on core (and binding, if that is the smallest way to `apply-sweep`) for - this pipe only. Do not re-enter plugin-gherkin, plugin-sdcpn, repertoire, kinds, slots, fold, - completion, issues, or correction. -- No extraction LLM. No sweep tool on the interviewer. No token-threshold observer. -- No join to a runbook or IR template. Capture is a provenance ledger, not the workpiece. -- Flue history remains the conversation log. The capture store is not a second transcript. -- Key the store by the Flue conversation identity already in play (principal + conversation id). - If a later Host proof shows net ids unstable, rekey; do not invent a target-document. -- One stub Flue skill, short enough that activation is the proof. Do not dump research into it. +- Mission 1's chat door stays the door: Petrinaut panel → `transport-aisdk` → Flue + `ChatAgent`. Do not rewrite the panel onto `@flue/react`. The adapter still must not depend + on core, binding, or plugins. +- Restore the drive pattern from condition 5's runner (`createFlueClient` over the app + router). Do not revive that runner's SDCPN elicitor, `brunch_ask`, sweep, fold, or + completion accounting as the teaching vehicle. +- Do not re-enter plugin-gherkin, plugin-sdcpn, repertoire, kinds, slots, fold, completion, + issues, or correction in order to author the template. +- Template fill is not a sweep. Do not call `applyCaptureSweep` or otherwise join Mission 2's + ledger unless a later cut says so. The template is a teaching artifact, not ADR-0003 + register-2 derived from captures. +- No Petrinaut canvas mutation tools. No typed FE map. Generation may be structured without + being strictly typed. +- The app may import `@hashintel/petrinaut-core` to parse/validate PN JSON. It must not import + `@hashintel/petrinaut` UI. - Update runbook/docs only where exercised behavior changes. ## Fog-line Do not design past these questions before running the simplest path that can answer them: -- Whether `apply-sweep` is in-process in the app, via binding-flue, or a thinner local call into - core; pick the smallest path that uses the real store. -- Whether a trivial proposal schema is required for an empty payload, or the harness can persist - envelopes without a plugin catalog. -- Where the capture files live relative to the Flue SQLite conversation (path, not ontology). -- How the stub skill is declared (`SKILL.md` import vs `defineSkill`) so `activate_skill` shows - in `history()`. +- Where the runbook and IR template live (system prompt, skill body, supporting file, or a + bundle of those) so the model actually uses them under `send`/`wait`. +- What "structured but not strictly typed IR" looks like at the real boundary — a JSON + document the script consumes, a skill output, or a last-turn artifact — without inventing a + three-register revival. +- How much of condition 5's runner to restore versus a thinner drive script on the current + `ChatAgent`. +- Whether `parseSDCPNFile` is enough "petrinaut validate," or the throughline exposes a + smaller/larger import check. Resolve each at the real boundary, record the observed answer in code/tests, and then -re-evaluate. Do not turn them into a capture framework or a plugin SDK revival. +re-evaluate. Do not turn them into a plugin SDK revival or a capture↔IR join. ## Stop or reorient Stop and surface the evidence before continuing if: -- producing captures requires a model call; -- a sweep tool appears on the interviewer, or the skill teaches the model to schedule sweeps; -- kinds, slots, fold, repertoire, or the SDCPN/Gherkin plugins re-enter this door; -- the runbook or IR template is wired to the store; -- an ordinary turn on this path returns to condition-5 latency (order-of-minutes); -- idempotent reapplication cannot be shown without a second event model beside Flue history; -- the adapter grows a dependency on core, binding, or plugins. +- producing the net requires writing Mission 2's capture store, or template fill is + implemented as apply-sweep; +- plugin-sdcpn, repertoire, fold, completion, or `brunch_ask` re-enter as the teaching + vehicle; +- canvas mutation tools appear on the interviewer; +- the drive becomes a TUI or a second server rather than `createFlueClient` against the live + door; +- the adapter grows a dependency on core, binding, or plugins; +- ordinary turns on this path return to condition-5 latency (order-of-minutes) as the + designed shape of a teaching turn. ## Deferred -Host trunk (two brains, net lifecycle as session, compaction), the runbook/template/headless and -off-canvas PN path, Petrinaut read/write tools, typed IR maps, observer-triggered sweeps, and -whether capture and runbooks converge, are clustered in [`MISSION.next.md`](MISSION.next.md). -That scratchpad does not supersede this section. +Host trunk, typed map and Petrinaut read/write via existing `onToolCall`, capture improvement +(token-threshold observer, typed payloads), and whether capture and runbooks converge, are +clustered in [`MISSION.next.md`](MISSION.next.md). That scratchpad does not supersede this +section. diff --git a/libs/@hashintel/brunch-agent/MISSION.next.md b/libs/@hashintel/brunch-agent/MISSION.next.md index be9b1e855a5..cba4464c5b4 100644 --- a/libs/@hashintel/brunch-agent/MISSION.next.md +++ b/libs/@hashintel/brunch-agent/MISSION.next.md @@ -4,9 +4,9 @@ Scratchpad, not a mission. [`MISSION.md`](MISSION.md) remains execution authorit implement from this file. Clusters are ordered; they are not a second concurrent mission. When a focus is cut into a new `MISSION.md`, leave everything that did not make the cut here. -Capture (live Mission 2) and the runbook/IR path below are **independent**. Whether they -converge, and if so where, when, and in what form, is an open later question. Do not wire them -in order to tidy the list. +Capture (archived Mission 2) and the live runbook/IR path (Mission 3) are **independent**. +Whether they converge, and if so where, when, and in what form, is an open later question. Do +not wire them in order to tidy the list. ## Host trunk @@ -31,23 +31,9 @@ stolen vs configured `/api/chat` on that stack. Brunch owns no provider audio. ## Elicitation ladder -After Mission 2's pipe. Order is the reintegration sequence: prompting experiment, then typed -map plus canvas I/O, then capture improvement. Watch for the strain threshold (condition 5: -typed mapping, in-loop LLM judgment, ~2 min question turns). - -### Runbook, template, headless drive, off-canvas PN - -Comprehensive runbook + IR template (system prompt, skill body, supporting file — placement is -fog; bundling in the skill is allowed). Iterate teaching without the GUI: restore the JS-API -loop (`createFlueClient` → `send` → `wait` → `history()`). Condition 5's runner is broken on -this app (deleted elicitor imports); restore the drive pattern, not a TUI. - -Generate a Petri net from the filled template **without** canvas mutation tools: structured but -not strictly typed IR; inference to PN JSON; petrinaut validate; manual load into the app is -enough. That scores whether the template contains enough to draw a net. - -Template fill is not a sweep. Sweep means capture-store apply. Do not join this path to Mission -2's ledger unless a later cut says so. +Mission 3 holds the prompting experiment (runbook, template, headless drive, off-canvas PN). +Remaining order after that: typed map plus canvas I/O, then capture improvement. Watch for the +strain threshold (condition 5: typed mapping, in-loop LLM judgment, ~2 min question turns). ### Typed map and Petrinaut read/write diff --git a/libs/@hashintel/brunch-agent/docs/mission-archive/2-mechanical-capture-sweep.md b/libs/@hashintel/brunch-agent/docs/mission-archive/2-mechanical-capture-sweep.md new file mode 100644 index 00000000000..b7c00b312b5 --- /dev/null +++ b/libs/@hashintel/brunch-agent/docs/mission-archive/2-mechanical-capture-sweep.md @@ -0,0 +1,145 @@ +# Mission 2 — mechanical capture sweep + +## Status + +Accepted 2026-08-27. Evidence of what was proven, not execution authority. Re-earn +before building on it, same as an ADR. + +The live mission is [`MISSION.md`](../../MISSION.md). Successor clusters: +[`MISSION.next.md`](../../MISSION.next.md). + +## Imperative + +Re-enter durable, source-linked capture as a pipe, not as elicitation intelligence. Condition 5 +showed the old kernel becoming untenable: typed mapping and in-loop LLM judgment, question turns +on the order of minutes. This mission proves that an explicit settled range can be applied into +the capture store and re-applied without duplication, without asking a model to extract, type, or +schedule. It does not improve extraction quality, and it does not feed an IR. + +## Throughline + +One harness-side pass over a real Flue conversation already on the Mission 1 door: + +`settled Flue history range → harness apply-sweep → capture store keyed by Flue conversation identity → same range applied again → same capture identities` + +On the same production agent, mount one stub Flue skill (`useSkill`) so `activate_skill` can +appear in that conversation's history. The skill is not a runbook and not the IR template. + +The interviewer does not call a sweep tool and does not decide when to sweep. A test or harness +fact names the range. Stub extraction: one envelope per user utterance, quote = that text, +payload `{}`. + +## Proof + +This proof establishes that the capture pipe works on the live chat path. It does not establish +extraction quality, a typed IR, a runbook, session-as-net, or two brains. + +From the real brunch-agent entrypoint (same `ChatAgent` / `/api/chat` door as Mission 1), one +production-path test observes all of the following: + +1. After an explicit settled range, apply-sweep writes capture envelopes with evidence spans and + empty payload, one per user utterance in that range. +2. Applying the same range again yields the same capture identities and does not duplicate. +3. The stub skill is mounted; `activate_skill` appears in Flue history for that conversation. +4. The interviewer never called a sweep tool; producing the captures did not require a model + call. + +Prefer that one throughline test over a broad suite. Unit tests may pin envelope/idempotency +invariants that failed or proved easy to regress. + +This proof does not require a human panel run unless the throughline cannot be observed from the +service entrypoint the demo uses. + +## Constraints + +- Mission 1's chat door stays the door: Petrinaut panel → `transport-aisdk` → Flue `ChatAgent`. + Do not rewrite the panel onto `@flue/react`. The adapter still must not depend on core, + binding, or plugins. +- The app may depend on core (and binding, if that is the smallest way to `apply-sweep`) for + this pipe only. Do not re-enter plugin-gherkin, plugin-sdcpn, repertoire, kinds, slots, fold, + completion, issues, or correction. +- No extraction LLM. No sweep tool on the interviewer. No token-threshold observer. +- No join to a runbook or IR template. Capture is a provenance ledger, not the workpiece. +- Flue history remains the conversation log. The capture store is not a second transcript. +- Key the store by the Flue conversation identity already in play (principal + conversation id). + If a later Host proof shows net ids unstable, rekey; do not invent a target-document. +- One stub Flue skill, short enough that activation is the proof. Do not dump research into it. +- Update runbook/docs only where exercised behavior changes. + +## Fog-line + +Do not design past these questions before running the simplest path that can answer them: + +- Whether `apply-sweep` is in-process in the app, via binding-flue, or a thinner local call into + core; pick the smallest path that uses the real store. +- Whether a trivial proposal schema is required for an empty payload, or the harness can persist + envelopes without a plugin catalog. +- Where the capture files live relative to the Flue SQLite conversation (path, not ontology). +- How the stub skill is declared (`SKILL.md` import vs `defineSkill`) so `activate_skill` shows + in `history()`. + +Resolve each at the real boundary, record the observed answer in code/tests, and then +re-evaluate. Do not turn them into a capture framework or a plugin SDK revival. + +## Stop or reorient + +Stop and surface the evidence before continuing if: + +- producing captures requires a model call; +- a sweep tool appears on the interviewer, or the skill teaches the model to schedule sweeps; +- kinds, slots, fold, repertoire, or the SDCPN/Gherkin plugins re-enter this door; +- the runbook or IR template is wired to the store; +- an ordinary turn on this path returns to condition-5 latency (order-of-minutes); +- idempotent reapplication cannot be shown without a second event model beside Flue history; +- the adapter grows a dependency on core, binding, or plugins. + +## Deferred + +Host trunk (two brains, net lifecycle as session, compaction), the runbook/template/headless and +off-canvas PN path, Petrinaut read/write tools, typed IR maps, observer-triggered sweeps, and +whether capture and runbooks converge, are clustered in [`MISSION.next.md`](../../MISSION.next.md). +That scratchpad does not supersede this section. + +## Close + +Witnessed 2026-08-27 on `ln/fe-1524-mission-2` (Linear FE-1524). Production-path test: +`apps/brunch-agent/test/petrinaut-chat.test.ts` driving +`apps/brunch-agent/test/petrinaut-chat.integration.ts`. The GitHub PR from this branch is the +review record. + +### Proof + +1. After one user utterance, harness `applyCaptureSweep` wrote one envelope whose excerpt is that + text and whose payload is `{}`. +2. Applying the same named user-entry ids again returned the same capture ids and a non-empty + `skippedDedupKeys`; no second row was minted. +3. `defineSkill` / `useSkill` mounted `confirm-path`; faux-scripted `activate_skill` with + `{ name: "confirm-path" }` appears in Flue `history()` and in the AI SDK stream. +4. Interviewer tools were `activate_skill`, `ping`, and `readPetrinautDoc`. No `sweep` / + `brunch_sweep`. Stub proposals are built from history text in-process; producing captures did + not require an extraction model call. + +### Fog-line answers + +- **apply-sweep home.** In-process in the app via binding-flue: + `createLocalCaptureStore` + `createFlueHistoryReader` + `store.execute({ type: "apply-sweep" })`. + Not `useElicitation`, not HTTP, not `useAgentFinish`. Evidence-bearing apply requires the + history reader to archive quotes before execute. Session id for that archive is the Flue + instance id. +- **Empty payload.** `{}` persisted with the existing `CaptureInputProposalSchema`. No plugin + catalog and no trivial proposal schema were required. +- **Capture path.** Sibling of the Flue sqlite file, named `.json`. The hermetic + test writes beside `BRUNCH_CHAT_DB_PATH`. Internal JSON is still binding's + `TargetDocumentRecord`; the app API does not expose a target-document ontology. +- **Skill declaration.** `defineSkill` + `useSkill` is enough for `activate_skill` to appear in + `history()` under `node --experimental-strip-types`. `SKILL.md` / Vite skill import was not + needed. + +### Carried flags + +- Capture remains a provenance ledger with empty payloads. Typed payloads, token-threshold + observers, and any join to a runbook or IR are not proven and stay on the scratchpad. +- Store key is Flue conversation identity (principal + conversation id). Net id as discriminator + is still the unproven Host-trunk assumption from Mission 1. +- The interviewer still must not own sweep scheduling. Mission 3's runbook/template path must + not wire itself to this store in order to tidy the list. diff --git a/libs/@hashintel/brunch-agent/docs/mission-archive/README.md b/libs/@hashintel/brunch-agent/docs/mission-archive/README.md index c21ee38831c..58830f56096 100644 --- a/libs/@hashintel/brunch-agent/docs/mission-archive/README.md +++ b/libs/@hashintel/brunch-agent/docs/mission-archive/README.md @@ -5,3 +5,5 @@ authority. The scratchpad and cut rule live in the context-root [`AGENTS.md`](.. - [`1-bare-petrinaut-flue-chat.md`](1-bare-petrinaut-flue-chat.md) — Mission 1, accepted 2026-08-27. +- [`2-mechanical-capture-sweep.md`](2-mechanical-capture-sweep.md) — Mission 2, accepted + 2026-08-27.