baa-conductor

git clone 

commit
a02204e
parent
0874a72
author
im_wower
date
2026-03-28 18:28:17 +0800 CST
feat: wire artifact persistence into baa pipeline
11 files changed,  +625, -75
M apps/conductor-daemon/src/artifacts.test.js
+37, -15
 1@@ -2,12 +2,14 @@ import assert from "node:assert/strict";
 2 import test from "node:test";
 3 
 4 import {
 5-  DEFAULT_BAA_DELIVERY_LINE_LIMIT,
 6+  DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD,
 7+  DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH,
 8   renderBaaDeliveryMessageText
 9 } from "../dist/index.js";
10 
11 function createExecutionResult(overrides = {}) {
12   return {
13+    artifact: null,
14     data: null,
15     dedupeKey: "dedupe-default",
16     details: null,
17@@ -47,6 +49,20 @@ test("renderBaaDeliveryMessageText renders plain-text execution details", () =>
18       processResult: createProcessResult(
19         [
20           createExecutionResult({
21+            artifact: {
22+              resultText: JSON.stringify({
23+                data: {
24+                  a: 1,
25+                  b: 2
26+                },
27+                details: {
28+                  retryable: false
29+                },
30+                message: "command completed",
31+                ok: true
32+              }, null, 2),
33+              url: "https://conductor.makefile.so/artifact/exec/inst_render_01.txt"
34+            },
35             data: {
36               b: 2,
37               a: 1
38@@ -99,16 +115,16 @@ test("renderBaaDeliveryMessageText renders plain-text execution details", () =>
39   assert.equal(rendered.messageTruncated, false);
40   assert.match(rendered.messageText, /\[BAA 执行结果\]/u);
41   assert.match(rendered.messageText, /assistant_message_id: msg-render-plain/u);
42-  assert.match(rendered.messageText, /block_index: 3/u);
43-  assert.match(rendered.messageText, /message:\ncommand completed/u);
44-  assert.match(rendered.messageText, /data:\n  a: 1\n  b: 2/u);
45-  assert.match(rendered.messageText, /details:\n  retryable: false/u);
46+  assert.match(rendered.messageText, /instruction_id: inst_render_01/u);
47+  assert.match(rendered.messageText, /"retryable": false/u);
48+  assert.match(
49+    rendered.messageText,
50+    /完整结果:https:\/\/conductor\.makefile\.so\/artifact\/exec\/inst_render_01\.txt/u
51+  );
52 });
53 
54-test("renderBaaDeliveryMessageText truncates overlong output and appends marker", () => {
55-  const stdout = Array.from({
56-    length: DEFAULT_BAA_DELIVERY_LINE_LIMIT + 20
57-  }, (_, index) => `line-${index + 1}`).join("\n");
58+test("renderBaaDeliveryMessageText truncates overlong output and appends artifact URL", () => {
59+  const stdout = `${"A".repeat(DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD)}TRUNCATED_TAIL_MARKER`;
60   const rendered = renderBaaDeliveryMessageText(
61     {
62       assistantMessageId: "msg-render-truncated",
63@@ -116,6 +132,10 @@ test("renderBaaDeliveryMessageText truncates overlong output and appends marker"
64       platform: "claude",
65       processResult: createProcessResult([
66         createExecutionResult({
67+          artifact: {
68+            resultText: stdout,
69+            url: "https://conductor.makefile.so/artifact/exec/inst_render_truncated.txt"
70+          },
71           data: {
72             result: {
73               stdout
74@@ -133,11 +153,13 @@ test("renderBaaDeliveryMessageText truncates overlong output and appends marker"
75     }
76   );
77 
78-  assert.equal(rendered.messageLineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
79+  assert.equal(rendered.messageLineLimit, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD);
80   assert.equal(rendered.messageTruncated, true);
81-  assert.equal(rendered.messageLineCount, DEFAULT_BAA_DELIVERY_LINE_LIMIT + 1);
82-  assert.ok(rendered.sourceLineCount > rendered.messageLineCount);
83-  assert.match(rendered.messageText, /line-1/u);
84-  assert.doesNotMatch(rendered.messageText, /line-220/u);
85-  assert.match(rendered.messageText, /超长截断$/u);
86+  assert.ok(rendered.messageCharCount < stdout.length);
87+  assert.match(rendered.messageText, new RegExp(`A{${DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH}}`, "u"));
88+  assert.doesNotMatch(rendered.messageText, /TRUNCATED_TAIL_MARKER/u);
89+  assert.match(
90+    rendered.messageText,
91+    /完整结果:https:\/\/conductor\.makefile\.so\/artifact\/exec\/inst_render_truncated\.txt/u
92+  );
93 });
M apps/conductor-daemon/src/artifacts/upload-session.ts
+127, -22
  1@@ -1,5 +1,7 @@
  2+import { DEFAULT_SUMMARY_LENGTH } from "../../../../packages/artifact-db/dist/index.js";
  3 import type { BrowserBridgeController } from "../browser-types.js";
  4 import {
  5+  type BaaInstructionExecutionResult,
  6   sortBaaJsonValue,
  7   type BaaInstructionProcessResult,
  8   type BaaJsonValue
  9@@ -15,6 +17,8 @@ const DEFAULT_COMPLETED_SESSION_TTL_MS = 10 * 60_000;
 10 const DEFAULT_DELIVERY_ACTION_RESULT_TIMEOUT_MS = 20_000;
 11 const DEFAULT_DELIVERY_ACTION_TIMEOUT_MS = 15_000;
 12 export const DEFAULT_BAA_DELIVERY_LINE_LIMIT = 200;
 13+export const DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD = 2_000;
 14+export const DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH = DEFAULT_SUMMARY_LENGTH;
 15 const DEFAULT_DELIVERY_POLL_INTERVAL_MS = 150;
 16 const DEFAULT_DELIVERY_RETRY_ATTEMPTS = 2;
 17 const DEFAULT_DELIVERY_RETRY_DELAY_MS = 250;
 18@@ -37,9 +41,11 @@ interface BaaDeliverySessionRecord {
 19 
 20 export interface BaaBrowserDeliveryBridgeOptions {
 21   bridge: BrowserBridgeController;
 22+  inlineThreshold?: number | null;
 23   lineLimit?: number | null;
 24   now?: () => number;
 25   onChange?: (() => Promise<void> | void) | null;
 26+  summaryLength?: number | null;
 27 }
 28 
 29 export interface BaaBrowserDeliveryInput {
 30@@ -250,10 +256,100 @@ function buildExecutionSection(
 31   return lines;
 32 }
 33 
 34+function appendArtifactUrlLine(lines: string[], artifactUrl: string | null): void {
 35+  if (artifactUrl == null) {
 36+    return;
 37+  }
 38+
 39+  lines.push("");
 40+  lines.push(`完整结果:${artifactUrl}`);
 41+}
 42+
 43+function buildExecutionResultText(execution: BaaInstructionExecutionResult): string | null {
 44+  const artifactResultText = execution.artifact?.resultText;
 45+
 46+  if (typeof artifactResultText === "string" && artifactResultText.trim() !== "") {
 47+    return artifactResultText;
 48+  }
 49+  const payload: Record<string, BaaJsonValue> = {
 50+    http_status: execution.httpStatus,
 51+    ok: execution.ok,
 52+    route: {
 53+      key: execution.route.key,
 54+      method: execution.route.method,
 55+      path: execution.route.path
 56+    }
 57+  };
 58+
 59+  if (execution.data != null) {
 60+    payload.data = execution.data;
 61+  }
 62+
 63+  if (execution.details != null) {
 64+    payload.details = execution.details;
 65+  }
 66+
 67+  if (execution.error != null) {
 68+    payload.error = execution.error;
 69+  }
 70+
 71+  if (execution.message != null) {
 72+    payload.message = execution.message;
 73+  }
 74+
 75+  if (execution.requestId != null) {
 76+    payload.request_id = execution.requestId;
 77+  }
 78+
 79+  return JSON.stringify(payload, null, 2);
 80+}
 81+
 82+function buildRenderedExecutionSection(
 83+  execution: BaaInstructionExecutionResult,
 84+  executionIndex: number,
 85+  inlineThreshold: number,
 86+  summaryLength: number
 87+): {
 88+  sourceLines: string[];
 89+  truncated: boolean;
 90+  visibleLines: string[];
 91+} {
 92+  const fullResultText = buildExecutionResultText(execution);
 93+  const summaryText =
 94+    fullResultText != null && fullResultText.length > inlineThreshold
 95+      ? fullResultText.slice(0, summaryLength)
 96+      : fullResultText;
 97+  const truncated = fullResultText != null && summaryText !== fullResultText;
 98+  const sourceLines = [
 99+    `[执行 ${executionIndex + 1}]`,
100+    `instruction_id: ${execution.instructionId}`,
101+    `tool: ${execution.tool}`,
102+    `target: ${execution.target}`,
103+    `status: ${execution.ok ? "ok" : "error"}`,
104+    `http_status: ${String(execution.httpStatus)}`
105+  ];
106+  const visibleLines = [...sourceLines];
107+  const sourceResultText = fullResultText ?? "(empty)";
108+  const visibleResultText = summaryText ?? "(empty)";
109+
110+  appendSection(sourceLines, "result:", sourceResultText);
111+  appendSection(visibleLines, "result:", visibleResultText);
112+  appendArtifactUrlLine(sourceLines, execution.artifact?.url ?? null);
113+  appendArtifactUrlLine(visibleLines, execution.artifact?.url ?? null);
114+
115+  return {
116+    sourceLines,
117+    truncated,
118+    visibleLines
119+  };
120+}
121+
122 export function renderBaaDeliveryMessageText(
123   input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform" | "processResult">,
124   options: {
125+    inlineThreshold?: number | null;
126     lineLimit?: number | null;
127+    summaryLength?: number | null;
128   } = {}
129 ): BaaDeliveryMessageRenderResult {
130   const processResult = input.processResult;
131@@ -263,48 +359,51 @@ export function renderBaaDeliveryMessageText(
132       executionCount: 0,
133       messageCharCount: 0,
134       messageLineCount: 0,
135-      messageLineLimit: normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT),
136+      messageLineLimit: normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD),
137       messageText: "",
138       messageTruncated: false,
139       sourceLineCount: 0
140     };
141   }
142 
143-  const lineLimit = normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
144-  const lines = [
145+  const inlineThreshold = normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD);
146+  const summaryLength = normalizePositiveInteger(options.summaryLength, DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH);
147+  const sourceLines = [
148     "[BAA 执行结果]",
149     `assistant_message_id: ${input.assistantMessageId}`,
150     `platform: ${input.platform}`,
151     `conversation_id: ${input.conversationId ?? "-"}`,
152     `execution_count: ${String(processResult.executions.length)}`
153   ];
154-
155-  for (let index = 0; index < processResult.executions.length; index += 1) {
156-    lines.push("");
157-    lines.push(...buildExecutionSection(processResult, index));
158-  }
159-
160-  const sourceLines = normalizeDeliveryLines(lines.join("\n"));
161-  let visibleLines = sourceLines;
162+  const visibleLines = [...sourceLines];
163   let truncated = false;
164 
165-  if (sourceLines.length > lineLimit) {
166-    visibleLines = [
167-      ...sourceLines.slice(0, lineLimit),
168-      "超长截断"
169-    ];
170-    truncated = true;
171+  for (let index = 0; index < processResult.executions.length; index += 1) {
172+    const renderedSection = buildRenderedExecutionSection(
173+      processResult.executions[index]!,
174+      index,
175+      inlineThreshold,
176+      summaryLength
177+    );
178+
179+    sourceLines.push("");
180+    sourceLines.push(...renderedSection.sourceLines);
181+    visibleLines.push("");
182+    visibleLines.push(...renderedSection.visibleLines);
183+    truncated = truncated || renderedSection.truncated;
184   }
185 
186-  const messageText = visibleLines.join("\n");
187+  const normalizedSourceLines = normalizeDeliveryLines(sourceLines.join("\n"));
188+  const normalizedVisibleLines = normalizeDeliveryLines(visibleLines.join("\n"));
189+  const messageText = normalizedVisibleLines.join("\n");
190   return {
191     executionCount: processResult.executions.length,
192     messageCharCount: messageText.length,
193-    messageLineCount: visibleLines.length,
194-    messageLineLimit: lineLimit,
195+    messageLineCount: normalizedVisibleLines.length,
196+    messageLineLimit: inlineThreshold,
197     messageText,
198     messageTruncated: truncated,
199-    sourceLineCount: sourceLines.length
200+    sourceLineCount: normalizedSourceLines.length
201   };
202 }
203 
204@@ -358,18 +457,22 @@ function shouldFailClosedWithoutFallback(reason: string): boolean {
205 
206 export class BaaBrowserDeliveryBridge {
207   private readonly bridge: BrowserBridgeController;
208+  private readonly inlineThreshold: number;
209   private lastRoute: BaaDeliveryRouteSnapshot | null = null;
210   private lastSession: BaaDeliverySessionSnapshot | null = null;
211   private readonly lineLimit: number;
212   private readonly now: () => number;
213   private readonly onChange: (() => Promise<void> | void) | null;
214+  private readonly summaryLength: number;
215   private readonly sessions = new Map<string, BaaDeliverySessionRecord>();
216 
217   constructor(options: BaaBrowserDeliveryBridgeOptions) {
218     this.bridge = options.bridge;
219+    this.inlineThreshold = normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD);
220     this.lineLimit = normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
221     this.now = options.now ?? (() => Date.now());
222     this.onChange = options.onChange ?? null;
223+    this.summaryLength = normalizePositiveInteger(options.summaryLength, DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH);
224   }
225 
226   getSnapshot(): BaaDeliveryBridgeSnapshot {
227@@ -423,7 +526,9 @@ export class BaaBrowserDeliveryBridge {
228     }
229 
230     const rendered = renderBaaDeliveryMessageText(input, {
231-      lineLimit: this.lineLimit
232+      inlineThreshold: this.inlineThreshold,
233+      lineLimit: this.lineLimit,
234+      summaryLength: this.summaryLength
235     });
236 
237     if (rendered.messageText.trim() === "") {
M apps/conductor-daemon/src/firefox-ws.ts
+8, -1
 1@@ -45,6 +45,8 @@ type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
 2 type FirefoxWsAction = "pause" | "resume" | "drain";
 3 
 4 interface FirefoxWebSocketServerOptions {
 5+  artifactInlineThreshold?: number | null;
 6+  artifactSummaryLength?: number | null;
 7   baseUrlLoader: () => string;
 8   instructionIngest?: BaaLiveInstructionIngest | null;
 9   now?: () => number;
10@@ -1006,11 +1008,13 @@ export class ConductorFirefoxWebSocketServer {
11     });
12     this.bridgeService = new FirefoxBridgeService(commandBroker);
13     this.deliveryBridge = new BaaBrowserDeliveryBridge({
14+      inlineThreshold: options.artifactInlineThreshold,
15       bridge: this.bridgeService,
16       now: () => this.getNextTimestampMilliseconds(),
17       onChange: () => this.broadcastStateSnapshot("delivery_session", {
18         force: true
19-      })
20+      }),
21+      summaryLength: options.artifactSummaryLength
22     });
23   }
24 
25@@ -1631,6 +1635,9 @@ export class ConductorFirefoxWebSocketServer {
26       assistantMessageId: finalMessage.assistant_message_id,
27       conversationId: finalMessage.conversation_id,
28       observedAt: finalMessage.observed_at,
29+      organizationId: finalMessage.organization_id,
30+      pageTitle: finalMessage.page_title,
31+      pageUrl: finalMessage.page_url,
32       platform: finalMessage.platform,
33       source: "browser.final_message",
34       text: finalMessage.raw_text
M apps/conductor-daemon/src/index.test.js
+35, -2
 1@@ -4799,6 +4799,32 @@ test("ConductorRuntime routes browser.final_message into live instruction ingest
 2     );
 3     assert.equal(readFileSync(join(hostOpsDir, "final-message-ingest.txt"), "utf8"), "ws-live\n");
 4 
 5+    const persistedStore = new ArtifactStore({
 6+      artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
 7+      databasePath: join(stateDir, ARTIFACT_DB_FILENAME),
 8+      publicBaseUrl: "https://control.example.test"
 9+    });
10+
11+    try {
12+      const persistedMessage = await persistedStore.getMessage("msg-final-message-ingest");
13+      const persistedExecutions = await persistedStore.listExecutions({
14+        messageId: "msg-final-message-ingest"
15+      });
16+      const persistedSessions = await persistedStore.getLatestSessions(1);
17+
18+      assert.equal(persistedMessage?.rawText, messageText);
19+      assert.equal(existsSync(join(stateDir, ARTIFACTS_DIRNAME, "msg", "msg-final-message-ingest.txt")), true);
20+      assert.equal(persistedExecutions.length, 2);
21+      assert.ok(persistedExecutions.every((execution) =>
22+        existsSync(join(stateDir, ARTIFACTS_DIRNAME, execution.staticPath))
23+      ));
24+      assert.equal(persistedSessions[0]?.messageCount, 1);
25+      assert.equal(persistedSessions[0]?.executionCount, 2);
26+      assert.equal(existsSync(join(stateDir, ARTIFACTS_DIRNAME, "session", "latest.txt")), true);
27+    } finally {
28+      persistedStore.close();
29+    }
30+
31     const browserStatusResponse = await fetch(`${baseUrl}/v1/browser`);
32     assert.equal(browserStatusResponse.status, 200);
33     const browserStatusPayload = await browserStatusResponse.json();
34@@ -5077,8 +5103,15 @@ test("ConductorRuntime exposes proxy-delivery browser snapshots with routed busi
35       (message) => message.type === "browser.proxy_delivery"
36     );
37     assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
38-    assert.match(proxyDelivery.message_text, /line-1/u);
39-    assert.match(proxyDelivery.message_text, /超长截断$/u);
40+    assert.equal(
41+      proxyDelivery.message_text.includes("\"command\": \"i=1; while [ $i -le 260"),
42+      true
43+    );
44+    assert.match(
45+      proxyDelivery.message_text,
46+      /完整结果:https:\/\/control\.example\.test\/artifact\/exec\/[^ \n]+\.txt/u
47+    );
48+    assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
49     assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
50     assert.equal(proxyDelivery.target_tab_id, 71);
51 
M apps/conductor-daemon/src/index.ts
+54, -2
  1@@ -9,7 +9,8 @@ import { join } from "node:path";
  2 import {
  3   ARTIFACTS_DIRNAME,
  4   ARTIFACT_DB_FILENAME,
  5-  ArtifactStore
  6+  ArtifactStore,
  7+  DEFAULT_SUMMARY_LENGTH
  8 } from "../../../packages/artifact-db/dist/index.js";
  9 import {
 10   DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
 11@@ -24,6 +25,7 @@ import {
 12   ConductorFirefoxWebSocketServer,
 13   buildFirefoxWebSocketUrl
 14 } from "./firefox-ws.js";
 15+import { DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD } from "./artifacts/upload-session.js";
 16 import {
 17   BrowserRequestPolicyController,
 18   type BrowserRequestPolicyControllerOptions
 19@@ -154,6 +156,8 @@ export interface ConductorRuntimePaths {
 20 }
 21 
 22 export interface ConductorRuntimeConfig extends ConductorConfig {
 23+  artifactInlineThreshold?: number | null;
 24+  artifactSummaryLength?: number | null;
 25   codexdLocalApiBase?: string | null;
 26   localApiAllowedHosts?: readonly string[] | string | null;
 27   localApiBase?: string | null;
 28@@ -163,6 +167,8 @@ export interface ConductorRuntimeConfig extends ConductorConfig {
 29 
 30 export interface ResolvedConductorRuntimeConfig
 31   extends Omit<ConductorConfig, "controlApiBase" | "publicApiBase"> {
 32+  artifactInlineThreshold: number;
 33+  artifactSummaryLength: number;
 34   controlApiBase: string;
 35   heartbeatIntervalMs: number;
 36   leaseRenewIntervalMs: number;
 37@@ -346,6 +352,8 @@ interface LocalApiListenConfig {
 38 }
 39 
 40 interface CliValueOverrides {
 41+  artifactInlineThreshold?: string;
 42+  artifactSummaryLength?: string;
 43   codexdLocalApiBase?: string;
 44   controlApiBase?: string;
 45   heartbeatIntervalMs?: string;
 46@@ -724,6 +732,8 @@ class ConductorLocalHttpServer {
 47     sharedToken: string | null,
 48     version: string | null,
 49     now: () => number,
 50+    artifactInlineThreshold: number,
 51+    artifactSummaryLength: number,
 52     browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {}
 53   ) {
 54     this.artifactStore = artifactStore;
 55@@ -739,6 +749,7 @@ class ConductorLocalHttpServer {
 56     this.resolvedBaseUrl = localApiBase;
 57     const nowMs = () => this.now() * 1000;
 58     const localApiContext = {
 59+      artifactStore: this.artifactStore,
 60       fetchImpl: this.fetchImpl,
 61       now: this.now,
 62       repository: this.repository,
 63@@ -764,6 +775,8 @@ class ConductorLocalHttpServer {
 64     });
 65     this.instructionIngest = instructionIngest;
 66     this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
 67+      artifactInlineThreshold,
 68+      artifactSummaryLength,
 69       baseUrlLoader: () => this.resolvedBaseUrl,
 70       instructionIngest,
 71       now: this.now,
 72@@ -1621,6 +1634,8 @@ export function resolveConductorRuntimeConfig(
 73   const localApiAllowedHosts = parseLocalApiAllowedHosts(config.localApiAllowedHosts);
 74   const leaseTtlSec = config.leaseTtlSec ?? DEFAULT_LEASE_TTL_SEC;
 75   const renewFailureThreshold = config.renewFailureThreshold ?? DEFAULT_RENEW_FAILURE_THRESHOLD;
 76+  const artifactInlineThreshold = config.artifactInlineThreshold ?? DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD;
 77+  const artifactSummaryLength = config.artifactSummaryLength ?? DEFAULT_SUMMARY_LENGTH;
 78   const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
 79 
 80   if (heartbeatIntervalMs <= 0) {
 81@@ -1639,8 +1654,18 @@ export function resolveConductorRuntimeConfig(
 82     throw new Error("Conductor renewFailureThreshold must be > 0.");
 83   }
 84 
 85+  if (!Number.isInteger(artifactInlineThreshold) || artifactInlineThreshold <= 0) {
 86+    throw new Error("Conductor artifactInlineThreshold must be a positive integer.");
 87+  }
 88+
 89+  if (!Number.isInteger(artifactSummaryLength) || artifactSummaryLength <= 0) {
 90+    throw new Error("Conductor artifactSummaryLength must be a positive integer.");
 91+  }
 92+
 93   return {
 94     ...config,
 95+    artifactInlineThreshold,
 96+    artifactSummaryLength,
 97     nodeId,
 98     host,
 99     role: parseConductorRole("Conductor role", config.role),
100@@ -1700,6 +1725,16 @@ function resolveRuntimeConfigFromSources(
101     role,
102     publicApiBase,
103     controlApiBase: publicApiBase,
104+    artifactInlineThreshold: parseIntegerValue(
105+      "Conductor artifact inline threshold",
106+      overrides.artifactInlineThreshold ?? env.BAA_ARTIFACT_INLINE_THRESHOLD,
107+      { minimum: 1 }
108+    ),
109+    artifactSummaryLength: parseIntegerValue(
110+      "Conductor artifact summary length",
111+      overrides.artifactSummaryLength ?? env.BAA_ARTIFACT_SUMMARY_LENGTH,
112+      { minimum: 1 }
113+    ),
114     priority: parseIntegerValue("Conductor priority", overrides.priority ?? env.BAA_CONDUCTOR_PRIORITY, {
115       minimum: 0
116     }),
117@@ -1819,6 +1854,14 @@ export function parseConductorCliRequest(
118         overrides.sharedToken = readOptionValue(tokens, token, index);
119         index += 1;
120         break;
121+      case "--artifact-inline-threshold":
122+        overrides.artifactInlineThreshold = readOptionValue(tokens, token, index);
123+        index += 1;
124+        break;
125+      case "--artifact-summary-length":
126+        overrides.artifactSummaryLength = readOptionValue(tokens, token, index);
127+        index += 1;
128+        break;
129       case "--priority":
130         overrides.priority = readOptionValue(tokens, token, index);
131         index += 1;
132@@ -1952,6 +1995,8 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
133     `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
134     `priority: ${config.priority}`,
135     `preferred: ${String(config.preferred)}`,
136+    `artifact_inline_threshold: ${config.artifactInlineThreshold}`,
137+    `artifact_summary_length: ${config.artifactSummaryLength}`,
138     `heartbeat_interval_ms: ${config.heartbeatIntervalMs}`,
139     `lease_renew_interval_ms: ${config.leaseRenewIntervalMs}`,
140     `lease_ttl_sec: ${config.leaseTtlSec}`,
141@@ -1995,6 +2040,8 @@ function getUsageText(): string {
142     "  --codexd-local-api <url>",
143     "  --local-api <url>",
144     "  --shared-token <token>",
145+    "  --artifact-inline-threshold <integer>",
146+    "  --artifact-summary-length <integer>",
147     "  --priority <integer>",
148     "  --version <string>",
149     "  --preferred | --no-preferred",
150@@ -2021,6 +2068,8 @@ function getUsageText(): string {
151     "  BAA_CONDUCTOR_LOCAL_API",
152     "  BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
153     "  BAA_SHARED_TOKEN",
154+    "  BAA_ARTIFACT_INLINE_THRESHOLD",
155+    "  BAA_ARTIFACT_SUMMARY_LENGTH",
156     "  BAA_CONDUCTOR_PRIORITY",
157     "  BAA_CONDUCTOR_VERSION",
158     "  BAA_CONDUCTOR_PREFERRED",
159@@ -2065,7 +2114,8 @@ export class ConductorRuntime {
160     this.artifactStore = new ArtifactStore({
161       artifactDir: join(resolvedStateDir, ARTIFACTS_DIRNAME),
162       databasePath: join(resolvedStateDir, ARTIFACT_DB_FILENAME),
163-      publicBaseUrl: this.config.publicApiBase
164+      publicBaseUrl: this.config.publicApiBase,
165+      summaryLength: this.config.artifactSummaryLength
166     });
167     this.daemon = new ConductorDaemon(this.config, {
168       ...options,
169@@ -2087,6 +2137,8 @@ export class ConductorRuntime {
170             this.config.sharedToken,
171             this.config.version,
172             this.now,
173+            this.config.artifactInlineThreshold,
174+            this.config.artifactSummaryLength,
175             options.browserRequestPolicyOptions
176           );
177   }
M apps/conductor-daemon/src/instructions/executor.ts
+135, -8
  1@@ -1,3 +1,4 @@
  2+import { buildArtifactPublicUrl } from "../../../../packages/artifact-db/dist/index.js";
  3 import {
  4   handleConductorHttpRequest,
  5   type ConductorLocalApiContext
  6@@ -6,6 +7,7 @@ import type {
  7   BaaInstructionEnvelope,
  8   BaaInstructionExecutionResult,
  9   BaaInstructionRoute,
 10+  BaaJsonObject,
 11   BaaJsonValue
 12 } from "./types.js";
 13 import { isBaaJsonValue } from "./types.js";
 14@@ -18,6 +20,7 @@ function toExecutionFailure(
 15   details: BaaJsonValue | null = null
 16 ): BaaInstructionExecutionResult {
 17   return {
 18+    artifact: null,
 19     data: null,
 20     dedupeKey: instruction.dedupeKey,
 21     details,
 22@@ -41,6 +44,125 @@ function normalizeJsonBodyValue(value: unknown): BaaJsonValue | null {
 23   return isBaaJsonValue(value) ? value : null;
 24 }
 25 
 26+function normalizeOptionalString(value: string | null | undefined): string | null {
 27+  if (typeof value !== "string") {
 28+    return null;
 29+  }
 30+
 31+  const normalized = value.trim();
 32+  return normalized === "" ? null : normalized;
 33+}
 34+
 35+function stringifyArtifactValue(value: BaaJsonValue | null): string | null {
 36+  if (value == null) {
 37+    return null;
 38+  }
 39+
 40+  if (typeof value === "string") {
 41+    return value.trim() === "" ? null : value;
 42+  }
 43+
 44+  return JSON.stringify(value, null, 2);
 45+}
 46+
 47+function buildExecutionArtifactPayload(result: BaaInstructionExecutionResult): BaaJsonObject {
 48+  const payload: BaaJsonObject = {
 49+    http_status: result.httpStatus,
 50+    ok: result.ok,
 51+    route: {
 52+      key: result.route.key,
 53+      method: result.route.method,
 54+      path: result.route.path
 55+    }
 56+  };
 57+
 58+  if (result.data != null) {
 59+    payload.data = result.data;
 60+  }
 61+
 62+  if (result.details != null) {
 63+    payload.details = result.details;
 64+  }
 65+
 66+  if (result.error != null) {
 67+    payload.error = result.error;
 68+  }
 69+
 70+  if (result.message != null) {
 71+    payload.message = result.message;
 72+  }
 73+
 74+  if (result.requestId != null) {
 75+    payload.request_id = result.requestId;
 76+  }
 77+
 78+  return payload;
 79+}
 80+
 81+function buildExecutionArtifactError(result: BaaInstructionExecutionResult): string | null {
 82+  const values = [normalizeOptionalString(result.message), normalizeOptionalString(result.error)]
 83+    .filter((value, index, all): value is string => value != null && all.indexOf(value) === index);
 84+
 85+  return values.length === 0 ? null : values.join("\n");
 86+}
 87+
 88+function logArtifactPersistenceFailure(instructionId: string, error: unknown): void {
 89+  const message = error instanceof Error ? error.stack ?? error.message : String(error);
 90+  console.error(`[artifact] failed to persist execution ${instructionId}: ${message}`);
 91+}
 92+
 93+async function withExecutionArtifact(
 94+  result: BaaInstructionExecutionResult,
 95+  instruction: BaaInstructionEnvelope,
 96+  context: ConductorLocalApiContext
 97+): Promise<BaaInstructionExecutionResult> {
 98+  const resultData = buildExecutionArtifactPayload(result);
 99+  const resultError = buildExecutionArtifactError(result);
100+  const fallbackArtifact = {
101+    resultText: stringifyArtifactValue(resultData) ?? resultError,
102+    url: null
103+  };
104+  const artifactStore = context.artifactStore ?? null;
105+
106+  if (artifactStore == null) {
107+    return {
108+      ...result,
109+      artifact: fallbackArtifact
110+    };
111+  }
112+
113+  try {
114+    const record = await artifactStore.insertExecution({
115+      executedAt: Date.now(),
116+      httpStatus: result.httpStatus,
117+      instructionId: result.instructionId,
118+      messageId: instruction.assistantMessageId,
119+      params: instruction.params,
120+      paramsKind: instruction.paramsKind,
121+      resultData,
122+      resultError,
123+      resultOk: result.ok,
124+      target: instruction.target,
125+      tool: instruction.tool
126+    });
127+
128+    return {
129+      ...result,
130+      artifact: {
131+        resultText: record.resultData ?? record.resultError,
132+        url: buildArtifactPublicUrl(artifactStore.getPublicBaseUrl(), record.staticPath)
133+      }
134+    };
135+  } catch (error) {
136+    logArtifactPersistenceFailure(result.instructionId, error);
137+
138+    return {
139+      ...result,
140+      artifact: fallbackArtifact
141+    };
142+  }
143+}
144+
145 export async function executeBaaInstruction(
146   instruction: BaaInstructionEnvelope,
147   route: BaaInstructionRoute,
148@@ -75,12 +197,16 @@ export async function executeBaaInstruction(
149       parsedBody = responseText.trim() === "" ? null : JSON.parse(responseText);
150     } catch (error) {
151       const message = error instanceof Error ? error.message : String(error);
152-      return toExecutionFailure(
153+      return withExecutionArtifact(
154+        toExecutionFailure(
155+          instruction,
156+          route,
157+          `Failed to parse local API response JSON: ${message}`,
158+          "invalid_local_api_response",
159+          normalizeJsonBodyValue(responseText)
160+        ),
161         instruction,
162-        route,
163-        `Failed to parse local API response JSON: ${message}`,
164-        "invalid_local_api_response",
165-        normalizeJsonBodyValue(responseText)
166+        context
167       );
168     }
169 
170@@ -89,7 +215,8 @@ export async function executeBaaInstruction(
171         ? (parsedBody as Record<string, unknown>)
172         : null;
173 
174-    return {
175+    return withExecutionArtifact({
176+      artifact: null,
177       data: payload?.ok === true ? normalizeJsonBodyValue(payload.data) : null,
178       dedupeKey: instruction.dedupeKey,
179       details: normalizeJsonBodyValue(payload?.details),
180@@ -106,9 +233,9 @@ export async function executeBaaInstruction(
181       },
182       target: instruction.target,
183       tool: instruction.tool
184-    };
185+    }, instruction, context);
186   } catch (error) {
187     const message = error instanceof Error ? error.message : String(error);
188-    return toExecutionFailure(instruction, route, message);
189+    return withExecutionArtifact(toExecutionFailure(instruction, route, message), instruction, context);
190   }
191 }
M apps/conductor-daemon/src/instructions/ingest.ts
+44, -0
 1@@ -1,4 +1,5 @@
 2 import { createHash } from "node:crypto";
 3+import type { ArtifactStore } from "../../../../packages/artifact-db/dist/index.js";
 4 
 5 import type { ConductorLocalApiContext } from "../local-api.js";
 6 
 7@@ -23,6 +24,9 @@ export interface BaaLiveInstructionIngestInput {
 8   assistantMessageId: string;
 9   conversationId?: string | null;
10   observedAt?: number | null;
11+  organizationId?: string | null;
12+  pageTitle?: string | null;
13+  pageUrl?: string | null;
14   platform: string;
15   source: "browser.final_message";
16   text: string;
17@@ -172,6 +176,13 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
18   return normalized === "" ? null : normalized;
19 }
20 
21+function isDuplicateArtifactMessageError(error: unknown): boolean {
22+  return (
23+    error instanceof Error
24+    && error.message.includes("UNIQUE constraint failed: messages.id")
25+  );
26+}
27+
28 function cloneSummary(summary: BaaLiveInstructionIngestSummary): BaaLiveInstructionIngestSummary {
29   return {
30     ...summary,
31@@ -214,6 +225,7 @@ export function buildBaaLiveInstructionMessageDedupeKey(input: {
32 }
33 
34 export class BaaLiveInstructionIngest {
35+  private readonly artifactStore: ArtifactStore | null;
36   private readonly center: BaaInstructionCenter;
37   private readonly historyLimit: number;
38   private readonly messageDeduper: BaaLiveInstructionMessageDeduper;
39@@ -236,6 +248,7 @@ export class BaaLiveInstructionIngest {
40       ?? new BaaInstructionCenter({
41         localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"]
42       });
43+    this.artifactStore = options.localApiContext?.artifactStore ?? null;
44     this.historyLimit = normalizeHistoryLimit(options.historyLimit);
45     this.messageDeduper = options.messageDeduper ?? new InMemoryBaaLiveInstructionMessageDeduper();
46     this.now = options.now ?? Date.now;
47@@ -267,6 +280,7 @@ export class BaaLiveInstructionIngest {
48     input: BaaLiveInstructionIngestInput
49   ): Promise<BaaLiveInstructionIngestResult> {
50     await this.initialize();
51+    await this.persistMessageArtifact(input);
52 
53     const messageDedupeKey = buildBaaLiveInstructionMessageDedupeKey({
54       assistantMessageId: input.assistantMessageId,
55@@ -348,6 +362,36 @@ export class BaaLiveInstructionIngest {
56     }
57   }
58 
59+  private async persistMessageArtifact(input: BaaLiveInstructionIngestInput): Promise<void> {
60+    if (this.artifactStore == null) {
61+      return;
62+    }
63+
64+    try {
65+      await this.artifactStore.insertMessage({
66+        conversationId: normalizeOptionalString(input.conversationId),
67+        id: input.assistantMessageId,
68+        observedAt:
69+          typeof input.observedAt === "number" && Number.isFinite(input.observedAt)
70+            ? input.observedAt
71+            : this.now(),
72+        organizationId: normalizeOptionalString(input.organizationId),
73+        pageTitle: normalizeOptionalString(input.pageTitle),
74+        pageUrl: normalizeOptionalString(input.pageUrl),
75+        platform: input.platform,
76+        rawText: input.text,
77+        role: "assistant"
78+      });
79+    } catch (error) {
80+      if (isDuplicateArtifactMessageError(error)) {
81+        return;
82+      }
83+
84+      const message = error instanceof Error ? error.stack ?? error.message : String(error);
85+      console.error(`[artifact] failed to persist message ${input.assistantMessageId}: ${message}`);
86+    }
87+  }
88+
89   private async loadPersistedSnapshot(): Promise<void> {
90     if (this.snapshotStore == null) {
91       return;
M apps/conductor-daemon/src/instructions/types.ts
+4, -0
 1@@ -78,6 +78,10 @@ export interface BaaInstructionDeniedResult {
 2 }
 3 
 4 export interface BaaInstructionExecutionResult {
 5+  artifact: {
 6+    resultText: string | null;
 7+    url: string | null;
 8+  } | null;
 9   data: BaaJsonValue | null;
10   dedupeKey: string;
11   details: BaaJsonValue | null;
M packages/artifact-db/src/index.test.js
+13, -17
 1@@ -54,24 +54,21 @@ test("ArtifactStore writes message, execution, session, and index artifacts sync
 2       target: "conductor",
 3       tool: "exec"
 4     });
 5-    const session = await store.upsertSession({
 6-      conversationId: "conv_123",
 7-      executionCount: 1,
 8-      id: "session_123",
 9-      lastActivityAt: Date.UTC(2026, 2, 28, 8, 10, 0),
10-      messageCount: 1,
11-      platform: "claude",
12-      startedAt: Date.UTC(2026, 2, 28, 8, 9, 0),
13-      summary: "会话摘要"
14-    });
15+    const [session] = await store.getLatestSessions(1);
16+
17+    assert.ok(session);
18+    assert.equal(session.conversationId, "conv_123");
19+    assert.equal(session.executionCount, 1);
20+    assert.equal(session.messageCount, 1);
21+    assert.equal(session.platform, "claude");
22 
23     assert.equal(existsSync(databasePath), true);
24     assert.equal(existsSync(join(artifactDir, "msg", "msg_123.txt")), true);
25     assert.equal(existsSync(join(artifactDir, "msg", "msg_123.json")), true);
26     assert.equal(existsSync(join(artifactDir, "exec", "inst_123.txt")), true);
27     assert.equal(existsSync(join(artifactDir, "exec", "inst_123.json")), true);
28-    assert.equal(existsSync(join(artifactDir, "session", "session_123.txt")), true);
29-    assert.equal(existsSync(join(artifactDir, "session", "session_123.json")), true);
30+    assert.equal(existsSync(join(artifactDir, session.staticPath)), true);
31+    assert.equal(existsSync(join(artifactDir, session.staticPath.replace(/\.txt$/u, ".json"))), true);
32     assert.equal(existsSync(join(artifactDir, "session", "latest.txt")), true);
33     assert.equal(existsSync(join(artifactDir, "session", "latest.json")), true);
34 
35@@ -83,17 +80,16 @@ test("ArtifactStore writes message, execution, session, and index artifacts sync
36     );
37     assert.match(readFileSync(join(artifactDir, "exec", "inst_123.txt"), "utf8"), /params:/u);
38     assert.match(readFileSync(join(artifactDir, "exec", "inst_123.txt"), "utf8"), /pnpm test/u);
39-    assert.match(readFileSync(join(artifactDir, "session", "session_123.txt"), "utf8"), /### message msg_123/u);
40-    assert.match(readFileSync(join(artifactDir, "session", "session_123.txt"), "utf8"), /### execution inst_123/u);
41-    assert.match(readFileSync(join(artifactDir, "session", "latest.txt"), "utf8"), /session_123/u);
42+    assert.match(readFileSync(join(artifactDir, session.staticPath), "utf8"), /### message msg_123/u);
43+    assert.match(readFileSync(join(artifactDir, session.staticPath), "utf8"), /### execution inst_123/u);
44+    assert.match(readFileSync(join(artifactDir, "session", "latest.txt"), "utf8"), new RegExp(session.id, "u"));
45     assert.match(
46       readFileSync(join(artifactDir, "session", "latest.json"), "utf8"),
47-      /https:\/\/conductor\.makefile\.so\/artifact\/session\/session_123\.txt/u
48+      new RegExp(`https://conductor\\.makefile\\.so/artifact/session/${session.id}\\.txt`, "u")
49     );
50 
51     assert.deepEqual(await store.getMessage(message.id), message);
52     assert.deepEqual(await store.getExecution(execution.instructionId), execution);
53-    assert.deepEqual(await store.getLatestSessions(1), [session]);
54     assert.deepEqual(await store.listMessages({ conversationId: "conv_123" }), [message]);
55     assert.deepEqual(await store.listExecutions({ messageId: message.id }), [execution]);
56     assert.deepEqual(await store.listSessions({ platform: "claude" }), [session]);
M packages/artifact-db/src/store.ts
+148, -5
  1@@ -173,6 +173,10 @@ interface LatestMessageRow {
  2   static_path: string;
  3 }
  4 
  5+interface CountRow {
  6+  count: number;
  7+}
  8+
  9 export class ArtifactStore {
 10   private readonly artifactDir: string;
 11   private readonly db: DatabaseSync;
 12@@ -216,6 +220,10 @@ export class ArtifactStore {
 13     return this.artifactDir;
 14   }
 15 
 16+  getPublicBaseUrl(): string | null {
 17+    return this.publicBaseUrl;
 18+  }
 19+
 20   async getExecution(instructionId: string): Promise<ExecutionRecord | null> {
 21     const row = this.getRow<ExecutionRow>(
 22       "SELECT * FROM executions WHERE instruction_id = ? LIMIT 1;",
 23@@ -256,6 +264,17 @@ export class ArtifactStore {
 24         buildExecutionArtifactFiles(record, this.renderConfig(), message.staticPath),
 25         mutations
 26       );
 27+      this.writeSessionArtifacts(
 28+        this.buildDerivedSessionRecord({
 29+          conversationId: message.conversationId,
 30+          createdAtCandidate: message.createdAt,
 31+          lastActivityAt: record.executedAt,
 32+          platform: message.platform,
 33+          startedAtCandidate: message.observedAt,
 34+          summaryCandidate: record.resultSummary ?? message.summary
 35+        }),
 36+        mutations
 37+      );
 38       this.writeArtifactFiles(
 39         buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
 40         mutations
 41@@ -271,6 +290,17 @@ export class ArtifactStore {
 42     this.executeWrite((mutations) => {
 43       this.run(INSERT_MESSAGE_SQL, messageParams(record));
 44       this.writeArtifactFiles(buildMessageArtifactFiles(record, this.renderConfig()), mutations);
 45+      this.writeSessionArtifacts(
 46+        this.buildDerivedSessionRecord({
 47+          conversationId: record.conversationId,
 48+          createdAtCandidate: record.createdAt,
 49+          lastActivityAt: record.observedAt,
 50+          platform: record.platform,
 51+          startedAtCandidate: record.observedAt,
 52+          summaryCandidate: record.summary
 53+        }),
 54+        mutations
 55+      );
 56       this.writeArtifactFiles(
 57         buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
 58         mutations
 59@@ -354,11 +384,7 @@ export class ArtifactStore {
 60     const record = buildSessionRecord(input, this.summaryLength);
 61 
 62     this.executeWrite((mutations) => {
 63-      this.run(UPSERT_SESSION_SQL, sessionParams(record));
 64-      this.writeArtifactFiles(
 65-        buildSessionArtifactFiles(record, this.readSessionTimeline(record), this.renderConfig()),
 66-        mutations
 67-      );
 68+      this.writeSessionArtifacts(record, mutations);
 69       this.writeArtifactFiles(
 70         buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
 71         mutations
 72@@ -408,6 +434,81 @@ export class ArtifactStore {
 73     );
 74   }
 75 
 76+  private buildDerivedSessionRecord(input: {
 77+    conversationId: string | null;
 78+    createdAtCandidate: number;
 79+    lastActivityAt: number;
 80+    platform: string;
 81+    startedAtCandidate: number;
 82+    summaryCandidate: string | null;
 83+  }): SessionRecord {
 84+    const id = buildSessionId(input.platform, input.conversationId);
 85+    const existing = this.getRow<SessionRow>("SELECT * FROM sessions WHERE id = ? LIMIT 1;", id);
 86+    const existingRecord = existing == null ? null : mapSessionRow(existing);
 87+    const messageCount = this.countMessages(input.platform, input.conversationId);
 88+    const executionCount = this.countExecutions(input.platform, input.conversationId);
 89+
 90+    return buildSessionRecord(
 91+      {
 92+        conversationId: input.conversationId,
 93+        createdAt: existingRecord?.createdAt ?? input.createdAtCandidate,
 94+        executionCount,
 95+        id,
 96+        lastActivityAt:
 97+          existingRecord == null
 98+            ? input.lastActivityAt
 99+            : Math.max(existingRecord.lastActivityAt, input.lastActivityAt),
100+        messageCount,
101+        platform: input.platform,
102+        startedAt:
103+          existingRecord == null
104+            ? input.startedAtCandidate
105+            : Math.min(existingRecord.startedAt, input.startedAtCandidate),
106+        summary: resolveSessionSummary(existingRecord, input.summaryCandidate, input.lastActivityAt)
107+      },
108+      this.summaryLength
109+    );
110+  }
111+
112+  private countExecutions(platform: string, conversationId: string | null): number {
113+    const { clause, params } = buildConversationFilters(platform, conversationId);
114+    const executionClause =
115+      clause === ""
116+        ? ""
117+        : `WHERE ${clause
118+          .replaceAll("platform", "m.platform")
119+          .replaceAll("conversation_id", "m.conversation_id")}`;
120+    const row = this.getRow<CountRow>(
121+      [
122+        "SELECT COUNT(*) AS count",
123+        "FROM executions e",
124+        "INNER JOIN messages m ON m.id = e.message_id",
125+        executionClause
126+      ]
127+        .filter(Boolean)
128+        .join(" "),
129+      ...params
130+    );
131+
132+    return row?.count ?? 0;
133+  }
134+
135+  private countMessages(platform: string, conversationId: string | null): number {
136+    const { clause, params } = buildConversationFilters(platform, conversationId);
137+    const row = this.getRow<CountRow>(
138+      [
139+        "SELECT COUNT(*) AS count",
140+        "FROM messages",
141+        clause === "" ? "" : `WHERE ${clause}`
142+      ]
143+        .filter(Boolean)
144+        .join(" "),
145+      ...params
146+    );
147+
148+    return row?.count ?? 0;
149+  }
150+
151   private readSessionIndexEntries(limit: number): SessionIndexEntry[] {
152     const sessions = this.getRows<SessionRow>(
153       `
154@@ -500,6 +601,14 @@ export class ArtifactStore {
155     };
156   }
157 
158+  private writeSessionArtifacts(record: SessionRecord, mutations: FileMutation[]): void {
159+    this.run(UPSERT_SESSION_SQL, sessionParams(record));
160+    this.writeArtifactFiles(
161+      buildSessionArtifactFiles(record, this.readSessionTimeline(record), this.renderConfig()),
162+      mutations
163+    );
164+  }
165+
166   private restoreFiles(mutations: FileMutation[]): void {
167     for (let index = mutations.length - 1; index >= 0; index -= 1) {
168       const mutation = mutations[index];
169@@ -838,3 +947,37 @@ function summarizeText(value: string, summaryLength: number): string | null {
170 
171   return normalized.slice(0, summaryLength);
172 }
173+
174+function buildSessionId(platform: string, conversationId: string | null): string {
175+  const key = `${normalizeRequiredString(platform, "platform")}\u0000${conversationId ?? ""}`;
176+  return `session_${buildStableHash(key)}${buildStableHash(`${key}\u0001session`)}`;
177+}
178+
179+function resolveSessionSummary(
180+  existing: SessionRecord | null,
181+  summaryCandidate: string | null,
182+  activityAt: number
183+): string | null {
184+  const normalizedCandidate = normalizeOptionalString(summaryCandidate);
185+
186+  if (existing == null) {
187+    return normalizedCandidate;
188+  }
189+
190+  if (activityAt >= existing.lastActivityAt) {
191+    return normalizedCandidate ?? existing.summary;
192+  }
193+
194+  return existing.summary;
195+}
196+
197+function buildStableHash(value: string): string {
198+  let hash = 2_166_136_261;
199+
200+  for (let index = 0; index < value.length; index += 1) {
201+    hash ^= value.charCodeAt(index);
202+    hash = Math.imul(hash, 16_777_619);
203+  }
204+
205+  return (hash >>> 0).toString(16).padStart(8, "0");
206+}
M tasks/T-S040.md
+20, -3
 1@@ -145,17 +145,33 @@ T-S039 建好了数据库和静态文件生成能力,但还没有接入实际
 2 
 3 ### 开始执行
 4 
 5-- 执行者:
 6-- 开始时间:
 7+- 执行者:`Codex`
 8+- 开始时间:`2026-03-28 17:35:00 +0800`
 9 - 状态变更:`待开始` → `进行中`
10 
11 ### 完成摘要
12 
13-- 完成时间:
14+- 完成时间:`2026-03-28 18:27:35 +0800`
15 - 状态变更:`进行中` → `已完成`
16 - 修改了哪些文件:
17+  - `packages/artifact-db/src/store.ts`
18+  - `packages/artifact-db/src/index.test.js`
19+  - `apps/conductor-daemon/src/instructions/ingest.ts`
20+  - `apps/conductor-daemon/src/instructions/executor.ts`
21+  - `apps/conductor-daemon/src/instructions/types.ts`
22+  - `apps/conductor-daemon/src/artifacts/upload-session.ts`
23+  - `apps/conductor-daemon/src/firefox-ws.ts`
24+  - `apps/conductor-daemon/src/index.ts`
25+  - `apps/conductor-daemon/src/artifacts.test.js`
26+  - `apps/conductor-daemon/src/index.test.js`
27+  - `tasks/T-S040.md`
28 - 核心实现思路:
29+  - 在 `ArtifactStore` 内部把 `insertMessage()` / `insertExecution()` 扩展为自动维护 session 记录、timeline 静态文件和 `session/latest.txt`,避免上层链路重复拼 session 逻辑。
30+  - `browser.final_message` 进入 ingest 时先 best-effort 写入 messages 表与静态文件,失败只记录日志;指令执行结束后在 executor 中写入 executions 表,并把 artifact URL / 完整结果文本挂回执行结果。
31+  - delivery 渲染从“按行截断”改为“按字符阈值 + exact URL”:短结果内联全文并附 URL,长结果截断前 `summaryLength` 字符并附 `完整结果:{url}`;阈值通过 conductor config / 环境变量注入。
32 - 跑了哪些测试:
33+  - `pnpm -C /Users/george/code/baa-conductor-artifact-pipeline -F @baa-conductor/artifact-db test`
34+  - `pnpm -C /Users/george/code/baa-conductor-artifact-pipeline -F @baa-conductor/conductor-daemon test`
35 
36 ### 执行过程中遇到的问题
37 
38@@ -163,3 +179,4 @@ T-S039 建好了数据库和静态文件生成能力,但还没有接入实际
39 
40 ### 剩余风险
41 
42+- artifact URL 依赖 `publicApiBase` / `BAA_CONDUCTOR_PUBLIC_API_BASE` 配置正确;如果公网域名或反代未同步,AI 能拿到 URL,但外部访问仍会失败。