- commit
- 5cdeb5d
- parent
- 85a27b6
- author
- codex@macbookpro
- date
- 2026-03-27 18:40:31 +0800 CST
feat: add live instruction ingest and artifact center core
30 files changed,
+2754,
-40
+49,
-14
1@@ -2,12 +2,12 @@
2
3 ## 结论摘要
4
5-- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 的功能落地。
6-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
7+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 的功能落地。
8+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + live instruction ingest + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
9 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
10 - 目前不应再把系统描述成“只有 Claude / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
11-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 `browser.final_message` 尚未持久化也未直接接入 instruction parser。
12-- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030` 已完成;当前主要剩余 `T-S026`。
13+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 live message dedupe / instruction dedupe 仍未持久化。
14+- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成;当前主要剩余 `T-S026`。
15
16 ## 本次核对依据
17
18@@ -26,7 +26,7 @@
19 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
20 - 结果:通过
21 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
22- - 结果:`39/39` 通过
23+ - 结果:`44/44` 通过
24 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
25 - 结果:`6/6` 通过
26
27@@ -147,6 +147,40 @@
28 - `../apps/conductor-daemon/src/index.ts`
29 - `../apps/conductor-daemon/src/index.test.js`
30
31+### 14. live `browser.final_message` 已接入 instruction center
32+
33+- `conductor-daemon` 当前会在收到 `browser.final_message` 后直接进入 live instruction ingest。
34+- 当前读面已暴露:
35+ - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
36+ - `GET /v1/browser -> data.instruction_ingest`
37+- 自动化已覆盖:
38+ - 普通消息忽略
39+ - replay 去重
40+ - 缺少 `conversation_id` 可容忍
41+ - live final message 触发执行并暴露最近摘要
42+- 证据:
43+ - `../apps/conductor-daemon/src/instructions/ingest.ts`
44+ - `../apps/conductor-daemon/src/firefox-ws.ts`
45+ - `../apps/conductor-daemon/src/local-api.ts`
46+ - `../apps/conductor-daemon/src/index.test.js`
47+
48+### 15. service-side artifact center core 已落地
49+
50+- `apps/conductor-daemon/src/artifacts/` 当前已新增:
51+ - `types.ts`
52+ - `materialize.ts`
53+ - `manifest.ts`
54+ - `delivery-plan.ts`
55+- synthetic execution result 已覆盖:
56+ - `exec` -> artifact + manifest + delivery plan
57+ - `files/read` small / large 的不同 delivery 策略
58+ - 多结果稳定排序
59+ - 缺少可选字段不崩整批 materialize
60+- 证据:
61+ - `../apps/conductor-daemon/src/artifacts/`
62+ - `../apps/conductor-daemon/src/artifacts.test.js`
63+ - `../apps/conductor-daemon/src/index.test.js`
64+
65 ### 4. 浏览器风控策略已在 `conductor-daemon` 内实现
66
67 - 默认策略已实装,不只是文档约定:
68@@ -275,11 +309,10 @@
69 - 但它的最终文本提取基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT。
70 - 当前保留 synthetic `assistant_message_id` 兜底,因此这层应表述为“已接通 raw relay,但平台提取稳定性仍有边界”,不是“Gemini 整个平台能力都已转正”。
71
72-### 4. `browser.final_message` 当前只做最小兼容接收
73+### 4. live message dedupe 和 instruction dedupe 仍是内存态
74
75-- `conductor-daemon` 当前已经 live 接收 `browser.final_message`,并保留最近快照。
76-- 但这轮还没有把它落到持久化表,也没有直接接到 instruction parser / execution loop。
77-- 因此当前更准确的口径是“消息中继和快照已落地,自动执行接线仍留在下一阶段”。
78+- `browser.final_message` 当前已经 live 接入 instruction center,但 live message dedupe 和 instruction dedupe 都还是进程内内存态。
79+- 因此进程重启后,这两类 dedupe 状态都不会保留。
80
81 ### 5. 风控状态仍是进程内内存态
82
83@@ -304,11 +337,13 @@
84 - 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
85 - 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
86
87-### 9. instruction dedupe 仍是内存态,且只覆盖本机精确 target
88+### 9. 当前摘要只保留最近一次 live ingest / execute,且仍只覆盖 Phase 1 exact target
89
90-- Phase 1 instruction center 当前已能稳定生成 dedupe key,并避免同一条 assistant message replay 重复执行。
91-- 但 dedupe 目前仍是进程内内存态,进程重启后不会保留。
92-- 当前路由也只做本机精确 target,跨节点和多轮闭环还没接。
93+- 当前 live ingest 读面只保留最近一次 ingest / execute 摘要,不落库。
94+- 当前仍只允许 Phase 1 精确 target:
95+ - `conductor`
96+ - `system`
97+- 虽然 service-side artifact center core 已落地,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send。
98
99 ## 已拆出的后续任务
100
101@@ -331,4 +366,4 @@
102
103 如果只写一段给外部协作者看,可以用下面这版:
104
105-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay 和 conductor 侧 BAA instruction center Phase 1。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(39/39)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、`browser.final_message` 尚未持久化也未直接接入 instruction parser,以及风控和 instruction dedupe 运行态仍是进程内内存态。
106+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest,以及 service-side artifact center core。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照、`instruction_ingest` 最近摘要,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(44/44)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、live message dedupe / instruction dedupe 仍是内存态、最近摘要不落库,以及 live 路径还没有真正接到 artifact / upload / inject / send。
+327,
-0
1@@ -0,0 +1,327 @@
2+import assert from "node:assert/strict";
3+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import test from "node:test";
7+
8+import {
9+ buildBaaArtifactManifest,
10+ buildBaaDeliveryPlan,
11+ materializeBaaExecutionArtifacts,
12+ renderBaaArtifactIndexText
13+} from "../dist/index.js";
14+
15+function createExecutionResult(overrides = {}) {
16+ return {
17+ data: null,
18+ dedupeKey: "dedupe-default",
19+ details: null,
20+ error: null,
21+ httpStatus: 200,
22+ instructionId: "inst_default",
23+ message: null,
24+ ok: true,
25+ requestId: "req-default",
26+ route: {
27+ key: "local.default",
28+ method: "POST",
29+ path: "/v1/default"
30+ },
31+ target: "conductor",
32+ tool: "status",
33+ ...overrides
34+ };
35+}
36+
37+test("artifact materializer turns exec output into artifact, manifest, and delivery plan uploads", async () => {
38+ const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-exec-"));
39+
40+ try {
41+ const materialization = await materializeBaaExecutionArtifacts(
42+ [
43+ {
44+ blockIndex: 2,
45+ execution: createExecutionResult({
46+ data: {
47+ ok: true,
48+ operation: "exec",
49+ input: {
50+ command: "printf 'hello artifact'",
51+ cwd: "/tmp",
52+ maxBufferBytes: 1024,
53+ timeoutMs: 2000
54+ },
55+ result: {
56+ durationMs: 4,
57+ exitCode: 0,
58+ finishedAt: "2026-03-27T12:00:01.000Z",
59+ signal: null,
60+ startedAt: "2026-03-27T12:00:00.000Z",
61+ stderr: "",
62+ stdout: "hello artifact",
63+ timedOut: false
64+ }
65+ },
66+ dedupeKey: "dedupe-exec-1",
67+ instructionId: "inst_exec_01",
68+ requestId: "req-exec-1",
69+ route: {
70+ key: "local.exec",
71+ method: "POST",
72+ path: "/v1/exec"
73+ },
74+ tool: "exec"
75+ })
76+ }
77+ ],
78+ {
79+ artifactOnlyTextBytes: 2000,
80+ inlineTextBytes: 200,
81+ outputDir,
82+ roundId: "r03",
83+ traceId: "trace_artifacts"
84+ }
85+ );
86+
87+ assert.equal(materialization.results[0].deliveryMode, "inline_and_artifact");
88+ assert.equal(materialization.artifacts.length, 1);
89+ assert.match(materialization.results[0].summary, /exec succeeded/);
90+ assert.equal(existsSync(materialization.artifacts[0].localPath), true);
91+ assert.match(readFileSync(materialization.artifacts[0].localPath, "utf8"), /\[stdout\]\nhello artifact/);
92+
93+ const manifestBundle = await buildBaaArtifactManifest(materialization);
94+ const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
95+ const plan = buildBaaDeliveryPlan({
96+ conversationId: "conv_exec",
97+ indexText,
98+ manifestBundle,
99+ materialization,
100+ target: "browser.claude"
101+ });
102+
103+ assert.equal(plan.uploads.length, 2);
104+ assert.deepEqual(plan.pendingBarriers, ["upload_receipt"]);
105+ assert.equal(plan.receiptBarrierImplemented, false);
106+ assert.match(indexText, /instruction_id: inst_exec_01/);
107+ assert.match(indexText, /baa-result_trace-artifacts_r03_b02_exec_conductor_ok\.log/);
108+ assert.match(indexText, /baa-manifest_trace-artifacts_r03\.json/);
109+ } finally {
110+ rmSync(outputDir, {
111+ force: true,
112+ recursive: true
113+ });
114+ }
115+});
116+
117+test("files/read small and large payloads do not collapse to the same delivery strategy", async () => {
118+ const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-files-read-"));
119+ const largeContent = `HEADER\n${"0123456789".repeat(700)}\nTAIL_MARKER`;
120+
121+ try {
122+ const materialization = await materializeBaaExecutionArtifacts(
123+ [
124+ {
125+ blockIndex: 1,
126+ execution: createExecutionResult({
127+ data: {
128+ ok: true,
129+ operation: "files/read",
130+ input: {
131+ cwd: "/tmp",
132+ encoding: "utf8",
133+ path: "small.txt"
134+ },
135+ result: {
136+ absolutePath: "/tmp/small.txt",
137+ content: "small text\n",
138+ encoding: "utf8",
139+ modifiedAt: "2026-03-27T12:00:00.000Z",
140+ sizeBytes: 11
141+ }
142+ },
143+ dedupeKey: "dedupe-read-small",
144+ instructionId: "inst_read_small",
145+ route: {
146+ key: "local.files.read",
147+ method: "POST",
148+ path: "/v1/files/read"
149+ },
150+ tool: "files/read"
151+ })
152+ },
153+ {
154+ blockIndex: 2,
155+ execution: createExecutionResult({
156+ data: {
157+ ok: true,
158+ operation: "files/read",
159+ input: {
160+ cwd: "/tmp",
161+ encoding: "utf8",
162+ path: "large.txt"
163+ },
164+ result: {
165+ absolutePath: "/tmp/large.txt",
166+ content: largeContent,
167+ encoding: "utf8",
168+ modifiedAt: "2026-03-27T12:00:00.000Z",
169+ sizeBytes: largeContent.length
170+ }
171+ },
172+ dedupeKey: "dedupe-read-large",
173+ instructionId: "inst_read_large",
174+ route: {
175+ key: "local.files.read",
176+ method: "POST",
177+ path: "/v1/files/read"
178+ },
179+ tool: "files/read"
180+ })
181+ }
182+ ],
183+ {
184+ artifactOnlyTextBytes: 1024,
185+ inlineTextBytes: 128,
186+ outputDir,
187+ roundId: "r05",
188+ traceId: "trace_files"
189+ }
190+ );
191+ const manifestBundle = await buildBaaArtifactManifest(materialization);
192+ const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
193+ const plan = buildBaaDeliveryPlan({
194+ indexText,
195+ manifestBundle,
196+ materialization,
197+ target: "browser.chatgpt"
198+ });
199+
200+ assert.equal(materialization.results[0].deliveryMode, "inline");
201+ assert.equal(materialization.results[0].artifacts.length, 0);
202+ assert.equal(materialization.results[1].deliveryMode, "artifact_only");
203+ assert.equal(materialization.results[1].artifacts.length, 1);
204+ assert.match(indexText, /small text/);
205+ assert.match(indexText, /baa-result_trace-files_r05_b02_files-read_conductor_ok\.txt/);
206+ assert.doesNotMatch(indexText, /TAIL_MARKER/);
207+ assert.equal(plan.uploads.length, 2);
208+ } finally {
209+ rmSync(outputDir, {
210+ force: true,
211+ recursive: true
212+ });
213+ }
214+});
215+
216+test("manifest order stays stable across multiple results and missing optional fields do not crash materialization", async () => {
217+ const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-order-"));
218+
219+ try {
220+ const materialization = await materializeBaaExecutionArtifacts(
221+ [
222+ {
223+ blockIndex: 3,
224+ execution: createExecutionResult({
225+ data: {
226+ daemon: {
227+ leaseState: "leader"
228+ },
229+ runtime: {
230+ started: true
231+ }
232+ },
233+ dedupeKey: "dedupe-status",
234+ instructionId: "inst_status",
235+ message: null,
236+ requestId: null,
237+ route: {
238+ key: "local.status",
239+ method: "GET",
240+ path: "/v1/status"
241+ },
242+ tool: "status"
243+ })
244+ },
245+ {
246+ blockIndex: 1,
247+ execution: createExecutionResult({
248+ data: {
249+ endpoints: [
250+ {
251+ method: "GET",
252+ path: "/describe/control"
253+ }
254+ ],
255+ surface: "control"
256+ },
257+ dedupeKey: "dedupe-describe",
258+ instructionId: "inst_describe",
259+ route: {
260+ key: "local.describe.control",
261+ method: "GET",
262+ path: "/describe/control"
263+ },
264+ tool: "describe/control"
265+ })
266+ },
267+ {
268+ blockIndex: 2,
269+ execution: createExecutionResult({
270+ data: {
271+ ok: true,
272+ operation: "files/write",
273+ input: {
274+ content: "hello",
275+ createParents: false,
276+ cwd: "/tmp",
277+ encoding: "utf8",
278+ overwrite: true,
279+ path: "written.txt"
280+ },
281+ result: {
282+ absolutePath: "/tmp/written.txt",
283+ bytesWritten: 5,
284+ created: true,
285+ encoding: "utf8",
286+ modifiedAt: "2026-03-27T12:00:00.000Z"
287+ }
288+ },
289+ dedupeKey: "dedupe-write",
290+ details: null,
291+ instructionId: "inst_write",
292+ requestId: null,
293+ route: {
294+ key: "local.files.write",
295+ method: "POST",
296+ path: "/v1/files/write"
297+ },
298+ tool: "files/write"
299+ })
300+ }
301+ ],
302+ {
303+ artifactOnlyTextBytes: 512,
304+ inlineTextBytes: 32,
305+ outputDir,
306+ roundId: "r07",
307+ traceId: "trace_order"
308+ }
309+ );
310+ const manifestBundle = await buildBaaArtifactManifest(materialization);
311+
312+ assert.deepEqual(
313+ materialization.results.map((entry) => entry.instructionId),
314+ ["inst_describe", "inst_write", "inst_status"]
315+ );
316+ assert.deepEqual(
317+ manifestBundle.manifest.results.map((entry) => entry.blockIndex),
318+ [1, 2, 3]
319+ );
320+ assert.equal(manifestBundle.manifest.resultCount, 3);
321+ assert.equal(manifestBundle.manifest.notes.length >= 2, true);
322+ } finally {
323+ rmSync(outputDir, {
324+ force: true,
325+ recursive: true
326+ });
327+ }
328+});
1@@ -0,0 +1,42 @@
2+import type {
3+ BaaDeliveryPlan,
4+ BaaDeliveryUploadItem,
5+ BuildBaaDeliveryPlanInput
6+} from "./types.js";
7+
8+function toUploadItem(artifact: {
9+ artifactId: string;
10+ filename: string;
11+ localPath: string;
12+ mimeType: string;
13+}): BaaDeliveryUploadItem {
14+ return {
15+ artifactId: artifact.artifactId,
16+ filename: artifact.filename,
17+ localPath: artifact.localPath,
18+ mimeType: artifact.mimeType
19+ };
20+}
21+
22+export function buildBaaDeliveryPlan(input: BuildBaaDeliveryPlanInput): BaaDeliveryPlan {
23+ const uploads = input.materialization.artifacts.map((artifact) => toUploadItem(artifact));
24+
25+ if (uploads.length > 0) {
26+ uploads.push(toUploadItem(input.manifestBundle.artifact));
27+ }
28+
29+ return {
30+ autoSend: input.autoSend ?? false,
31+ conversationId: input.conversationId ?? null,
32+ manifestId: input.manifestBundle.manifest.manifestId,
33+ messageText: input.indexText,
34+ pendingBarriers: uploads.length === 0 ? [] : ["upload_receipt"],
35+ planId: `plan_${input.materialization.traceId}_${input.materialization.roundId}`,
36+ receiptBarrierImplemented: false,
37+ roundId: input.materialization.roundId,
38+ target: input.target,
39+ traceId: input.materialization.traceId,
40+ uploads,
41+ version: "baa.delivery-plan.v1"
42+ };
43+}
1@@ -0,0 +1,4 @@
2+export * from "./types.js";
3+export * from "./materialize.js";
4+export * from "./manifest.js";
5+export * from "./delivery-plan.js";
1@@ -0,0 +1,150 @@
2+import { createHash } from "node:crypto";
3+import { writeFile } from "node:fs/promises";
4+import { join } from "node:path";
5+
6+import type {
7+ BaaArtifactManifest,
8+ BaaArtifactManifestBundle,
9+ BaaArtifactMaterialization,
10+ BaaMaterializedArtifactRef
11+} from "./types.js";
12+
13+function bytesOf(text: string): number {
14+ return Buffer.byteLength(text, "utf8");
15+}
16+
17+function sanitizeSegment(value: string): string {
18+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
19+ const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
20+ return collapsed === "" ? "unknown" : collapsed;
21+}
22+
23+function toManifestArtifactEntry(artifact: BaaMaterializedArtifactRef) {
24+ return {
25+ artifactId: artifact.artifactId,
26+ blockIndex: artifact.blockIndex,
27+ filename: artifact.filename,
28+ instructionId: artifact.instructionId,
29+ kind: artifact.kind,
30+ localPath: artifact.localPath,
31+ mimeType: artifact.mimeType,
32+ sequence: artifact.sequence,
33+ sha256: artifact.sha256,
34+ sizeBytes: artifact.sizeBytes,
35+ target: artifact.target,
36+ tool: artifact.tool
37+ };
38+}
39+
40+function toManifestResultEntry(materialization: BaaArtifactMaterialization) {
41+ return materialization.results.map((result) => ({
42+ artifactIds: result.artifacts.map((artifact) => artifact.artifactId),
43+ blockIndex: result.blockIndex,
44+ dedupeKey: result.dedupeKey,
45+ deliveryMode: result.deliveryMode,
46+ errorCode: result.errorCode,
47+ httpStatus: result.httpStatus,
48+ instructionId: result.instructionId,
49+ ok: result.ok,
50+ requestId: result.requestId,
51+ route: {
52+ key: result.route.key,
53+ method: result.route.method,
54+ path: result.route.path
55+ },
56+ sequence: result.sequence,
57+ summary: result.summary,
58+ target: result.target,
59+ tool: result.tool
60+ }));
61+}
62+
63+export async function buildBaaArtifactManifest(
64+ materialization: BaaArtifactMaterialization
65+): Promise<BaaArtifactManifestBundle> {
66+ const manifest: BaaArtifactManifest = {
67+ artifactCount: materialization.artifacts.length,
68+ artifacts: materialization.artifacts.map((artifact) => toManifestArtifactEntry(artifact)),
69+ createdAt: materialization.createdAt,
70+ manifestId: `mf_${sanitizeSegment(materialization.traceId)}_${sanitizeSegment(materialization.roundId)}`,
71+ notes: [
72+ "Service-side artifact materialization, manifest generation, and delivery-plan generation are implemented in conductor.",
73+ "Browser upload/download execution and upload receipt barrier are not implemented in this phase."
74+ ],
75+ resultCount: materialization.results.length,
76+ results: toManifestResultEntry(materialization),
77+ roundId: materialization.roundId,
78+ traceId: materialization.traceId,
79+ version: "baa.manifest.v1"
80+ };
81+ const content = `${JSON.stringify(manifest, null, 2)}\n`;
82+ const filename = `baa-manifest_${sanitizeSegment(materialization.traceId)}_${sanitizeSegment(materialization.roundId)}.json`;
83+ const localPath = join(materialization.outputDir, filename);
84+
85+ await writeFile(localPath, content, "utf8");
86+
87+ return {
88+ artifact: {
89+ artifactId: manifest.manifestId,
90+ blockIndex: null,
91+ createdAt: materialization.createdAt,
92+ filename,
93+ instructionId: null,
94+ kind: "manifest",
95+ localPath,
96+ mimeType: "application/json",
97+ sequence: materialization.results.length,
98+ sha256: createHash("sha256").update(content).digest("hex"),
99+ sizeBytes: bytesOf(content),
100+ target: null,
101+ tool: null
102+ },
103+ manifest
104+ };
105+}
106+
107+export function renderBaaArtifactIndexText(
108+ materialization: BaaArtifactMaterialization,
109+ manifestBundle: BaaArtifactManifestBundle
110+): string {
111+ const lines = [
112+ "[BAA Result Index]",
113+ `- trace_id: ${materialization.traceId}`,
114+ `- round_id: ${materialization.roundId}`,
115+ `- manifest_id: ${manifestBundle.manifest.manifestId}`
116+ ];
117+
118+ for (const result of materialization.results) {
119+ lines.push("");
120+ lines.push(`- instruction_id: ${result.instructionId}`);
121+ lines.push(`- tool: ${result.target}::${result.tool}`);
122+ lines.push(`- status: ${result.ok ? "ok" : "failed"}`);
123+ lines.push(`- delivery_mode: ${result.deliveryMode}`);
124+ lines.push(`- summary: ${result.summary}`);
125+
126+ if (result.deliveryMode === "inline" && result.inlineText != null) {
127+ lines.push("- inline_text:");
128+ lines.push("```text");
129+ lines.push(result.inlineText.trimEnd());
130+ lines.push("```");
131+ } else if (result.deliveryMode === "inline_and_artifact") {
132+ const excerpt = result.previewText ?? result.inlineText;
133+
134+ if (excerpt != null && excerpt.trim() !== "") {
135+ lines.push("- excerpt:");
136+ lines.push("```text");
137+ lines.push(excerpt.trimEnd());
138+ lines.push("```");
139+ }
140+ }
141+
142+ if (result.artifacts.length > 0) {
143+ lines.push(`- artifacts: ${result.artifacts.map((artifact) => artifact.filename).join(", ")}`);
144+ }
145+ }
146+
147+ lines.push("");
148+ lines.push(`- manifest: ${manifestBundle.artifact.filename}`);
149+
150+ return `${lines.join("\n")}\n`;
151+}
1@@ -0,0 +1,712 @@
2+import { createHash } from "node:crypto";
3+import { mkdir, writeFile } from "node:fs/promises";
4+import { join } from "node:path";
5+
6+import {
7+ sortBaaJsonValue,
8+ type BaaInstructionExecutionResult,
9+ type BaaJsonValue
10+} from "../instructions/types.js";
11+import type {
12+ BaaArtifactContentKind,
13+ BaaArtifactKind,
14+ BaaArtifactMaterialization,
15+ BaaArtifactMaterializeCandidate,
16+ BaaArtifactMaterializeOptions,
17+ BaaDeliveryMode,
18+ BaaMaterializedArtifactRef,
19+ BaaMaterializedResult
20+} from "./types.js";
21+
22+export const DEFAULT_INLINE_TEXT_BYTES = 600;
23+export const DEFAULT_ARTIFACT_ONLY_TEXT_BYTES = 4_096;
24+export const DEFAULT_PREVIEW_CHARS = 240;
25+
26+interface JsonRecord {
27+ [key: string]: BaaJsonValue;
28+}
29+
30+interface Thresholds {
31+ artifactOnlyTextBytes: number;
32+ inlineTextBytes: number;
33+ previewChars: number;
34+}
35+
36+interface PreparedCandidate {
37+ blockIndex: number | null;
38+ execution: BaaInstructionExecutionResult;
39+ sequence: number;
40+}
41+
42+interface ArtifactSpec {
43+ content: string;
44+ extension: string;
45+ kind: BaaArtifactKind;
46+ mimeType: string;
47+}
48+
49+interface MaterializedDraft {
50+ artifactSpec: ArtifactSpec | null;
51+ contentBytes: number;
52+ contentKind: BaaArtifactContentKind;
53+ deliveryMode: BaaDeliveryMode;
54+ errorCode: string | null;
55+ inlineText: string | null;
56+ ok: boolean;
57+ previewText: string | null;
58+ summary: string;
59+}
60+
61+interface TextDeliveryDecision {
62+ deliveryMode: BaaDeliveryMode;
63+ inlineText: string | null;
64+ previewText: string | null;
65+ shouldArtifact: boolean;
66+}
67+
68+function asRecord(value: BaaJsonValue | null): JsonRecord | null {
69+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
70+ return null;
71+ }
72+
73+ return value as JsonRecord;
74+}
75+
76+function readBooleanValue(record: JsonRecord | null, key: string): boolean | null {
77+ const value = record?.[key];
78+ return typeof value === "boolean" ? value : null;
79+}
80+
81+function readNumberValue(record: JsonRecord | null, key: string): number | null {
82+ const value = record?.[key];
83+ return typeof value === "number" && Number.isFinite(value) ? value : null;
84+}
85+
86+function readObjectValue(record: JsonRecord | null, key: string): JsonRecord | null {
87+ return asRecord(record?.[key] ?? null);
88+}
89+
90+function readStringValue(record: JsonRecord | null, key: string): string | null {
91+ const value = record?.[key];
92+ return typeof value === "string" ? value : null;
93+}
94+
95+function bytesOf(text: string): number {
96+ return Buffer.byteLength(text, "utf8");
97+}
98+
99+function buildArtifactId(traceId: string, roundId: string, artifactSequence: number): string {
100+ return `art_${sanitizeSegment(traceId)}_${sanitizeSegment(roundId)}_${String(artifactSequence).padStart(3, "0")}`;
101+}
102+
103+function buildArtifactFilename(
104+ traceId: string,
105+ roundId: string,
106+ blockIndex: number | null,
107+ sequence: number,
108+ tool: string,
109+ target: string,
110+ ok: boolean,
111+ extension: string
112+): string {
113+ const blockLabel =
114+ blockIndex == null
115+ ? `s${String(sequence + 1).padStart(2, "0")}`
116+ : `b${String(blockIndex).padStart(2, "0")}`;
117+
118+ return [
119+ "baa-result",
120+ sanitizeSegment(traceId),
121+ sanitizeSegment(roundId),
122+ blockLabel,
123+ sanitizeSegment(tool),
124+ sanitizeSegment(target),
125+ ok ? "ok" : "fail"
126+ ].join("_") + `.${extension}`;
127+}
128+
129+function buildDefaultSummary(execution: BaaInstructionExecutionResult): string {
130+ const detail = truncateText(execution.message ?? execution.error ?? "completed", 96);
131+ return execution.ok ? `${execution.tool} completed: ${detail}` : `${execution.tool} failed: ${detail}`;
132+}
133+
134+function buildFailureText(execution: BaaInstructionExecutionResult): string {
135+ const sections: string[] = [];
136+
137+ if (execution.message) {
138+ sections.push(`message: ${execution.message}`);
139+ }
140+
141+ if (execution.error) {
142+ sections.push(`error: ${execution.error}`);
143+ }
144+
145+ if (execution.details != null) {
146+ sections.push(`[details]\n${stablePrettyJson(execution.details).trimEnd()}`);
147+ }
148+
149+ return sections.join("\n\n");
150+}
151+
152+function buildStructuredSummary(execution: BaaInstructionExecutionResult, data: BaaJsonValue | null): string {
153+ const root = asRecord(data);
154+
155+ if (execution.tool === "status") {
156+ const daemon = readObjectValue(root, "daemon");
157+ const runtime = readObjectValue(root, "runtime");
158+ const leaseState = readStringValue(daemon, "leaseState") ?? "unknown";
159+ const started = readBooleanValue(runtime, "started");
160+ return `status returned daemon=${leaseState}, runtime.started=${started == null ? "unknown" : String(started)}`;
161+ }
162+
163+ const surface = readStringValue(root, "surface");
164+ const endpoints = root?.endpoints;
165+ const endpointCount = Array.isArray(endpoints) ? endpoints.length : null;
166+ const baseSummary =
167+ surface == null
168+ ? `${execution.tool} returned structured JSON`
169+ : `${execution.tool} returned surface=${surface}`;
170+
171+ return endpointCount == null ? baseSummary : `${baseSummary} (${endpointCount} endpoints)`;
172+}
173+
174+function createTimestamp(input: string | undefined): string {
175+ if (typeof input === "string" && input.trim() !== "") {
176+ return input;
177+ }
178+
179+ return new Date().toISOString();
180+}
181+
182+function decideTextDelivery(
183+ text: string,
184+ thresholds: Thresholds,
185+ options: {
186+ alwaysArtifact?: boolean;
187+ binaryLike?: boolean;
188+ } = {}
189+): TextDeliveryDecision {
190+ const binaryLike = options.binaryLike ?? looksBinaryLike(text);
191+ const contentBytes = bytesOf(text);
192+
193+ if (contentBytes === 0) {
194+ return {
195+ deliveryMode: "inline",
196+ inlineText: null,
197+ previewText: null,
198+ shouldArtifact: options.alwaysArtifact === true
199+ };
200+ }
201+
202+ if (binaryLike) {
203+ return {
204+ deliveryMode: "artifact_only",
205+ inlineText: null,
206+ previewText: null,
207+ shouldArtifact: true
208+ };
209+ }
210+
211+ if (contentBytes <= thresholds.inlineTextBytes) {
212+ if (options.alwaysArtifact === true) {
213+ return {
214+ deliveryMode: "inline_and_artifact",
215+ inlineText: text,
216+ previewText: null,
217+ shouldArtifact: true
218+ };
219+ }
220+
221+ return {
222+ deliveryMode: "inline",
223+ inlineText: text,
224+ previewText: null,
225+ shouldArtifact: false
226+ };
227+ }
228+
229+ if (contentBytes <= thresholds.artifactOnlyTextBytes) {
230+ return {
231+ deliveryMode: "inline_and_artifact",
232+ inlineText: null,
233+ previewText: truncateText(text, thresholds.previewChars),
234+ shouldArtifact: true
235+ };
236+ }
237+
238+ return {
239+ deliveryMode: "artifact_only",
240+ inlineText: null,
241+ previewText: null,
242+ shouldArtifact: true
243+ };
244+}
245+
246+function extractErrorCode(
247+ execution: BaaInstructionExecutionResult,
248+ record: JsonRecord | null
249+): string | null {
250+ const errorRecord = readObjectValue(record, "error");
251+ return readStringValue(errorRecord, "code") ?? execution.error;
252+}
253+
254+async function createArtifact(
255+ prepared: PreparedCandidate,
256+ draft: MaterializedDraft,
257+ artifactSequence: number,
258+ options: BaaArtifactMaterializeOptions,
259+ createdAt: string
260+): Promise<BaaMaterializedArtifactRef | null> {
261+ const spec = draft.artifactSpec;
262+
263+ if (spec == null) {
264+ return null;
265+ }
266+
267+ const artifactId = buildArtifactId(options.traceId, options.roundId, artifactSequence);
268+ const filename = buildArtifactFilename(
269+ options.traceId,
270+ options.roundId,
271+ prepared.blockIndex,
272+ prepared.sequence,
273+ prepared.execution.tool,
274+ prepared.execution.target,
275+ draft.ok,
276+ spec.extension
277+ );
278+ const localPath = join(options.outputDir, filename);
279+
280+ await writeFile(localPath, spec.content, "utf8");
281+
282+ return {
283+ artifactId,
284+ blockIndex: prepared.blockIndex,
285+ createdAt,
286+ filename,
287+ instructionId: prepared.execution.instructionId,
288+ kind: spec.kind,
289+ localPath,
290+ mimeType: spec.mimeType,
291+ sequence: prepared.sequence,
292+ sha256: createHash("sha256").update(spec.content).digest("hex"),
293+ sizeBytes: bytesOf(spec.content),
294+ target: prepared.execution.target,
295+ tool: prepared.execution.tool
296+ };
297+}
298+
299+function isNonNegativeInteger(value: number | null | undefined): value is number {
300+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
301+}
302+
303+function looksBinaryLike(text: string): boolean {
304+ if (text.includes("\0")) {
305+ return true;
306+ }
307+
308+ let controlCount = 0;
309+
310+ for (const char of text) {
311+ const codePoint = char.codePointAt(0);
312+
313+ if (codePoint == null) {
314+ continue;
315+ }
316+
317+ if ((codePoint >= 0 && codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13) || codePoint === 127) {
318+ controlCount += 1;
319+ }
320+ }
321+
322+ return controlCount >= 16 && controlCount / Math.max(text.length, 1) > 0.05;
323+}
324+
325+function normalizeThresholds(options: BaaArtifactMaterializeOptions): Thresholds {
326+ const inlineTextBytes =
327+ isNonNegativeInteger(options.inlineTextBytes) && options.inlineTextBytes > 0
328+ ? options.inlineTextBytes
329+ : DEFAULT_INLINE_TEXT_BYTES;
330+ const requestedArtifactOnlyBytes =
331+ isNonNegativeInteger(options.artifactOnlyTextBytes) && options.artifactOnlyTextBytes > 0
332+ ? options.artifactOnlyTextBytes
333+ : DEFAULT_ARTIFACT_ONLY_TEXT_BYTES;
334+
335+ return {
336+ artifactOnlyTextBytes: Math.max(requestedArtifactOnlyBytes, inlineTextBytes + 1),
337+ inlineTextBytes,
338+ previewChars:
339+ isNonNegativeInteger(options.previewChars) && options.previewChars > 3
340+ ? options.previewChars
341+ : DEFAULT_PREVIEW_CHARS
342+ };
343+}
344+
345+function prepareCandidates(
346+ inputs: readonly BaaArtifactMaterializeCandidate[]
347+): PreparedCandidate[] {
348+ return inputs
349+ .map((input, index) => ({
350+ blockIndex: isNonNegativeInteger(input.blockIndex) ? input.blockIndex : null,
351+ execution: input.execution,
352+ sequence: isNonNegativeInteger(input.sequence) ? input.sequence : index
353+ }))
354+ .sort((left, right) => {
355+ const leftBlock = left.blockIndex ?? Number.MAX_SAFE_INTEGER;
356+ const rightBlock = right.blockIndex ?? Number.MAX_SAFE_INTEGER;
357+
358+ if (leftBlock !== rightBlock) {
359+ return leftBlock - rightBlock;
360+ }
361+
362+ if (left.sequence !== right.sequence) {
363+ return left.sequence - right.sequence;
364+ }
365+
366+ const instructionOrder = left.execution.instructionId.localeCompare(right.execution.instructionId);
367+
368+ if (instructionOrder !== 0) {
369+ return instructionOrder;
370+ }
371+
372+ const toolOrder = left.execution.tool.localeCompare(right.execution.tool);
373+
374+ if (toolOrder !== 0) {
375+ return toolOrder;
376+ }
377+
378+ return left.execution.target.localeCompare(right.execution.target);
379+ });
380+}
381+
382+function sanitizeSegment(value: string): string {
383+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
384+ const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
385+ return collapsed === "" ? "unknown" : collapsed;
386+}
387+
388+function stablePrettyJson(value: BaaJsonValue): string {
389+ return `${JSON.stringify(sortBaaJsonValue(value), null, 2)}\n`;
390+}
391+
392+function truncateText(text: string, maxChars: number): string {
393+ if (text.length <= maxChars) {
394+ return text;
395+ }
396+
397+ return `${text.slice(0, Math.max(maxChars - 3, 1))}...`;
398+}
399+
400+function materializeDescribeLike(
401+ execution: BaaInstructionExecutionResult,
402+ thresholds: Thresholds
403+): MaterializedDraft {
404+ if (execution.data == null) {
405+ return materializeGeneric(execution, thresholds);
406+ }
407+
408+ const content = stablePrettyJson(execution.data);
409+ const decision = decideTextDelivery(content, thresholds);
410+
411+ return {
412+ artifactSpec: decision.shouldArtifact
413+ ? {
414+ content,
415+ extension: "json",
416+ kind: "payload",
417+ mimeType: "application/json"
418+ }
419+ : null,
420+ contentBytes: bytesOf(content),
421+ contentKind: "structured_json",
422+ deliveryMode: decision.deliveryMode,
423+ errorCode: execution.error,
424+ inlineText: decision.inlineText,
425+ ok: execution.ok,
426+ previewText: decision.previewText,
427+ summary: buildStructuredSummary(execution, execution.data)
428+ };
429+}
430+
431+function materializeExec(
432+ execution: BaaInstructionExecutionResult,
433+ thresholds: Thresholds
434+): MaterializedDraft {
435+ const record = asRecord(execution.data);
436+ const input = readObjectValue(record, "input");
437+ const result = readObjectValue(record, "result");
438+ const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
439+ const command = readStringValue(input, "command") ?? "unknown command";
440+ const cwd = readStringValue(input, "cwd");
441+ const exitCode = readNumberValue(result, "exitCode");
442+ const stderr = readStringValue(result, "stderr") ?? "";
443+ const stdout = readStringValue(result, "stdout") ?? "";
444+ const timedOut = readBooleanValue(result, "timedOut") ?? false;
445+ const sections = [`$ ${command}`];
446+
447+ if (cwd != null) {
448+ sections.push(`cwd: ${cwd}`);
449+ }
450+
451+ if (stdout !== "") {
452+ sections.push(`[stdout]\n${stdout}`);
453+ }
454+
455+ if (stderr !== "") {
456+ sections.push(`[stderr]\n${stderr}`);
457+ }
458+
459+ if (stdout === "" && stderr === "") {
460+ sections.push("[output]\n(no stdout or stderr)");
461+ }
462+
463+ const content = `${sections.join("\n\n")}\n`;
464+ const decision = decideTextDelivery(content, thresholds, {
465+ alwaysArtifact: true,
466+ binaryLike: false
467+ });
468+ const statusDetail = timedOut
469+ ? "timeout"
470+ : exitCode == null
471+ ? extractErrorCode(execution, record) ?? "unknown"
472+ : `exitCode=${exitCode}`;
473+
474+ return {
475+ artifactSpec: {
476+ content,
477+ extension: "log",
478+ kind: "log",
479+ mimeType: "text/plain"
480+ },
481+ contentBytes: bytesOf(content),
482+ contentKind: "text",
483+ deliveryMode: decision.deliveryMode,
484+ errorCode: extractErrorCode(execution, record),
485+ inlineText: decision.inlineText,
486+ ok: operationOk,
487+ previewText: decision.previewText,
488+ summary: operationOk
489+ ? `exec succeeded (${statusDetail}): ${truncateText(command, 96)}`
490+ : `exec failed (${statusDetail}): ${truncateText(command, 96)}`
491+ };
492+}
493+
494+function materializeFileRead(
495+ execution: BaaInstructionExecutionResult,
496+ thresholds: Thresholds
497+): MaterializedDraft {
498+ const record = asRecord(execution.data);
499+ const input = readObjectValue(record, "input");
500+ const result = readObjectValue(record, "result");
501+ const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
502+ const content = readStringValue(result, "content") ?? "";
503+ const absolutePath = readStringValue(result, "absolutePath") ?? readStringValue(input, "path") ?? "unknown";
504+ const reportedSizeBytes = readNumberValue(result, "sizeBytes");
505+ const contentBytes = content === "" ? 0 : bytesOf(content);
506+ const binaryLike = looksBinaryLike(content);
507+ const errorCode = extractErrorCode(execution, record);
508+
509+ if (!operationOk || content === "") {
510+ return {
511+ artifactSpec: null,
512+ contentBytes: 0,
513+ contentKind: "none",
514+ deliveryMode: "inline",
515+ errorCode,
516+ inlineText: null,
517+ ok: operationOk,
518+ previewText: null,
519+ summary: operationOk
520+ ? `files/read completed with no content: ${absolutePath}`
521+ : `files/read failed${errorCode == null ? "" : ` (${errorCode})`}: ${absolutePath}`
522+ };
523+ }
524+
525+ const decision = decideTextDelivery(content, thresholds, {
526+ binaryLike
527+ });
528+
529+ return {
530+ artifactSpec: decision.shouldArtifact
531+ ? {
532+ content,
533+ extension: binaryLike ? "bin" : "txt",
534+ kind: "payload",
535+ mimeType: binaryLike ? "application/octet-stream" : "text/plain"
536+ }
537+ : null,
538+ contentBytes,
539+ contentKind: binaryLike ? "binary_like_text" : "text",
540+ deliveryMode: decision.deliveryMode,
541+ errorCode,
542+ inlineText: decision.inlineText,
543+ ok: operationOk,
544+ previewText: decision.previewText,
545+ summary: binaryLike
546+ ? `files/read returned binary-like text: ${absolutePath} (${reportedSizeBytes ?? contentBytes} B)`
547+ : `files/read succeeded: ${absolutePath} (${reportedSizeBytes ?? contentBytes} B)`
548+ };
549+}
550+
551+function materializeFileWrite(
552+ execution: BaaInstructionExecutionResult
553+): MaterializedDraft {
554+ const record = asRecord(execution.data);
555+ const input = readObjectValue(record, "input");
556+ const result = readObjectValue(record, "result");
557+ const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
558+ const absolutePath = readStringValue(result, "absolutePath") ?? readStringValue(input, "path") ?? "unknown";
559+ const bytesWritten = readNumberValue(result, "bytesWritten");
560+ const created = readBooleanValue(result, "created");
561+ const errorCode = extractErrorCode(execution, record);
562+
563+ return {
564+ artifactSpec: null,
565+ contentBytes: 0,
566+ contentKind: "none",
567+ deliveryMode: "inline",
568+ errorCode,
569+ inlineText: null,
570+ ok: operationOk,
571+ previewText: null,
572+ summary: operationOk
573+ ? `files/write succeeded: ${absolutePath} (${bytesWritten ?? 0} B, ${created === true ? "created" : "updated"})`
574+ : `files/write failed${errorCode == null ? "" : ` (${errorCode})`}: ${absolutePath}`
575+ };
576+}
577+
578+function materializeGeneric(
579+ execution: BaaInstructionExecutionResult,
580+ thresholds: Thresholds
581+): MaterializedDraft {
582+ if (execution.data != null) {
583+ return materializeDescribeLike(execution, thresholds);
584+ }
585+
586+ const failureText = buildFailureText(execution);
587+
588+ if (failureText !== "") {
589+ const decision = decideTextDelivery(failureText, thresholds);
590+
591+ return {
592+ artifactSpec: decision.shouldArtifact
593+ ? {
594+ content: `${failureText}\n`,
595+ extension: "txt",
596+ kind: "summary",
597+ mimeType: "text/plain"
598+ }
599+ : null,
600+ contentBytes: bytesOf(failureText),
601+ contentKind: "text",
602+ deliveryMode: decision.deliveryMode,
603+ errorCode: execution.error,
604+ inlineText: decision.inlineText,
605+ ok: execution.ok,
606+ previewText: decision.previewText,
607+ summary: buildDefaultSummary(execution)
608+ };
609+ }
610+
611+ return {
612+ artifactSpec: null,
613+ contentBytes: 0,
614+ contentKind: "none",
615+ deliveryMode: "inline",
616+ errorCode: execution.error,
617+ inlineText: null,
618+ ok: execution.ok,
619+ previewText: null,
620+ summary: buildDefaultSummary(execution)
621+ };
622+}
623+
624+function materializePreparedCandidate(
625+ prepared: PreparedCandidate,
626+ thresholds: Thresholds
627+): MaterializedDraft {
628+ switch (prepared.execution.tool) {
629+ case "exec":
630+ return materializeExec(prepared.execution, thresholds);
631+ case "files/read":
632+ return materializeFileRead(prepared.execution, thresholds);
633+ case "files/write":
634+ return materializeFileWrite(prepared.execution);
635+ case "describe":
636+ case "status":
637+ return materializeDescribeLike(prepared.execution, thresholds);
638+ default:
639+ return prepared.execution.tool.startsWith("describe/")
640+ ? materializeDescribeLike(prepared.execution, thresholds)
641+ : materializeGeneric(prepared.execution, thresholds);
642+ }
643+}
644+
645+export async function materializeBaaExecutionArtifacts(
646+ inputs: readonly BaaArtifactMaterializeCandidate[],
647+ options: BaaArtifactMaterializeOptions
648+): Promise<BaaArtifactMaterialization> {
649+ const createdAt = createTimestamp(options.createdAt);
650+ const thresholds = normalizeThresholds(options);
651+ const preparedCandidates = prepareCandidates(inputs);
652+ const artifacts: BaaMaterializedArtifactRef[] = [];
653+ const results: BaaMaterializedResult[] = [];
654+ let artifactSequence = 0;
655+
656+ await mkdir(options.outputDir, {
657+ recursive: true
658+ });
659+
660+ for (const prepared of preparedCandidates) {
661+ const draft = materializePreparedCandidate(prepared, thresholds);
662+ const artifact =
663+ draft.artifactSpec == null
664+ ? null
665+ : await createArtifact(prepared, draft, ++artifactSequence, options, createdAt);
666+ const resultArtifacts = artifact == null ? [] : [artifact];
667+
668+ if (artifact != null) {
669+ artifacts.push(artifact);
670+ }
671+
672+ results.push({
673+ artifacts: resultArtifacts,
674+ audit: {
675+ contentBytes: draft.contentBytes,
676+ contentKind: draft.contentKind,
677+ hasArtifact: artifact != null,
678+ localApiOk: prepared.execution.ok,
679+ operationOk: draft.ok
680+ },
681+ blockIndex: prepared.blockIndex,
682+ dedupeKey: prepared.execution.dedupeKey,
683+ deliveryMode: draft.deliveryMode,
684+ errorCode: draft.errorCode,
685+ httpStatus: prepared.execution.httpStatus,
686+ inlineText: draft.inlineText,
687+ instructionId: prepared.execution.instructionId,
688+ ok: draft.ok,
689+ previewText: draft.previewText,
690+ requestId: prepared.execution.requestId,
691+ route: {
692+ key: prepared.execution.route.key,
693+ method: prepared.execution.route.method,
694+ path: prepared.execution.route.path
695+ },
696+ sequence: prepared.sequence,
697+ summary: draft.summary,
698+ target: prepared.execution.target,
699+ tool: prepared.execution.tool
700+ });
701+ }
702+
703+ return {
704+ artifactCount: artifacts.length,
705+ artifacts,
706+ createdAt,
707+ outputDir: options.outputDir,
708+ results,
709+ roundId: options.roundId,
710+ traceId: options.traceId,
711+ version: "baa.artifacts.v1"
712+ };
713+}
+166,
-0
1@@ -0,0 +1,166 @@
2+import type { BaaInstructionExecutionResult } from "../instructions/types.js";
3+
4+export type BaaArtifactContentKind = "binary_like_text" | "none" | "structured_json" | "text";
5+export type BaaArtifactKind = "log" | "manifest" | "payload" | "summary";
6+export type BaaDeliveryBarrier = "upload_receipt";
7+export type BaaDeliveryMode = "artifact_only" | "inline" | "inline_and_artifact";
8+
9+export interface BaaArtifactMaterializeCandidate {
10+ blockIndex?: number | null;
11+ execution: BaaInstructionExecutionResult;
12+ sequence?: number | null;
13+}
14+
15+export interface BaaArtifactMaterializeOptions {
16+ artifactOnlyTextBytes?: number;
17+ createdAt?: string;
18+ inlineTextBytes?: number;
19+ outputDir: string;
20+ previewChars?: number;
21+ roundId: string;
22+ traceId: string;
23+}
24+
25+export interface BaaMaterializedArtifactRef {
26+ artifactId: string;
27+ blockIndex: number | null;
28+ createdAt: string;
29+ filename: string;
30+ instructionId: string | null;
31+ kind: BaaArtifactKind;
32+ localPath: string;
33+ mimeType: string;
34+ sequence: number;
35+ sha256: string;
36+ sizeBytes: number;
37+ target: string | null;
38+ tool: string | null;
39+}
40+
41+export interface BaaMaterializedResultAudit {
42+ contentBytes: number;
43+ contentKind: BaaArtifactContentKind;
44+ hasArtifact: boolean;
45+ localApiOk: boolean;
46+ operationOk: boolean;
47+}
48+
49+export interface BaaMaterializedResult {
50+ artifacts: BaaMaterializedArtifactRef[];
51+ audit: BaaMaterializedResultAudit;
52+ blockIndex: number | null;
53+ dedupeKey: string;
54+ deliveryMode: BaaDeliveryMode;
55+ errorCode: string | null;
56+ httpStatus: number;
57+ inlineText: string | null;
58+ instructionId: string;
59+ ok: boolean;
60+ previewText: string | null;
61+ requestId: string | null;
62+ route: {
63+ key: string;
64+ method: "GET" | "POST";
65+ path: string;
66+ };
67+ sequence: number;
68+ summary: string;
69+ target: string;
70+ tool: string;
71+}
72+
73+export interface BaaArtifactMaterialization {
74+ artifactCount: number;
75+ artifacts: BaaMaterializedArtifactRef[];
76+ createdAt: string;
77+ outputDir: string;
78+ results: BaaMaterializedResult[];
79+ roundId: string;
80+ traceId: string;
81+ version: "baa.artifacts.v1";
82+}
83+
84+export interface BaaArtifactManifestArtifactEntry {
85+ artifactId: string;
86+ blockIndex: number | null;
87+ filename: string;
88+ instructionId: string | null;
89+ kind: BaaArtifactKind;
90+ localPath: string;
91+ mimeType: string;
92+ sequence: number;
93+ sha256: string;
94+ sizeBytes: number;
95+ target: string | null;
96+ tool: string | null;
97+}
98+
99+export interface BaaArtifactManifestResultEntry {
100+ artifactIds: string[];
101+ blockIndex: number | null;
102+ dedupeKey: string;
103+ deliveryMode: BaaDeliveryMode;
104+ errorCode: string | null;
105+ httpStatus: number;
106+ instructionId: string;
107+ ok: boolean;
108+ requestId: string | null;
109+ route: {
110+ key: string;
111+ method: "GET" | "POST";
112+ path: string;
113+ };
114+ sequence: number;
115+ summary: string;
116+ target: string;
117+ tool: string;
118+}
119+
120+export interface BaaArtifactManifest {
121+ artifactCount: number;
122+ artifacts: BaaArtifactManifestArtifactEntry[];
123+ createdAt: string;
124+ manifestId: string;
125+ notes: string[];
126+ resultCount: number;
127+ results: BaaArtifactManifestResultEntry[];
128+ roundId: string;
129+ traceId: string;
130+ version: "baa.manifest.v1";
131+}
132+
133+export interface BaaArtifactManifestBundle {
134+ artifact: BaaMaterializedArtifactRef;
135+ manifest: BaaArtifactManifest;
136+}
137+
138+export interface BaaDeliveryUploadItem {
139+ artifactId: string;
140+ filename: string;
141+ localPath: string;
142+ mimeType: string;
143+}
144+
145+export interface BaaDeliveryPlan {
146+ autoSend: boolean;
147+ conversationId: string | null;
148+ manifestId: string;
149+ messageText: string;
150+ pendingBarriers: BaaDeliveryBarrier[];
151+ planId: string;
152+ receiptBarrierImplemented: boolean;
153+ roundId: string;
154+ target: string;
155+ traceId: string;
156+ uploads: BaaDeliveryUploadItem[];
157+ version: "baa.delivery-plan.v1";
158+}
159+
160+export interface BuildBaaDeliveryPlanInput {
161+ autoSend?: boolean;
162+ conversationId?: string | null;
163+ indexText: string;
164+ manifestBundle: BaaArtifactManifestBundle;
165+ materialization: BaaArtifactMaterialization;
166+ target: string;
167+}
1@@ -1,3 +1,5 @@
2+import type { BaaLiveInstructionIngestSnapshot } from "./instructions/ingest.js";
3+
4 export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
5
6 export interface BrowserBridgeEndpointMetadataSnapshot {
7@@ -143,6 +145,7 @@ export interface BrowserBridgeStateSnapshot {
8 active_connection_id: string | null;
9 client_count: number;
10 clients: BrowserBridgeClientSnapshot[];
11+ instruction_ingest: BaaLiveInstructionIngestSnapshot;
12 ws_path: string;
13 ws_url: string | null;
14 }
+26,
-2
1@@ -20,6 +20,7 @@ import type {
2 BrowserBridgeShellRuntimeSnapshot,
3 BrowserBridgeStateSnapshot
4 } from "./browser-types.js";
5+import type { BaaLiveInstructionIngest } from "./instructions/ingest.js";
6 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
7 import type { ConductorRuntimeSnapshot } from "./index.js";
8
9@@ -43,6 +44,7 @@ type FirefoxWsAction = "pause" | "resume" | "drain";
10
11 interface FirefoxWebSocketServerOptions {
12 baseUrlLoader: () => string;
13+ instructionIngest?: BaaLiveInstructionIngest | null;
14 now?: () => number;
15 repository: ControlPlaneRepository;
16 snapshotLoader: () => ConductorRuntimeSnapshot;
17@@ -945,6 +947,7 @@ class FirefoxWebSocketConnection {
18 export class ConductorFirefoxWebSocketServer {
19 private readonly baseUrlLoader: () => string;
20 private readonly bridgeService: FirefoxBridgeService;
21+ private readonly instructionIngest: BaaLiveInstructionIngest | null;
22 private readonly now: () => number;
23 private readonly repository: ControlPlaneRepository;
24 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
25@@ -957,6 +960,7 @@ export class ConductorFirefoxWebSocketServer {
26
27 constructor(options: FirefoxWebSocketServerOptions) {
28 this.baseUrlLoader = options.baseUrlLoader;
29+ this.instructionIngest = options.instructionIngest ?? null;
30 this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
31 this.repository = options.repository;
32 this.snapshotLoader = options.snapshotLoader;
33@@ -1543,7 +1547,7 @@ export class ConductorFirefoxWebSocketServer {
34 return;
35 }
36
37- connection.addFinalMessage({
38+ const finalMessage = {
39 assistant_message_id: assistantMessageId,
40 conversation_id: readFirstString(message, ["conversation_id", "conversationId"]),
41 observed_at:
42@@ -1551,8 +1555,24 @@ export class ConductorFirefoxWebSocketServer {
43 ?? this.getNowMilliseconds(),
44 platform,
45 raw_text: rawText
46- });
47+ };
48+
49+ connection.addFinalMessage(finalMessage);
50 await this.broadcastStateSnapshot("browser.final_message");
51+
52+ if (this.instructionIngest == null) {
53+ return;
54+ }
55+
56+ await this.instructionIngest.ingestAssistantFinalMessage({
57+ assistantMessageId: finalMessage.assistant_message_id,
58+ conversationId: finalMessage.conversation_id,
59+ observedAt: finalMessage.observed_at,
60+ platform: finalMessage.platform,
61+ source: "browser.final_message",
62+ text: finalMessage.raw_text
63+ });
64+ await this.broadcastStateSnapshot("instruction_ingest");
65 }
66
67 private handleApiResponse(
68@@ -1778,6 +1798,10 @@ export class ConductorFirefoxWebSocketServer {
69 active_connection_id: activeClient?.connectionId ?? null,
70 client_count: clients.length,
71 clients,
72+ instruction_ingest: this.instructionIngest?.getSnapshot() ?? {
73+ last_execute: null,
74+ last_ingest: null
75+ },
76 ws_path: FIREFOX_WS_PATH,
77 ws_url: this.getUrl()
78 };
+222,
-1
1@@ -1,17 +1,19 @@
2 import assert from "node:assert/strict";
3 import { EventEmitter } from "node:events";
4 import { createServer } from "node:http";
5-import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7 import { createConnection } from "node:net";
8 import { homedir, tmpdir } from "node:os";
9 import { join } from "node:path";
10 import test from "node:test";
11
12+import "./artifacts.test.js";
13 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
14 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
15 import {
16 BaaInstructionCenter,
17 BaaInstructionCenterError,
18+ BaaLiveInstructionIngest,
19 BrowserRequestPolicyController,
20 ConductorDaemon,
21 ConductorRuntime,
22@@ -740,6 +742,106 @@ test("BaaInstructionCenter fails closed before execution when a batch contains a
23 }
24 });
25
26+test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser final messages, and tolerates missing conversation ids", async () => {
27+ const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
28+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-live-instruction-ingest-"));
29+ const dedupeFilePath = join(hostOpsDir, "dedupe-count.txt");
30+ const ingest = new BaaLiveInstructionIngest({
31+ localApiContext: {
32+ fetchImpl: globalThis.fetch,
33+ repository,
34+ sharedToken,
35+ snapshotLoader: () => snapshot
36+ },
37+ now: () => 1_710_000_100_000
38+ });
39+ const executableMessage = [
40+ "```baa",
41+ `@conductor::exec::{"command":"printf 'live-hit\\n' >> dedupe-count.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
42+ "```"
43+ ].join("\n");
44+
45+ try {
46+ const ignored = await ingest.ingestAssistantFinalMessage({
47+ assistantMessageId: "msg-live-plain",
48+ conversationId: null,
49+ observedAt: 1_710_000_001_000,
50+ platform: "chatgpt",
51+ source: "browser.final_message",
52+ text: [
53+ "plain markdown must stay inert",
54+ "```js",
55+ "console.log('no baa');",
56+ "```"
57+ ].join("\n")
58+ });
59+
60+ assert.equal(ignored.summary.status, "ignored_no_instructions");
61+ assert.equal(ignored.summary.execution_count, 0);
62+ assert.equal(ignored.summary.conversation_id, null);
63+
64+ const firstPass = await ingest.ingestAssistantFinalMessage({
65+ assistantMessageId: "msg-live-exec",
66+ conversationId: null,
67+ observedAt: 1_710_000_002_000,
68+ platform: "chatgpt",
69+ source: "browser.final_message",
70+ text: executableMessage
71+ });
72+
73+ assert.equal(firstPass.summary.status, "executed");
74+ assert.equal(firstPass.summary.execution_count, 1);
75+ assert.equal(firstPass.summary.execution_ok_count, 1);
76+ assert.equal(firstPass.summary.instruction_tools[0], "conductor::exec");
77+ assert.equal(firstPass.summary.conversation_id, null);
78+ assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
79+
80+ const replayPass = await ingest.ingestAssistantFinalMessage({
81+ assistantMessageId: "msg-live-exec",
82+ conversationId: "conv-should-not-matter",
83+ observedAt: 1_710_000_003_000,
84+ platform: "chatgpt",
85+ source: "browser.final_message",
86+ text: executableMessage
87+ });
88+
89+ assert.equal(replayPass.summary.status, "duplicate_message");
90+ assert.equal(replayPass.summary.execution_count, 0);
91+ assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
92+
93+ const failed = await ingest.ingestAssistantFinalMessage({
94+ assistantMessageId: "msg-live-fail-closed",
95+ conversationId: null,
96+ observedAt: 1_710_000_004_000,
97+ platform: "chatgpt",
98+ source: "browser.final_message",
99+ text: [
100+ "```baa",
101+ `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
102+ "```",
103+ "",
104+ "```baa",
105+ "@browser.chatgpt::send::should fail closed",
106+ "```"
107+ ].join("\n")
108+ });
109+
110+ assert.equal(failed.summary.status, "failed");
111+ assert.equal(failed.summary.error_stage, "policy");
112+ assert.equal(failed.summary.error_block_index, 1);
113+ assert.equal(existsSync(join(hostOpsDir, "should-not-exist.txt")), false);
114+
115+ assert.equal(ingest.getSnapshot().last_ingest?.assistant_message_id, "msg-live-fail-closed");
116+ assert.equal(ingest.getSnapshot().last_execute?.status, "failed");
117+ } finally {
118+ controlPlane.close();
119+ rmSync(hostOpsDir, {
120+ force: true,
121+ recursive: true
122+ });
123+ }
124+});
125+
126 function createManualTimerScheduler() {
127 let now = 0;
128 let nextId = 1;
129@@ -4129,6 +4231,125 @@ test("ConductorRuntime exposes Firefox outbound bridge commands and api request
130 }
131 });
132
133+test("ConductorRuntime routes browser.final_message into live instruction ingest and exposes the latest summary", async () => {
134+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-ingest-"));
135+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-host-"));
136+ const runtime = new ConductorRuntime(
137+ {
138+ nodeId: "mini-main",
139+ host: "mini",
140+ role: "primary",
141+ controlApiBase: "https://control.example.test",
142+ localApiBase: "http://127.0.0.1:0",
143+ sharedToken: "replace-me",
144+ paths: {
145+ runsDir: "/tmp/runs",
146+ stateDir
147+ }
148+ },
149+ {
150+ autoStartLoops: false,
151+ now: () => 100
152+ }
153+ );
154+
155+ let client = null;
156+
157+ try {
158+ const snapshot = await runtime.start();
159+ const baseUrl = snapshot.controlApi.localApiBase;
160+ const messageText = [
161+ "```baa",
162+ "@conductor::describe",
163+ "```",
164+ "",
165+ "```baa",
166+ `@conductor::exec::{"command":"printf 'ws-live\\n' >> final-message-ingest.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
167+ "```"
168+ ].join("\n");
169+
170+ client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-final-message-ingest");
171+
172+ client.socket.send(
173+ JSON.stringify({
174+ type: "browser.final_message",
175+ platform: "chatgpt",
176+ assistant_message_id: "msg-final-message-ingest",
177+ raw_text: messageText,
178+ observed_at: 1_710_000_010_000
179+ })
180+ );
181+
182+ const executedSnapshot = await client.queue.next(
183+ (message) =>
184+ message.type === "state_snapshot"
185+ && message.reason === "instruction_ingest"
186+ && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-final-message-ingest"
187+ && message.snapshot.browser.instruction_ingest.last_ingest?.status === "executed"
188+ );
189+
190+ assert.equal(
191+ executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.conversation_id,
192+ null
193+ );
194+ assert.deepEqual(
195+ executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.instruction_tools,
196+ ["conductor::describe", "conductor::exec"]
197+ );
198+ assert.equal(
199+ executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.execution_ok_count,
200+ 2
201+ );
202+ assert.equal(readFileSync(join(hostOpsDir, "final-message-ingest.txt"), "utf8"), "ws-live\n");
203+
204+ const browserStatusResponse = await fetch(`${baseUrl}/v1/browser`);
205+ assert.equal(browserStatusResponse.status, 200);
206+ const browserStatusPayload = await browserStatusResponse.json();
207+ assert.equal(browserStatusPayload.data.instruction_ingest.last_ingest.status, "executed");
208+ assert.equal(
209+ browserStatusPayload.data.instruction_ingest.last_execute.assistant_message_id,
210+ "msg-final-message-ingest"
211+ );
212+
213+ client.socket.send(
214+ JSON.stringify({
215+ type: "browser.final_message",
216+ platform: "chatgpt",
217+ conversation_id: "conv-replayed",
218+ assistant_message_id: "msg-final-message-ingest",
219+ raw_text: messageText,
220+ observed_at: 1_710_000_010_500
221+ })
222+ );
223+
224+ const duplicateSnapshot = await client.queue.next(
225+ (message) =>
226+ message.type === "state_snapshot"
227+ && message.reason === "instruction_ingest"
228+ && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-final-message-ingest"
229+ && message.snapshot.browser.instruction_ingest.last_ingest?.status === "duplicate_message"
230+ );
231+
232+ assert.equal(
233+ duplicateSnapshot.snapshot.browser.instruction_ingest.last_execute.status,
234+ "executed"
235+ );
236+ assert.equal(readFileSync(join(hostOpsDir, "final-message-ingest.txt"), "utf8"), "ws-live\n");
237+ } finally {
238+ client?.queue.stop();
239+ client?.socket.close(1000, "done");
240+ await runtime.stop();
241+ rmSync(stateDir, {
242+ force: true,
243+ recursive: true
244+ });
245+ rmSync(hostOpsDir, {
246+ force: true,
247+ recursive: true
248+ });
249+ }
250+});
251+
252 test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Firefox bridge", async () => {
253 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-http-"));
254 const runtime = new ConductorRuntime(
+14,
-0
1@@ -20,6 +20,7 @@ import {
2 type BrowserRequestPolicyControllerOptions
3 } from "./browser-request-policy.js";
4 import type { FirefoxBridgeService } from "./firefox-bridge.js";
5+import { BaaLiveInstructionIngest } from "./instructions/ingest.js";
6 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
7 import { ConductorLocalControlPlane } from "./local-control-plane.js";
8
9@@ -40,6 +41,7 @@ export {
10 type BrowserRequestPolicyControllerOptions
11 } from "./browser-request-policy.js";
12 export { handleConductorHttpRequest } from "./local-api.js";
13+export * from "./artifacts/index.js";
14 export * from "./instructions/index.js";
15
16 export type ConductorRole = "primary" | "standby";
17@@ -708,8 +710,20 @@ class ConductorLocalHttpServer {
18 this.snapshotLoader = snapshotLoader;
19 this.version = version;
20 this.resolvedBaseUrl = localApiBase;
21+ const instructionIngest = new BaaLiveInstructionIngest({
22+ localApiContext: {
23+ fetchImpl: this.fetchImpl,
24+ now: this.now,
25+ repository: this.repository,
26+ sharedToken: this.sharedToken,
27+ snapshotLoader: this.snapshotLoader,
28+ version: this.version
29+ },
30+ now: () => this.now() * 1000
31+ });
32 this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
33 baseUrlLoader: () => this.resolvedBaseUrl,
34+ instructionIngest,
35 now: this.now,
36 repository: this.repository,
37 snapshotLoader: this.snapshotLoader
1@@ -7,3 +7,4 @@ export * from "./policy.js";
2 export * from "./router.js";
3 export * from "./executor.js";
4 export * from "./loop.js";
5+export * from "./ingest.js";
1@@ -0,0 +1,315 @@
2+import { createHash } from "node:crypto";
3+
4+import type { ConductorLocalApiContext } from "../local-api.js";
5+
6+import {
7+ BaaInstructionCenter,
8+ BaaInstructionCenterError,
9+ type BaaInstructionCenterOptions
10+} from "./loop.js";
11+import type { BaaInstructionProcessResult, BaaInstructionProcessStatus } from "./types.js";
12+import { stableStringifyBaaJson } from "./types.js";
13+
14+export type BaaLiveInstructionIngestStatus =
15+ | "duplicate_message"
16+ | "duplicate_only"
17+ | "executed"
18+ | "failed"
19+ | "ignored_no_instructions";
20+
21+export interface BaaLiveInstructionIngestInput {
22+ assistantMessageId: string;
23+ conversationId?: string | null;
24+ observedAt?: number | null;
25+ platform: string;
26+ source: "browser.final_message";
27+ text: string;
28+}
29+
30+export interface BaaLiveInstructionIngestSummary {
31+ assistant_message_id: string;
32+ block_count: number;
33+ conversation_id: string | null;
34+ duplicate_instruction_count: number;
35+ duplicate_tools: string[];
36+ error_block_index: number | null;
37+ error_message: string | null;
38+ error_stage: string | null;
39+ executed_tools: string[];
40+ execution_count: number;
41+ execution_failed_count: number;
42+ execution_ok_count: number;
43+ ingested_at: number;
44+ instruction_count: number;
45+ instruction_tools: string[];
46+ message_dedupe_key: string;
47+ observed_at: number | null;
48+ platform: string;
49+ source: "browser.final_message";
50+ status: BaaLiveInstructionIngestStatus;
51+}
52+
53+export interface BaaLiveInstructionIngestSnapshot {
54+ last_execute: BaaLiveInstructionIngestSummary | null;
55+ last_ingest: BaaLiveInstructionIngestSummary | null;
56+}
57+
58+export interface BaaLiveInstructionIngestResult {
59+ processResult: BaaInstructionProcessResult | null;
60+ summary: BaaLiveInstructionIngestSummary;
61+}
62+
63+export interface BaaLiveInstructionMessageDeduper {
64+ add(dedupeKey: string): Promise<void> | void;
65+ has(dedupeKey: string): Promise<boolean> | boolean;
66+}
67+
68+export interface BaaLiveInstructionIngestOptions {
69+ center?: BaaInstructionCenter;
70+ localApiContext?: ConductorLocalApiContext;
71+ messageDeduper?: BaaLiveInstructionMessageDeduper;
72+ now?: () => number;
73+}
74+
75+export class InMemoryBaaLiveInstructionMessageDeduper implements BaaLiveInstructionMessageDeduper {
76+ private readonly keys = new Set<string>();
77+
78+ add(dedupeKey: string): void {
79+ this.keys.add(dedupeKey);
80+ }
81+
82+ clear(): void {
83+ this.keys.clear();
84+ }
85+
86+ has(dedupeKey: string): boolean {
87+ return this.keys.has(dedupeKey);
88+ }
89+}
90+
91+function buildInstructionDescriptor(target: string, tool: string): string {
92+ return `${target}::${tool}`;
93+}
94+
95+function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInstructionIngestStatus {
96+ switch (status) {
97+ case "duplicate_only":
98+ return "duplicate_only";
99+ case "executed":
100+ return "executed";
101+ case "no_instructions":
102+ return "ignored_no_instructions";
103+ }
104+}
105+
106+function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): boolean {
107+ return status === "duplicate_only" || status === "executed" || status === "failed";
108+}
109+
110+function normalizeOptionalString(value: string | null | undefined): string | null {
111+ if (typeof value !== "string") {
112+ return null;
113+ }
114+
115+ const normalized = value.trim();
116+ return normalized === "" ? null : normalized;
117+}
118+
119+export function buildBaaLiveInstructionMessageDedupeKey(input: {
120+ assistantMessageId: string;
121+ platform: string;
122+ text: string;
123+}): string {
124+ return `sha256:${createHash("sha256")
125+ .update(
126+ stableStringifyBaaJson({
127+ assistant_message_id: input.assistantMessageId,
128+ platform: input.platform,
129+ raw_text: input.text,
130+ version: "baa.live.v1"
131+ })
132+ )
133+ .digest("hex")}`;
134+}
135+
136+export class BaaLiveInstructionIngest {
137+ private readonly center: BaaInstructionCenter;
138+ private readonly messageDeduper: BaaLiveInstructionMessageDeduper;
139+ private readonly now: () => number;
140+ private lastExecute: BaaLiveInstructionIngestSummary | null = null;
141+ private lastIngest: BaaLiveInstructionIngestSummary | null = null;
142+ private readonly pendingKeys = new Set<string>();
143+
144+ constructor(options: BaaLiveInstructionIngestOptions) {
145+ if (options.center == null && options.localApiContext == null) {
146+ throw new Error("BaaLiveInstructionIngest requires either center or localApiContext.");
147+ }
148+
149+ this.center =
150+ options.center
151+ ?? new BaaInstructionCenter({
152+ localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"]
153+ });
154+ this.messageDeduper = options.messageDeduper ?? new InMemoryBaaLiveInstructionMessageDeduper();
155+ this.now = options.now ?? Date.now;
156+ }
157+
158+ getSnapshot(): BaaLiveInstructionIngestSnapshot {
159+ return {
160+ last_execute: this.lastExecute == null ? null : { ...this.lastExecute },
161+ last_ingest: this.lastIngest == null ? null : { ...this.lastIngest }
162+ };
163+ }
164+
165+ async ingestAssistantFinalMessage(
166+ input: BaaLiveInstructionIngestInput
167+ ): Promise<BaaLiveInstructionIngestResult> {
168+ const messageDedupeKey = buildBaaLiveInstructionMessageDedupeKey({
169+ assistantMessageId: input.assistantMessageId,
170+ platform: input.platform,
171+ text: input.text
172+ });
173+ const baseSummary = {
174+ assistant_message_id: input.assistantMessageId,
175+ conversation_id: normalizeOptionalString(input.conversationId),
176+ ingested_at: this.now(),
177+ message_dedupe_key: messageDedupeKey,
178+ observed_at: typeof input.observedAt === "number" && Number.isFinite(input.observedAt) ? input.observedAt : null,
179+ platform: input.platform,
180+ source: input.source
181+ } as const;
182+
183+ if (this.pendingKeys.has(messageDedupeKey) || await this.messageDeduper.has(messageDedupeKey)) {
184+ const summary: BaaLiveInstructionIngestSummary = {
185+ ...baseSummary,
186+ block_count: 0,
187+ duplicate_instruction_count: 0,
188+ duplicate_tools: [],
189+ error_block_index: null,
190+ error_message: null,
191+ error_stage: null,
192+ executed_tools: [],
193+ execution_count: 0,
194+ execution_failed_count: 0,
195+ execution_ok_count: 0,
196+ instruction_count: 0,
197+ instruction_tools: [],
198+ status: "duplicate_message"
199+ };
200+ this.lastIngest = summary;
201+ return {
202+ processResult: null,
203+ summary
204+ };
205+ }
206+
207+ this.pendingKeys.add(messageDedupeKey);
208+
209+ try {
210+ const processResult = await this.center.processAssistantMessage({
211+ assistantMessageId: input.assistantMessageId,
212+ conversationId: normalizeOptionalString(input.conversationId),
213+ platform: input.platform,
214+ text: input.text
215+ });
216+ const summary = this.buildSuccessSummary(baseSummary, processResult);
217+
218+ await this.messageDeduper.add(messageDedupeKey);
219+ this.lastIngest = summary;
220+
221+ if (shouldUpdateExecutionSummary(summary.status)) {
222+ this.lastExecute = summary;
223+ }
224+
225+ return {
226+ processResult,
227+ summary
228+ };
229+ } catch (error) {
230+ const summary = this.buildFailureSummary(baseSummary, error);
231+
232+ if (error instanceof BaaInstructionCenterError) {
233+ await this.messageDeduper.add(messageDedupeKey);
234+ }
235+
236+ this.lastIngest = summary;
237+ this.lastExecute = summary;
238+
239+ return {
240+ processResult: null,
241+ summary
242+ };
243+ } finally {
244+ this.pendingKeys.delete(messageDedupeKey);
245+ }
246+ }
247+
248+ private buildFailureSummary(
249+ baseSummary: Pick<
250+ BaaLiveInstructionIngestSummary,
251+ | "assistant_message_id"
252+ | "conversation_id"
253+ | "ingested_at"
254+ | "message_dedupe_key"
255+ | "observed_at"
256+ | "platform"
257+ | "source"
258+ >,
259+ error: unknown
260+ ): BaaLiveInstructionIngestSummary {
261+ return {
262+ ...baseSummary,
263+ block_count: 0,
264+ duplicate_instruction_count: 0,
265+ duplicate_tools: [],
266+ error_block_index:
267+ error instanceof BaaInstructionCenterError ? error.blockIndex : null,
268+ error_message: error instanceof Error ? error.message : String(error),
269+ error_stage: error instanceof BaaInstructionCenterError ? error.stage : "internal",
270+ executed_tools: [],
271+ execution_count: 0,
272+ execution_failed_count: 0,
273+ execution_ok_count: 0,
274+ instruction_count: 0,
275+ instruction_tools: [],
276+ status: "failed"
277+ };
278+ }
279+
280+ private buildSuccessSummary(
281+ baseSummary: Pick<
282+ BaaLiveInstructionIngestSummary,
283+ | "assistant_message_id"
284+ | "conversation_id"
285+ | "ingested_at"
286+ | "message_dedupe_key"
287+ | "observed_at"
288+ | "platform"
289+ | "source"
290+ >,
291+ processResult: BaaInstructionProcessResult
292+ ): BaaLiveInstructionIngestSummary {
293+ return {
294+ ...baseSummary,
295+ block_count: processResult.blocks.length,
296+ duplicate_instruction_count: processResult.duplicates.length,
297+ duplicate_tools: processResult.duplicates.map((instruction) =>
298+ buildInstructionDescriptor(instruction.target, instruction.tool)
299+ ),
300+ error_block_index: null,
301+ error_message: null,
302+ error_stage: null,
303+ executed_tools: processResult.executions.map((execution) =>
304+ buildInstructionDescriptor(execution.target, execution.tool)
305+ ),
306+ execution_count: processResult.executions.length,
307+ execution_failed_count: processResult.executions.filter((execution) => execution.ok === false).length,
308+ execution_ok_count: processResult.executions.filter((execution) => execution.ok === true).length,
309+ instruction_count: processResult.instructions.length,
310+ instruction_tools: processResult.instructions.map((instruction) =>
311+ buildInstructionDescriptor(instruction.target, instruction.tool)
312+ ),
313+ status: classifyProcessStatus(processResult.status)
314+ };
315+ }
316+}
1@@ -19,13 +19,22 @@ function requireNonEmptyField(name: string, value: string): string {
2 return normalized;
3 }
4
5+function normalizeOptionalStringField(value: string | null): string | null {
6+ if (value == null) {
7+ return null;
8+ }
9+
10+ const normalized = value.trim();
11+ return normalized === "" ? null : normalized;
12+}
13+
14 export function normalizeBaaInstruction(
15 source: BaaInstructionSourceMessage,
16 instruction: BaaParsedInstruction
17 ): BaaInstructionEnvelope {
18 const normalizedSource = {
19 assistantMessageId: requireNonEmptyField("assistantMessageId", source.assistantMessageId),
20- conversationId: requireNonEmptyField("conversationId", source.conversationId),
21+ conversationId: normalizeOptionalStringField(source.conversationId),
22 platform: requireNonEmptyField("platform", source.platform)
23 };
24 const dedupeBasis = buildBaaInstructionDedupeBasis(normalizedSource, instruction);
1@@ -26,14 +26,14 @@ export interface BaaParsedInstruction {
2
3 export interface BaaInstructionSourceMessage {
4 assistantMessageId: string;
5- conversationId: string;
6+ conversationId: string | null;
7 platform: string;
8 }
9
10 export interface BaaInstructionDedupeBasis {
11 assistant_message_id: string;
12 block_index: number;
13- conversation_id: string;
14+ conversation_id: string | null;
15 params: BaaInstructionParams;
16 platform: string;
17 target: string;
+61,
-1
1@@ -1633,13 +1633,30 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
2 active_connection_id: null,
3 client_count: 0,
4 clients: [],
5+ instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
6 ws_path: "/ws/firefox",
7 ws_url: snapshot.controlApi.firefoxWsUrl ?? null
8 };
9 }
10
11+function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapshot["instruction_ingest"] {
12+ return {
13+ last_execute: null,
14+ last_ingest: null
15+ };
16+}
17+
18+function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
19+ return {
20+ ...state,
21+ instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
22+ };
23+}
24+
25 function loadBrowserState(context: LocalApiRequestContext): BrowserBridgeStateSnapshot {
26- return context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader());
27+ return normalizeBrowserStateSnapshot(
28+ context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader())
29+ );
30 }
31
32 function selectClaudeBrowserClient(
33@@ -2281,6 +2298,48 @@ function serializeBrowserActionResultSnapshot(snapshot: BrowserBridgeActionResul
34 });
35 }
36
37+function serializeBrowserInstructionIngestSummary(
38+ summary: BrowserBridgeStateSnapshot["instruction_ingest"]["last_ingest"]
39+): JsonObject | null {
40+ if (summary == null) {
41+ return null;
42+ }
43+
44+ return compactJsonObject({
45+ assistant_message_id: summary.assistant_message_id,
46+ block_count: summary.block_count,
47+ conversation_id: summary.conversation_id ?? undefined,
48+ duplicate_instruction_count: summary.duplicate_instruction_count,
49+ duplicate_tools: [...summary.duplicate_tools],
50+ error_block_index: summary.error_block_index ?? undefined,
51+ error_message: summary.error_message ?? undefined,
52+ error_stage: summary.error_stage ?? undefined,
53+ executed_tools: [...summary.executed_tools],
54+ execution_count: summary.execution_count,
55+ execution_failed_count: summary.execution_failed_count,
56+ execution_ok_count: summary.execution_ok_count,
57+ ingested_at: summary.ingested_at,
58+ instruction_count: summary.instruction_count,
59+ instruction_tools: [...summary.instruction_tools],
60+ message_dedupe_key: summary.message_dedupe_key,
61+ observed_at: summary.observed_at ?? undefined,
62+ platform: summary.platform,
63+ source: summary.source,
64+ status: summary.status
65+ });
66+}
67+
68+function serializeBrowserInstructionIngestSnapshot(
69+ snapshot: BrowserBridgeStateSnapshot["instruction_ingest"] | undefined
70+): JsonObject {
71+ const normalized = snapshot ?? createEmptyBrowserInstructionIngestSnapshot();
72+
73+ return {
74+ last_execute: serializeBrowserInstructionIngestSummary(normalized.last_execute),
75+ last_ingest: serializeBrowserInstructionIngestSummary(normalized.last_ingest)
76+ };
77+}
78+
79 function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
80 return {
81 client_id: snapshot.client_id,
82@@ -2874,6 +2933,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
83 ws_path: browserState.ws_path,
84 ws_url: browserState.ws_url
85 },
86+ instruction_ingest: serializeBrowserInstructionIngestSnapshot(browserState.instruction_ingest),
87 current_client: currentClient == null ? null : serializeBrowserClientSnapshot(currentClient),
88 claude: {
89 credentials:
1@@ -1,6 +1,7 @@
2 declare class Buffer extends Uint8Array {
3 static alloc(size: number): Buffer;
4 static allocUnsafe(size: number): Buffer;
5+ static byteLength(value: string, encoding?: string): number;
6 static concat(chunks: readonly Uint8Array[]): Buffer;
7 static from(value: string, encoding?: string): Buffer;
8 copy(target: Uint8Array, targetStart?: number): number;
9@@ -47,6 +48,30 @@ declare module "node:fs" {
10 export function readFileSync(path: string, encoding: string): string;
11 }
12
13+declare module "node:fs/promises" {
14+ export interface FileOperationOptions {
15+ encoding?: string;
16+ flag?: string;
17+ mode?: number;
18+ }
19+
20+ export interface MakeDirectoryOptions {
21+ mode?: number;
22+ recursive?: boolean;
23+ }
24+
25+ export function mkdir(
26+ path: string,
27+ options?: MakeDirectoryOptions
28+ ): Promise<string | undefined>;
29+
30+ export function writeFile(
31+ path: string,
32+ data: string,
33+ options?: FileOperationOptions | string
34+ ): Promise<void>;
35+}
36+
37 declare module "node:http" {
38 import type { AddressInfo } from "node:net";
39
40@@ -88,6 +113,7 @@ declare module "node:http" {
41 }
42
43 declare module "node:path" {
44+ export function join(...paths: string[]): string;
45 export function resolve(...paths: string[]): string;
46 }
47
+15,
-4
1@@ -145,6 +145,9 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
2 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
3 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
4 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
5+- `snapshot.browser.instruction_ingest` 暴露最近一次 live ingest / execute 最小摘要:
6+ - `last_ingest`
7+ - `last_execute`
8 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
9
10 ### `action_request`
11@@ -251,13 +254,21 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
12 - `raw_text` 必须是完整最终文本,不是 stream chunk
13 - 插件必须在 streaming 完成后再发送,不得在半截 stream 提前上报
14 - `assistant_message_id` 允许退化为平台内等价稳定字段,但最终仍统一放进同名字段
15-- 当前 server 只做接收和最近快照保留,不在这层做 BAA 提取或执行
16+- `conversation_id` 允许为空
17+- 当前 server 会把 live `browser.final_message` 直接送入 conductor 侧 instruction center
18+- 若消息不含 ` ```baa `,会被安全忽略
19+- 当前只允许 Phase 1 精确 target:
20+ - `conductor`
21+ - `system`
22+- 当前仍不包含 artifact materialization / upload / inject / send
23
24 server 行为:
25
26-- 用 `requestId` 关联对应 HTTP `POST /v1/browser/actions`
27-- 把最新 `shell_runtime` 合并进当前 client 视图
28-- 把最近一次结构化 `action_result` 暴露给 `GET /v1/browser`
29+- 把最新 final message 去重后写入当前 client 的最近快照
30+- 以 `platform + assistant_message_id + raw_text` 做 live message replay 抑制
31+- 把最近一次 ingest / execute 摘要暴露到:
32+ - `state_snapshot.snapshot.browser.instruction_ingest`
33+ - `GET /v1/browser`
34
35 ### `credentials`
36
1@@ -218,6 +218,11 @@ packages/schemas/src/
2 4. 插件按 plan 上传
3 5. 插件返回 receipt
4 6. conductor 放行注入文本
5+
6+补充:
7+
8+- 当前 repo 已落地到第 2 步:Firefox WS `browser.final_message` 会直接进入 conductor instruction center
9+- 当前还没有 artifact / upload / inject / send 闭环,最近结果只保留 live runtime 摘要
10 7. 插件注入并发送
11
12 这样你就实现了:
1@@ -78,6 +78,28 @@ raw execution result
2
3 不建议把所有东西都强制变成 `.md`。
4
5+## 9.6A 当前 repo 落地状态(2026-03-27)
6+
7+- `apps/conductor-daemon/src/artifacts/` 已补上 service-side core:
8+ - `materialize.ts`
9+ - `manifest.ts`
10+ - `delivery-plan.ts`
11+- 当前 conductor 已能用 synthetic execution result 稳定生成:
12+ - artifact refs
13+ - auditable manifest
14+ - `[BAA Result Index]` 文本
15+ - upload-aware delivery plan
16+- 当前最小支持结果类型:
17+ - `exec`
18+ - `files/read`
19+ - `files/write`
20+ - `describe`
21+ - `status`
22+- 当前仍未实现:
23+ - 插件实际 upload / download 执行
24+ - upload receipt barrier
25+ - 依赖 receipt 的自动注入 / 自动发送
26+
27 ## 9.7 上传确认 barrier
28
29 文件被拖进输入框,不等于平台已经接收成功。
+10,
-1
1@@ -72,7 +72,11 @@
2 补充说明:
3
4 - `browser.final_message.raw_text` 属于 live WS relay,不进入当前持久化表
5-- 当前服务端只保留活跃 bridge client 的最近最终消息快照,用于后续 instruction center 接入
6+- `browser.final_message` 现在会直接进入 conductor 侧 `BaaInstructionCenter`;普通消息若不含 ` ```baa ` 会被安全忽略
7+- 当前服务端会同时保留:
8+ - 活跃 bridge client 的最近最终消息快照
9+ - 最近一次 `instruction_ingest` / `execute` 最小摘要(见 `GET /v1/browser`)
10+- 当前仍没有 artifact packaging、upload、inject 或自动 send
11
12 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
13
14@@ -164,6 +168,11 @@
15 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
16 - `browser.final_message` 只在最终完成态上报完整文本,不会在 streaming 半截上报
17 - 若平台暂时拿不到原生稳定 message id,插件会退化到等价稳定字段后再放进 `assistant_message_id`
18+- 若 `raw_text` 含 ` ```baa `,当前只会按 Phase 1 边界执行精确 target:
19+ - `conductor`
20+ - `system`
21+- `conversation_id` 允许为空;当前 replay 去重至少覆盖 `platform + assistant_message_id + raw_text`
22+- 当前仍未接 artifact / upload / inject / send 闭环
23
24 ## 浏览器本地代发
25
1@@ -0,0 +1,96 @@
2+# BAA Artifact Center 与 Delivery Plan Core 需求
3+
4+## 状态
5+
6+- `已完成(T-S032,2026-03-27)`
7+- 优先级:`high`
8+- 记录时间:`2026-03-27`
9+
10+## 关联文档
11+
12+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
13+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
14+- [06-integration-with-current-baa-conductor.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md)
15+- [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
16+
17+## 背景
18+
19+当前 repo 已经具备:
20+
21+- `instructions/` Phase 1 最小执行闭环
22+- `browser.final_message` raw relay
23+
24+但执行结果当前仍停留在“直接返回原始结果摘要”的阶段,缺少 v5 明确要求的:
25+
26+- artifact materialization
27+- manifest / index text
28+- delivery plan
29+
30+如果不把这层建起来,后续大结果、并发多结果和薄插件上传/注入都没有稳定交付模型。
31+
32+## 核心结论
33+
34+- artifact center 应放在 conductor,不放在插件
35+- 首版先完成 service-side core:
36+ - artifact materializer
37+ - manifest builder
38+ - delivery plan builder
39+- 首版允许完全用 synthetic execution result / fixture 驱动,不阻塞于 live browser final message 接线
40+- 插件上传 / 下载 / 注入执行可留到后续任务,不强行并进本张卡
41+
42+## 首版范围
43+
44+- `apps/conductor-daemon/src/artifacts/` 新模块
45+- 执行结果到 artifact 的转换规则
46+- manifest / index text 生成
47+- delivery plan 数据结构与最小生成逻辑
48+- 自动化测试与文档回写
49+
50+## 当前落地摘要
51+
52+- `apps/conductor-daemon/src/artifacts/` 已新增:
53+ - `types.ts`
54+ - `materialize.ts`
55+ - `manifest.ts`
56+ - `delivery-plan.ts`
57+- 当前最小支持结果类型:
58+ - `exec`
59+ - `files/read`
60+ - `files/write`
61+ - `describe` / `describe/business` / `describe/control`
62+ - `status`
63+- synthetic execution result 已补自动化覆盖:
64+ - `exec` 产出 artifact + manifest + delivery plan
65+ - `files/read` 小结果 / 大结果有不同 delivery 策略
66+ - 多结果排序稳定,缺少可选字段不会让整批 materialize 崩掉
67+- 当前仍明确未落地:
68+ - Firefox 插件 upload / download 执行
69+ - upload receipt barrier
70+ - 浏览器侧 inject / send 主链
71+
72+## 建议最小支持的结果类型
73+
74+- `exec` 的 stdout / stderr 文本
75+- `files/read` 的文本内容
76+- `files/write` / `describe` / `status` 的结构化摘要
77+
78+## 当前明确不要求
79+
80+- 不要求这张卡里实现 Firefox 插件 upload / download
81+- 不要求这张卡里实现 receipt barrier
82+- 不要求这张卡里完成自动注入 / 自动发送
83+- 不要求这张卡里接多节点 / 逻辑 target / 任务池
84+
85+## 验收条件
86+
87+- synthetic execution results 能稳定 materialize 为 artifacts
88+- manifest 和 index text 稳定、可审计
89+- delivery plan 结构可稳定生成,且对大结果不会退化成整段直接塞聊天
90+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/baa-instruction-system-v5/`
91+
92+## 当前预期残余边界
93+
94+- upload / download / receipt barrier 仍留到后续任务
95+- 首版 delivery plan 可以先面向单客户端、单轮交付
96+- 真正的浏览器注入链仍由后续插件任务接上
97+- 当前 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
1@@ -127,4 +127,6 @@
2
3 - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续调整 payload 形态,需要同步修改提取器
4 - Gemini 当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析来抽取最终文本;稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
5-- conductor 侧这轮只完成 `browser.final_message` 的最小兼容接收和最近快照保留;当前不落持久化表,也没有直接接入 instruction parser / execution loop
6+- conductor 侧现已把 `browser.final_message` 接到 live instruction ingest,但 live message dedupe 和 instruction dedupe 都还是进程内内存态,重启后不会保留
7+- 当前摘要只保留最近一次 live ingest / execute,不落当前持久化表
8+- 当前 live 路径仍只允许 Phase 1 精确 target,且还没有接 artifact / upload / inject / send
1@@ -0,0 +1,81 @@
2+# BAA 最终消息实时接线需求
3+
4+## 状态
5+
6+- `已落地(T-S031 已实现)`
7+- 优先级:`high`
8+- 记录时间:`2026-03-27`
9+
10+## 关联文档
11+
12+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
13+- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
14+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
15+- [06-integration-with-current-baa-conductor.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md)
16+- [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
17+
18+## 背景
19+
20+当前 repo 已经完成了两件前置工作:
21+
22+- Firefox 插件会把 ChatGPT / Gemini 的最终 assistant message 以 `browser.final_message` relay 给 conductor
23+- conductor 侧已经有可运行的 `instructions/` Phase 1 最小闭环,但当前仍主要通过 synthetic assistant message / fixture 驱动
24+
25+现在真正缺的不是 parser 本身,而是把这两段接起来:
26+
27+- `browser.final_message`
28+- `BaaInstructionCenter.processAssistantMessage(...)`
29+
30+如果这层不接,现有 BAA instruction center 仍然只是“本地可运行模块”,不是 browser bridge 主链路的一部分。
31+
32+## 核心结论
33+
34+- conductor 应在收到 live `browser.final_message` 时,按当前 Phase 1 能力直接进入 extract / parse / normalize / dedupe / policy / route / execute
35+- 首版只接本机精确 target:
36+ - `conductor`
37+ - `system`
38+- 首版不要求自动注入 / 自动发送 / delivery plan
39+- 首版允许只做内存态执行审计和最近结果快照,不要求同时落库
40+
41+## 首版范围
42+
43+- Firefox WS `browser.final_message` 到 instruction center 的实时接线
44+- live message 和 current synthetic path 共用同一套 instruction core
45+- live message replay 去重
46+- fail-closed 错误路径
47+- 最小可观察结果快照 / 读面诊断
48+- 文档与自动化测试
49+
50+## 处理原则
51+
52+- 只在最终 assistant message 上执行,不消费 stream 中间态
53+- 普通消息无 ` ```baa ` 时应安全忽略
54+- 同一条 final message replay 不得重复执行
55+- 任一 block 在 parse / policy / route 失败时,整批 fail-closed,不允许半执行
56+- 插件仍只负责 raw relay,不放 parser / executor
57+
58+## 当前明确不要求
59+
60+- 不要求本需求里实现 artifact materialization / manifest / delivery plan
61+- 不要求本需求里做 upload / download / inject / send
62+- 不要求本需求里补持久化 dedupe 或 execution journal
63+- 不要求本需求里扩到 `browser.*` / `codex` / `node.*` / `pool.*` / `role.*`
64+
65+## 验收条件
66+
67+- live `browser.final_message` 到达后,符合当前 Phase 1 范围的 ` ```baa ` 指令会进入 instruction center
68+- 无 ` ```baa ` 的 final message 不会误执行
69+- replay 同一条 final message 不会重复执行
70+- batch 内任一 unsupported / denied 指令会 fail-closed,且不会发生半执行
71+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/`
72+
73+## 当前预期残余边界
74+
75+- live message dedupe 和 instruction dedupe 当前都还是进程内内存态,重启后不会保留
76+- 当前仍只做本机精确 target:
77+ - `conductor`
78+ - `system`
79+- 当前最近摘要只保留在 live runtime:
80+ - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
81+ - `GET /v1/browser` → `data.instruction_ingest`
82+- 当前 live ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
1@@ -194,4 +194,5 @@ apps/conductor-daemon/src/instructions/
2
3 - dedupe 目前仍是进程内内存态,进程重启后不会保留
4 - 当前只做本机精确 target,跨节点分发和多轮闭环还没接
5-- 当前 instruction center 仍主要通过 synthetic assistant message / fixture 驱动验证,尚未把 Firefox bridge live `browser.final_message` 输入直接接到 `processAssistantMessage(...)`
6+- 当前 Firefox bridge live `browser.final_message` 已接入 instruction center,但最近摘要只保留在 live runtime,不落库
7+- 当前 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
+11,
-6
1@@ -8,14 +8,14 @@
2
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
7 - 任务文档已统一收口到 `tasks/`
8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
9-- `T-S001` 到 `T-S030` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10+- `T-S001` 到 `T-S032` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
11
12 ## 当前状态分类
13
14-- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15+- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
16 - `当前 TODO`:
17 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
18 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
19@@ -23,6 +23,8 @@
20
21 当前新的主需求文档:
22
23+- [`./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
24+- [`./BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](./BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
25 - [`./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
26 - [`./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
27 - [`./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
28@@ -77,6 +79,8 @@
29 9. `2026-03-27` 跟进修复:`BUG-017` 已修复,buffered 模式的 SSE 响应现在会返回结构化 `events` / `full_text`
30 10. `2026-03-27` 跟进任务:`T-S027` 已完成,browser request policy 已补 stale `inFlight` 自愈清扫、lease 活跃时间追踪和 `GET /v1/browser` 读面诊断字段
31 11. `2026-03-27` 跟进任务:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
32+12. `2026-03-27` 跟进任务:`T-S031` 已完成,`browser.final_message` 已接入 live instruction ingest,并把最近一次 ingest / execute 摘要暴露到 Firefox WS 与 `/v1/browser`
33+13. `2026-03-27` 跟进任务:`T-S032` 已完成,`conductor-daemon` 已补 service-side artifact center core:artifact materialize、manifest / index text 和 delivery plan
34
35 当前策略:
36
37@@ -166,12 +170,13 @@
38 - 当前 open bug backlog 已清空
39 - 当前主线下一波任务是:
40 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
41-- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留,以及 conductor 侧 instruction center Phase 1 最小闭环
42+- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留、conductor 侧 instruction center Phase 1 最小闭环与 live ingest,以及 artifact / manifest / delivery plan 服务端核心
43 - 当前 BAA 仍保留这些边界:
44 - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
45 - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
46- - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
47- - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
48+ - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
49+ - 当前摘要只保留最近一次 live ingest / execute,不落库
50+ - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
51 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
52 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
53 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
+162,
-0
1@@ -0,0 +1,162 @@
2+# Task T-S031:把 live `browser.final_message` 接到 BAA instruction center
3+
4+## 直接给对话的提示词
5+
6+读 `/Users/george/code/baa-conductor/tasks/T-S031.md` 任务文档,完成开发任务。
7+
8+如需补背景,再读:
9+
10+- `/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`
12+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
14+
15+## 当前基线
16+
17+- 仓库:`/Users/george/code/baa-conductor`
18+- 分支:`main`
19+- 提交:`85a27b6`
20+- 开工要求:如需新分支,从当前 `main` 新切
21+
22+## 建议分支名
23+
24+- `feat/browser-final-message-ingest`
25+
26+## 目标
27+
28+把 live `browser.final_message` 直接接到 conductor 侧 `BaaInstructionCenter`,让浏览器最终消息可以进入当前 Phase 1 的解析和执行闭环。
29+
30+## 背景
31+
32+`T-S029` 已经把 ChatGPT / Gemini 最终 assistant message relay 到 conductor,`T-S030` 也已经把 `instructions/` Phase 1 跑通。
33+
34+当前缺口是两者还没真正接线:`browser.final_message` 现在只做最近快照保留,没有直接进入 `processAssistantMessage(...)`。
35+
36+## 涉及仓库
37+
38+- `/Users/george/code/baa-conductor`
39+
40+## 范围
41+
42+- Firefox WS `browser.final_message` 实时接线
43+- instruction center live ingest 路径
44+- live message dedupe / fail-closed
45+- 最小执行结果观察面
46+- 自动化测试与文档回写
47+
48+## 路径约束
49+
50+- 首版只接本机精确 target:
51+ - `conductor`
52+ - `system`
53+- 不要在这张卡里顺手做 artifact / manifest / delivery plan
54+- 插件侧不加 parser,不改 `final-message.js` 提取逻辑,除非是修这个任务的直接 blocker
55+
56+## 推荐实现边界
57+
58+建议新增:
59+
60+- `apps/conductor-daemon/src/instructions/ingest.ts` 或等价 live ingest 封装
61+- 最小执行摘要 / 最近结果快照结构
62+
63+建议放到:
64+
65+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/instructions/`
66+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
67+
68+## 允许修改的目录
69+
70+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
71+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
72+- `/Users/george/code/baa-conductor/docs/api/`
73+- `/Users/george/code/baa-conductor/tasks/`
74+- `/Users/george/code/baa-conductor/plans/`
75+
76+## 尽量不要修改
77+
78+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
79+- `/Users/george/code/baa-conductor/tests/browser/`
80+- `/Users/george/code/baa-conductor/packages/db/`
81+- `/Users/george/code/baa-conductor/apps/status-api/`
82+
83+## 必须完成
84+
85+### 1. 打通 live final message ingest
86+
87+- `browser.final_message` 到达后能进入 `BaaInstructionCenter`
88+- 无 ` ```baa ` 的消息必须安全忽略
89+- replay 同一条 final message 不能重复执行
90+
91+### 2. 保持 fail-closed 和当前边界
92+
93+- batch 内任一 unsupported / denied 指令时,不能半执行
94+- 当前仍只允许本机精确 target
95+- 不要顺手扩到 `browser.*` / `pool.*` / `role.*`
96+
97+### 3. 补读面或审计摘要
98+
99+- 至少保留最近一次 ingest / execute 的最小摘要,便于诊断
100+- 文档要明确:当前还没有 artifact / upload / inject
101+
102+## 需要特别注意
103+
104+- 不要把这张卡扩成“自动注入和自动发送”
105+- 不要为了接 live ingest 重新改语法
106+- 和 `T-S032` 并行时,这张卡不要去实现 `apps/conductor-daemon/src/artifacts/`
107+
108+## 验收标准
109+
110+- live `browser.final_message` 能触发当前 Phase 1 范围内的指令执行
111+- 无 ` ```baa ` 的普通消息不会误执行
112+- replay 同一条 final message 不会重复执行
113+- unsupported / denied batch 会 fail-closed
114+- `git diff --check` 通过
115+
116+## 评测要求
117+
118+### 1. 正向评测
119+
120+- live `browser.final_message` 携带 `@conductor::describe` 可成功执行
121+- live `browser.final_message` 携带多条合法 ` ```baa ` block` 可按当前 Phase 1 规则执行
122+
123+### 2. 反向评测
124+
125+- 普通 Markdown / 普通代码块不能误触发执行
126+- replay 相同 `assistant_message_id + raw_text` 不能重复执行
127+
128+### 3. 边界评测
129+
130+- batch 中混入 unsupported 指令时必须 fail-closed
131+- 缺少 `conversation_id` 但 message identity 其余字段可用时,系统不能崩
132+
133+## 推荐验证命令
134+
135+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
136+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
137+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
138+- `git -C /Users/george/code/baa-conductor diff --check`
139+
140+## 交付要求
141+
142+完成后请说明:
143+
144+- 修改了哪些文件
145+- live ingest 是怎么接到 `BaaInstructionCenter` 的
146+- 最近执行摘要放在了哪里
147+- 跑了哪些测试
148+- 还有哪些剩余风险
149+
150+## 完成记录(2026-03-27)
151+
152+- 已完成:
153+ - `browser.final_message` 已接到 conductor 侧 live ingest 路径,并进入 `BaaInstructionCenter`
154+ - `/v1/browser` 与 Firefox WS `state_snapshot.snapshot.browser.instruction_ingest` 已暴露最近一次 live ingest / execute 摘要
155+ - automated test 已覆盖:普通消息忽略、replay 去重、缺少 `conversation_id` 可容忍、live final message 触发执行
156+- 当前残余风险:
157+ - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
158+ - 摘要当前只保留最近一次 live ingest / execute,不落库
159+ - 当前仍只允许 Phase 1 精确 target `conductor` / `system`,且 live 路径还没有接 artifact / upload / inject / send
160+- 实际验证:
161+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
162+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
163+ - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
+172,
-0
1@@ -0,0 +1,172 @@
2+# Task T-S032:补 BAA artifact center 与 delivery plan Phase 2 服务端核心
3+
4+## 直接给对话的提示词
5+
6+读 `/Users/george/code/baa-conductor/tasks/T-S032.md` 任务文档,完成开发任务。
7+
8+如需补背景,再读:
9+
10+- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
12+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md`
14+
15+## 当前基线
16+
17+- 仓库:`/Users/george/code/baa-conductor`
18+- 分支:`main`
19+- 提交:`85a27b6`
20+- 开工要求:如需新分支,从当前 `main` 新切
21+
22+## 建议分支名
23+
24+- `feat/baa-artifact-center`
25+
26+## 目标
27+
28+在 conductor 里补上 `artifact materialize + manifest/index + delivery plan` 的 Phase 2 服务端核心,不把结果交付逻辑继续散落在调用方。
29+
30+## 背景
31+
32+当前 repo 已经有 `instructions/` Phase 1,但执行结果还没有进入统一 artifact / manifest / delivery 结构。
33+
34+后续如果要稳定支持大结果、并发多结果和薄插件上传/注入,就需要先把 service-side artifact center 搭起来。
35+
36+## 涉及仓库
37+
38+- `/Users/george/code/baa-conductor`
39+
40+## 范围
41+
42+- artifact materializer
43+- manifest / index text builder
44+- delivery plan builder
45+- synthetic execution result 测试
46+- 文档与任务状态回写
47+
48+## 路径约束
49+
50+- 当前只做 conductor 服务端核心
51+- 不要在这张卡里实现 Firefox 插件 upload / download / inject
52+- 应允许完全用 synthetic execution result / fixture 跑通,不阻塞于 `T-S031`
53+
54+## 推荐实现边界
55+
56+建议新增:
57+
58+- `apps/conductor-daemon/src/artifacts/types.ts`
59+- `apps/conductor-daemon/src/artifacts/materialize.ts`
60+- `apps/conductor-daemon/src/artifacts/manifest.ts`
61+- `apps/conductor-daemon/src/artifacts/delivery-plan.ts`
62+
63+建议放到:
64+
65+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/artifacts/`
66+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
67+
68+## 允许修改的目录
69+
70+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
71+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
72+- `/Users/george/code/baa-conductor/tasks/`
73+- `/Users/george/code/baa-conductor/plans/`
74+
75+## 尽量不要修改
76+
77+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
78+- `/Users/george/code/baa-conductor/tests/browser/`
79+- `/Users/george/code/baa-conductor/packages/db/`
80+- `/Users/george/code/baa-conductor/apps/status-api/`
81+
82+## 必须完成
83+
84+### 1. 建 artifact materializer
85+
86+- 能把最小执行结果稳定转成 artifact 结构
87+- 至少覆盖 `exec`、`files/read`、`files/write` / `describe` / `status` 的摘要类结果
88+
89+### 2. 建 manifest / index / delivery plan
90+
91+- manifest 要可审计
92+- index text 要可直接作为后续注入文本基础
93+- delivery plan 不能把大结果退化成整段直接塞聊天
94+
95+### 3. 补自动化测试和文档
96+
97+- 用 synthetic execution result 跑通
98+- 文档明确:当前还没有 upload / download / receipt barrier
99+
100+## 需要特别注意
101+
102+- 不要把这张卡扩成插件上传实现
103+- 不要把这张卡扩成 live final message ingest
104+- 和 `T-S031` 并行时,这张卡不要去改 `firefox-ws.ts` / `final-message.js` 主路径
105+
106+## 验收标准
107+
108+- synthetic execution result 能稳定 materialize 为 artifacts
109+- manifest / index text / delivery plan 能稳定生成
110+- 大结果不会被默认整段直接塞进聊天
111+- `git diff --check` 通过
112+
113+## 评测要求
114+
115+### 1. 正向评测
116+
117+- `exec` 文本结果能产出 artifact + manifest entry + delivery plan
118+- `files/read` 文本结果能产出 artifact 或 inline/index 决策
119+
120+### 2. 反向评测
121+
122+- 小结果和大结果不能生成完全相同的 delivery 策略
123+- 不应该把二进制 / 大块文本默认当成纯 inline 注入
124+
125+### 3. 边界评测
126+
127+- 多结果并发时 manifest 顺序要稳定
128+- 某个结果缺少可选字段时不能让整批 materialize 崩掉
129+
130+## 推荐验证命令
131+
132+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
133+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
134+- `git -C /Users/george/code/baa-conductor diff --check`
135+
136+## 交付要求
137+
138+完成后请说明:
139+
140+- 修改了哪些文件
141+- artifact / manifest / delivery plan 是怎么设计的
142+- 当前最小支持了哪些结果类型
143+- 跑了哪些测试
144+- 还有哪些剩余风险
145+
146+## 完成记录(2026-03-27)
147+
148+- 已新增 `apps/conductor-daemon/src/artifacts/`:
149+ - `types.ts`
150+ - `materialize.ts`
151+ - `manifest.ts`
152+ - `delivery-plan.ts`
153+- 已通过 synthetic execution result 跑通:
154+ - `exec` -> artifact + manifest + delivery plan
155+ - `files/read` -> small/large 不同 delivery 策略
156+ - 多结果稳定排序
157+ - 缺少可选字段不崩整批 materialize
158+- 当前最小支持结果类型:
159+ - `exec`
160+ - `files/read`
161+ - `files/write`
162+ - `describe` / `describe/business` / `describe/control`
163+ - `status`
164+- 当前明确留到后续:
165+ - Firefox 插件 upload / download
166+ - upload receipt barrier
167+ - 依赖 receipt 的自动 inject / send
168+- 当前残余边界:
169+ - service-side artifact center core 已落地,但 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
170+ - 当前仍只服务于 Phase 1 本机精确 target,不涉及跨节点和多轮闭环
171+- 实际验证:
172+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
173+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
+12,
-6
1@@ -11,11 +11,11 @@
2 - 当前任务卡都放在本目录
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
7
8 ## 状态分类
9
10-- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
15@@ -23,6 +23,8 @@
16
17 当前新的主需求文档:
18
19+- [`../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
20+- [`../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
21 - [`../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
22 - [`../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
23 - [`../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
24@@ -66,6 +68,8 @@
25 30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
26 31. [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
27 32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
28+33. [`T-S031.md`](./T-S031.md):把 live `browser.final_message` 接到 BAA instruction center
29+34. [`T-S032.md`](./T-S032.md):补 BAA artifact center 与 delivery plan Phase 2 服务端核心
30
31 ## 已准备的后续任务
32
33@@ -130,14 +134,16 @@
34 - 当前 open bug backlog 已清空
35 - 当前主线剩余任务是:
36 - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
37-- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备:
38+- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备:
39 - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
40- - conductor 侧 instruction center Phase 1 最小闭环
41+ - conductor 侧 instruction center Phase 1 最小闭环与 live ingest
42+ - conductor 侧 artifact / manifest / index text / delivery plan 服务端核心
43 - 当前保留的 BAA 边界是:
44 - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
45 - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
46- - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
47- - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
48+ - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
49+ - 当前摘要只保留最近一次 live ingest / execute,不落库
50+ - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
51 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
52 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
53 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
1@@ -1304,6 +1304,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
2 assert.ok(firstClient);
3 assert.equal(firstClient.final_messages.length, 1);
4
5+ const firstIngestSnapshot = await client.queue.next(
6+ (message) =>
7+ message.type === "state_snapshot"
8+ && message.reason === "instruction_ingest"
9+ && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
10+ );
11+ assert.equal(
12+ firstIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
13+ "ignored_no_instructions"
14+ );
15+
16 client.socket.send(
17 JSON.stringify({
18 type: "browser.final_message",
19@@ -1332,6 +1343,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
20 assert.ok(duplicateClient);
21 assert.equal(duplicateClient.final_messages.length, 1);
22
23+ const duplicateIngestSnapshot = await client.queue.next(
24+ (message) =>
25+ message.type === "state_snapshot"
26+ && message.reason === "instruction_ingest"
27+ && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
28+ );
29+ assert.equal(
30+ duplicateIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
31+ "duplicate_message"
32+ );
33+
34 client.socket.send(
35 JSON.stringify({
36 type: "browser.final_message",
37@@ -1360,6 +1382,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
38 );
39 assert.ok(secondClient);
40 assert.equal(secondClient.final_messages.length, 2);
41+
42+ const secondIngestSnapshot = await client.queue.next(
43+ (message) =>
44+ message.type === "state_snapshot"
45+ && message.reason === "instruction_ingest"
46+ && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "synthetic_gemini_smoke"
47+ );
48+ assert.equal(
49+ secondIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
50+ "ignored_no_instructions"
51+ );
52 } finally {
53 client?.queue.stop();
54