baa-conductor

git clone 

commit
03da12e
parent
ecbf6b1
author
im_wower
date
2026-03-28 18:08:23 +0800 CST
feat: add artifact-db package with static file generation and HTTP routes

- New packages/artifact-db: SQLite store for messages, executions, sessions
- Schema with 3 tables, 6 indexes, foreign key constraints
- Synchronous dual-format static file generation (.txt frontmatter + .json)
- Transaction safety with file rollback on failure
- /artifact/:scope/:file route with Content-Type handling
- /robots.txt route (Allow: /artifact/)
- Path security validation preventing traversal
- Full test coverage (53/53 passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 files changed,  +1999, -7
M apps/conductor-daemon/package.json
+3, -2
 1@@ -4,14 +4,15 @@
 2   "type": "module",
 3   "main": "dist/index.js",
 4   "dependencies": {
 5+    "@baa-conductor/artifact-db": "workspace:*",
 6     "@baa-conductor/db": "workspace:*",
 7     "@baa-conductor/host-ops": "workspace:*"
 8   },
 9   "scripts": {
10-    "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm exec tsc -p tsconfig.json",
11+    "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm exec tsc -p tsconfig.json",
12     "dev": "pnpm run build && node dist/index.js",
13     "start": "node dist/index.js",
14     "test": "pnpm run build && node --test src/index.test.js",
15-    "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm exec tsc --noEmit -p tsconfig.json"
16+    "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm exec tsc --noEmit -p tsconfig.json"
17   }
18 }
M apps/conductor-daemon/src/index.test.js
+128, -0
  1@@ -7,6 +7,11 @@ import { homedir, tmpdir } from "node:os";
  2 import { join } from "node:path";
  3 import test from "node:test";
  4 
  5+import {
  6+  ARTIFACTS_DIRNAME,
  7+  ARTIFACT_DB_FILENAME,
  8+  ArtifactStore
  9+} from "../../../packages/artifact-db/dist/index.js";
 10 import "./artifacts.test.js";
 11 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
 12 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
 13@@ -519,6 +524,28 @@ async function startCodexdStubServer() {
 14   };
 15 }
 16 
 17+async function withArtifactStoreFixture(callback) {
 18+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifact-store-"));
 19+  const artifactStore = new ArtifactStore({
 20+    artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
 21+    databasePath: join(stateDir, ARTIFACT_DB_FILENAME),
 22+    publicBaseUrl: "https://conductor.makefile.so"
 23+  });
 24+
 25+  try {
 26+    return await callback({
 27+      artifactStore,
 28+      stateDir
 29+    });
 30+  } finally {
 31+    artifactStore.close();
 32+    rmSync(stateDir, {
 33+      force: true,
 34+      recursive: true
 35+    });
 36+  }
 37+}
 38+
 39 function parseJsonBody(response) {
 40   return JSON.parse(response.body);
 41 }
 42@@ -4201,6 +4228,107 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
 43   }
 44 });
 45 
 46+test("handleConductorHttpRequest serves artifact files and robots.txt", async () => {
 47+  const { repository, snapshot } = await createLocalApiFixture();
 48+
 49+  await withArtifactStoreFixture(async ({ artifactStore }) => {
 50+    await artifactStore.insertMessage({
 51+      conversationId: "conv_artifact",
 52+      id: "msg_artifact",
 53+      observedAt: 1_743_151_740_000,
 54+      platform: "claude",
 55+      rawText: "artifact message body",
 56+      role: "assistant"
 57+    });
 58+    await artifactStore.insertExecution({
 59+      executedAt: 1_743_151_800_000,
 60+      instructionId: "inst_artifact",
 61+      messageId: "msg_artifact",
 62+      params: {
 63+        command: "pnpm test"
 64+      },
 65+      paramsKind: "body",
 66+      resultData: {
 67+        exit_code: 0,
 68+        stdout: "ok"
 69+      },
 70+      resultOk: true,
 71+      target: "conductor",
 72+      tool: "exec"
 73+    });
 74+    await artifactStore.upsertSession({
 75+      conversationId: "conv_artifact",
 76+      executionCount: 1,
 77+      id: "session_artifact",
 78+      lastActivityAt: 1_743_151_800_000,
 79+      messageCount: 1,
 80+      platform: "claude",
 81+      startedAt: 1_743_151_740_000
 82+    });
 83+
 84+    const context = {
 85+      artifactStore,
 86+      repository,
 87+      snapshotLoader: () => snapshot
 88+    };
 89+
 90+    const robotsResponse = await handleConductorHttpRequest(
 91+      {
 92+        method: "GET",
 93+        path: "/robots.txt"
 94+      },
 95+      context
 96+    );
 97+    assert.equal(robotsResponse.status, 200);
 98+    assert.equal(robotsResponse.body, "User-agent: *\nAllow: /artifact/\n");
 99+
100+    const messageTextResponse = await handleConductorHttpRequest(
101+      {
102+        method: "GET",
103+        path: "/artifact/msg/msg_artifact.txt"
104+      },
105+      context
106+    );
107+    assert.equal(messageTextResponse.status, 200);
108+    assert.equal(messageTextResponse.headers["content-type"], "text/plain; charset=utf-8");
109+    assert.match(Buffer.from(messageTextResponse.body).toString("utf8"), /artifact message body/u);
110+
111+    const messageJsonResponse = await handleConductorHttpRequest(
112+      {
113+        method: "GET",
114+        path: "/artifact/msg/msg_artifact.json"
115+      },
116+      context
117+    );
118+    assert.equal(messageJsonResponse.status, 200);
119+    assert.equal(messageJsonResponse.headers["content-type"], "application/json; charset=utf-8");
120+    assert.match(
121+      Buffer.from(messageJsonResponse.body).toString("utf8"),
122+      /https:\/\/conductor\.makefile\.so\/artifact\/msg\/msg_artifact\.txt/u
123+    );
124+
125+    const sessionLatestResponse = await handleConductorHttpRequest(
126+      {
127+        method: "GET",
128+        path: "/artifact/session/latest.txt"
129+      },
130+      context
131+    );
132+    assert.equal(sessionLatestResponse.status, 200);
133+    assert.match(Buffer.from(sessionLatestResponse.body).toString("utf8"), /session_artifact/u);
134+
135+    const missingResponse = await handleConductorHttpRequest(
136+      {
137+        method: "GET",
138+        path: "/artifact/msg/missing.txt"
139+      },
140+      context
141+    );
142+    assert.equal(missingResponse.status, 404);
143+    assert.equal(parseJsonBody(missingResponse).error, "not_found");
144+  });
145+});
146+
147 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
148   await withRuntimeFixture(async ({ runtime }) => {
149     assert.equal(runtime.getRuntimeSnapshot().runtime.started, false);
M apps/conductor-daemon/src/index.ts
+23, -2
 1@@ -5,6 +5,12 @@ import {
 2   type ServerResponse
 3 } from "node:http";
 4 import type { AddressInfo } from "node:net";
 5+import { join } from "node:path";
 6+import {
 7+  ARTIFACTS_DIRNAME,
 8+  ARTIFACT_DB_FILENAME,
 9+  ArtifactStore
10+} from "../../../packages/artifact-db/dist/index.js";
11 import {
12   DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
13   type ControlPlaneRepository
14@@ -32,7 +38,10 @@ import {
15   PersistentBaaLiveInstructionSnapshotStore
16 } from "./instructions/store.js";
17 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
18-import { ConductorLocalControlPlane } from "./local-control-plane.js";
19+import {
20+  ConductorLocalControlPlane,
21+  resolveDefaultConductorStateDir
22+} from "./local-control-plane.js";
23 
24 export type { ConductorHttpRequest, ConductorHttpResponse } from "./http-types.js";
25 export {
26@@ -690,6 +699,7 @@ function normalizeIncomingRequestHeaders(
27 }
28 
29 class ConductorLocalHttpServer {
30+  private readonly artifactStore: ArtifactStore;
31   private readonly browserRequestPolicy: BrowserRequestPolicyController;
32   private readonly codexdLocalApiBase: string | null;
33   private readonly fetchImpl: typeof fetch;
34@@ -707,6 +717,7 @@ class ConductorLocalHttpServer {
35   constructor(
36     localApiBase: string,
37     repository: ControlPlaneRepository,
38+    artifactStore: ArtifactStore,
39     snapshotLoader: () => ConductorRuntimeSnapshot,
40     codexdLocalApiBase: string | null,
41     fetchImpl: typeof fetch,
42@@ -715,6 +726,7 @@ class ConductorLocalHttpServer {
43     now: () => number,
44     browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {}
45   ) {
46+    this.artifactStore = artifactStore;
47     this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
48     this.codexdLocalApiBase = codexdLocalApiBase;
49     this.fetchImpl = fetchImpl;
50@@ -806,6 +818,7 @@ class ConductorLocalHttpServer {
51             signal: requestAbortController.signal
52           },
53           {
54+            artifactStore: this.artifactStore,
55             deliveryBridge: this.firefoxWebSocketServer.getDeliveryBridge(),
56             browserBridge:
57               this.firefoxWebSocketServer.getBridgeService() as unknown as BrowserBridgeController,
58@@ -2028,6 +2041,7 @@ function getUsageText(): string {
59 }
60 
61 export class ConductorRuntime {
62+  private readonly artifactStore: ArtifactStore;
63   private readonly config: ResolvedConductorRuntimeConfig;
64   private readonly daemon: ConductorDaemon;
65   private readonly localControlPlane: ConductorLocalControlPlane;
66@@ -2044,8 +2058,14 @@ export class ConductorRuntime {
67       ...config,
68       startedAt
69     });
70+    const resolvedStateDir = this.config.paths.stateDir ?? resolveDefaultConductorStateDir();
71     this.localControlPlane = new ConductorLocalControlPlane({
72-      stateDir: this.config.paths.stateDir
73+      stateDir: resolvedStateDir
74+    });
75+    this.artifactStore = new ArtifactStore({
76+      artifactDir: join(resolvedStateDir, ARTIFACTS_DIRNAME),
77+      databasePath: join(resolvedStateDir, ARTIFACT_DB_FILENAME),
78+      publicBaseUrl: this.config.publicApiBase
79     });
80     this.daemon = new ConductorDaemon(this.config, {
81       ...options,
82@@ -2060,6 +2080,7 @@ export class ConductorRuntime {
83         : new ConductorLocalHttpServer(
84             this.config.localApiBase,
85             this.localControlPlane.repository,
86+            this.artifactStore,
87             () => this.getRuntimeSnapshot(),
88             this.config.codexdLocalApiBase,
89             options.fetchImpl ?? globalThis.fetch,
M apps/conductor-daemon/src/local-api.ts
+90, -0
  1@@ -1,4 +1,10 @@
  2+import { readFileSync } from "node:fs";
  3 import { randomUUID } from "node:crypto";
  4+import { join } from "node:path";
  5+import {
  6+  getArtifactContentType,
  7+  type ArtifactStore
  8+} from "../../../packages/artifact-db/dist/index.js";
  9 import {
 10   AUTOMATION_STATE_KEY,
 11   DEFAULT_AUTOMATION_MODE,
 12@@ -86,10 +92,13 @@ const STATUS_VIEW_HTML_HEADERS = {
 13   "cache-control": "no-store",
 14   "content-type": "text/html; charset=utf-8"
 15 } as const;
 16+const ROBOTS_TXT_BODY = "User-agent: *\nAllow: /artifact/";
 17 const SSE_RESPONSE_HEADERS = {
 18   "cache-control": "no-store",
 19   "content-type": "text/event-stream; charset=utf-8"
 20 } as const;
 21+const ALLOWED_ARTIFACT_SCOPES = new Set(["exec", "msg", "session"]);
 22+const ARTIFACT_FILE_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
 23 const BROWSER_CLAUDE_PLATFORM = "claude";
 24 const BROWSER_CLAUDE_ROOT_URL = "https://claude.ai/";
 25 const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
 26@@ -190,6 +199,7 @@ type UpstreamErrorEnvelope = JsonObject & {
 27 };
 28 
 29 interface LocalApiRequestContext {
 30+  artifactStore: ArtifactStore | null;
 31   deliveryBridge: BaaBrowserDeliveryBridge | null;
 32   browserBridge: BrowserBridgeController | null;
 33   browserRequestPolicy: BrowserRequestPolicyController | null;
 34@@ -238,6 +248,7 @@ export interface ConductorRuntimeApiSnapshot {
 35 }
 36 
 37 export interface ConductorLocalApiContext {
 38+  artifactStore?: ArtifactStore | null;
 39   deliveryBridge?: BaaBrowserDeliveryBridge | null;
 40   browserBridge?: BrowserBridgeController | null;
 41   browserRequestPolicy?: BrowserRequestPolicyController | null;
 42@@ -317,6 +328,22 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 43     pathPattern: "/describe/control",
 44     summary: "读取控制类自描述 JSON"
 45   },
 46+  {
 47+    id: "service.robots",
 48+    exposeInDescribe: false,
 49+    kind: "read",
 50+    method: "GET",
 51+    pathPattern: "/robots.txt",
 52+    summary: "返回允许 AI 访问 /artifact/ 的 robots.txt"
 53+  },
 54+  {
 55+    id: "service.artifact.read",
 56+    exposeInDescribe: false,
 57+    kind: "read",
 58+    method: "GET",
 59+    pathPattern: "/artifact/:artifact_scope/:artifact_file",
 60+    summary: "读取 artifact 静态文件"
 61+  },
 62   {
 63     id: "service.health",
 64     kind: "read",
 65@@ -942,6 +969,18 @@ function requireRepository(repository: ControlPlaneRepository | null): ControlPl
 66   return repository;
 67 }
 68 
 69+function requireArtifactStore(artifactStore: ArtifactStore | null): ArtifactStore {
 70+  if (artifactStore == null) {
 71+    throw new LocalApiHttpError(
 72+      503,
 73+      "artifact_store_not_configured",
 74+      "Conductor artifact store is not configured."
 75+    );
 76+  }
 77+
 78+  return artifactStore;
 79+}
 80+
 81 function summarizeTask(task: TaskRecord): JsonObject {
 82   return {
 83     task_id: task.taskId,
 84@@ -5572,6 +5611,40 @@ async function handleHostFileWrite(context: LocalApiRequestContext): Promise<Con
 85   );
 86 }
 87 
 88+async function handleArtifactRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 89+  const artifactStore = requireArtifactStore(context.artifactStore);
 90+  const scope = context.params.artifact_scope;
 91+  const fileName = context.params.artifact_file;
 92+
 93+  if (!scope || !fileName || !isSafeArtifactPath(scope, fileName)) {
 94+    throw new LocalApiHttpError(
 95+      404,
 96+      "not_found",
 97+      `No conductor route matches "${normalizePathname(context.url.pathname)}".`
 98+    );
 99+  }
100+
101+  try {
102+    return binaryResponse(200, readFileSync(join(artifactStore.getArtifactsDir(), scope, fileName)), {
103+      "content-type": getArtifactContentType(fileName)
104+    });
105+  } catch (error) {
106+    if (isMissingFileError(error)) {
107+      throw new LocalApiHttpError(
108+        404,
109+        "not_found",
110+        `Artifact "${normalizePathname(context.url.pathname)}" was not found.`
111+      );
112+    }
113+
114+    throw error;
115+  }
116+}
117+
118+async function handleRobotsRead(): Promise<ConductorHttpResponse> {
119+  return textResponse(200, ROBOTS_TXT_BODY);
120+}
121+
122 async function handleCodexStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
123   const result = await requestCodexd(context, {
124     method: "GET",
125@@ -5769,6 +5842,10 @@ async function dispatchBusinessRoute(
126       return handleScopedDescribeRead(context, version, "business");
127     case "service.describe.control":
128       return handleScopedDescribeRead(context, version, "control");
129+    case "service.robots":
130+      return handleRobotsRead();
131+    case "service.artifact.read":
132+      return handleArtifactRead(context);
133     case "service.health":
134       return handleHealthRead(context, version);
135     case "service.version":
136@@ -5923,6 +6000,18 @@ function matchPathPattern(pathPattern: string, pathname: string): Record<string,
137   return params;
138 }
139 
140+function isMissingFileError(error: unknown): boolean {
141+  return typeof error === "object" && error != null && "code" in error && error.code === "ENOENT";
142+}
143+
144+function isSafeArtifactPath(scope: string, fileName: string): boolean {
145+  return (
146+    ALLOWED_ARTIFACT_SCOPES.has(scope) &&
147+    ARTIFACT_FILE_SEGMENT_PATTERN.test(fileName) &&
148+    !fileName.includes("..")
149+  );
150+}
151+
152 export async function handleConductorHttpRequest(
153   request: ConductorHttpRequest,
154   context: ConductorLocalApiContext
155@@ -5967,6 +6056,7 @@ export async function handleConductorHttpRequest(
156     return await dispatchRoute(
157       matchedRoute,
158       {
159+        artifactStore: context.artifactStore ?? null,
160         deliveryBridge: context.deliveryBridge ?? null,
161         browserBridge: context.browserBridge ?? null,
162         browserRequestPolicy: context.browserRequestPolicy ?? null,
M apps/conductor-daemon/src/node-shims.d.ts
+1, -0
1@@ -45,6 +45,7 @@ declare module "node:net" {
2 }
3 
4 declare module "node:fs" {
5+  export function readFileSync(path: string): Uint8Array;
6   export function readFileSync(path: string, encoding: string): string;
7 }
8 
A packages/artifact-db/package.json
+12, -0
 1@@ -0,0 +1,12 @@
 2+{
 3+  "name": "@baa-conductor/artifact-db",
 4+  "private": true,
 5+  "type": "module",
 6+  "main": "dist/index.js",
 7+  "types": "dist/index.d.ts",
 8+  "scripts": {
 9+    "build": "pnpm exec tsc -p tsconfig.json",
10+    "test": "pnpm run build && node --test src/index.test.js",
11+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
12+  }
13+}
A packages/artifact-db/src/index.test.js
+107, -0
  1@@ -0,0 +1,107 @@
  2+import assert from "node:assert/strict";
  3+import {
  4+  existsSync,
  5+  mkdtempSync,
  6+  readFileSync,
  7+  rmSync
  8+} from "node:fs";
  9+import { tmpdir } from "node:os";
 10+import { join } from "node:path";
 11+import test from "node:test";
 12+
 13+import {
 14+  ARTIFACT_DB_FILENAME,
 15+  ARTIFACTS_DIRNAME,
 16+  ArtifactStore
 17+} from "../dist/index.js";
 18+
 19+test("ArtifactStore writes message, execution, session, and index artifacts synchronously", async () => {
 20+  const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-test-"));
 21+  const stateDir = join(rootDir, "state");
 22+  const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
 23+  const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
 24+  const store = new ArtifactStore({
 25+    artifactDir,
 26+    databasePath,
 27+    publicBaseUrl: "https://conductor.makefile.so"
 28+  });
 29+
 30+  try {
 31+    const message = await store.insertMessage({
 32+      conversationId: "conv_123",
 33+      id: "msg_123",
 34+      observedAt: Date.UTC(2026, 2, 28, 8, 9, 0),
 35+      organizationId: "org_123",
 36+      pageTitle: "Claude",
 37+      pageUrl: "https://claude.ai/chat/conv_123",
 38+      platform: "claude",
 39+      rawText: "完整消息内容\n第二行",
 40+      role: "assistant"
 41+    });
 42+    const execution = await store.insertExecution({
 43+      executedAt: Date.UTC(2026, 2, 28, 8, 10, 0),
 44+      instructionId: "inst_123",
 45+      messageId: message.id,
 46+      params: {
 47+        command: "pnpm test"
 48+      },
 49+      paramsKind: "body",
 50+      resultData: {
 51+        exit_code: 0,
 52+        stdout: "all good"
 53+      },
 54+      resultOk: true,
 55+      target: "conductor",
 56+      tool: "exec"
 57+    });
 58+    const session = await store.upsertSession({
 59+      conversationId: "conv_123",
 60+      executionCount: 1,
 61+      id: "session_123",
 62+      lastActivityAt: Date.UTC(2026, 2, 28, 8, 10, 0),
 63+      messageCount: 1,
 64+      platform: "claude",
 65+      startedAt: Date.UTC(2026, 2, 28, 8, 9, 0),
 66+      summary: "会话摘要"
 67+    });
 68+
 69+    assert.equal(existsSync(databasePath), true);
 70+    assert.equal(existsSync(join(artifactDir, "msg", "msg_123.txt")), true);
 71+    assert.equal(existsSync(join(artifactDir, "msg", "msg_123.json")), true);
 72+    assert.equal(existsSync(join(artifactDir, "exec", "inst_123.txt")), true);
 73+    assert.equal(existsSync(join(artifactDir, "exec", "inst_123.json")), true);
 74+    assert.equal(existsSync(join(artifactDir, "session", "session_123.txt")), true);
 75+    assert.equal(existsSync(join(artifactDir, "session", "session_123.json")), true);
 76+    assert.equal(existsSync(join(artifactDir, "session", "latest.txt")), true);
 77+    assert.equal(existsSync(join(artifactDir, "session", "latest.json")), true);
 78+
 79+    assert.match(readFileSync(join(artifactDir, "msg", "msg_123.txt"), "utf8"), /kind: message/u);
 80+    assert.match(readFileSync(join(artifactDir, "msg", "msg_123.txt"), "utf8"), /完整消息内容/u);
 81+    assert.match(
 82+      readFileSync(join(artifactDir, "msg", "msg_123.json"), "utf8"),
 83+      /https:\/\/conductor\.makefile\.so\/artifact\/msg\/msg_123\.txt/u
 84+    );
 85+    assert.match(readFileSync(join(artifactDir, "exec", "inst_123.txt"), "utf8"), /params:/u);
 86+    assert.match(readFileSync(join(artifactDir, "exec", "inst_123.txt"), "utf8"), /pnpm test/u);
 87+    assert.match(readFileSync(join(artifactDir, "session", "session_123.txt"), "utf8"), /### message msg_123/u);
 88+    assert.match(readFileSync(join(artifactDir, "session", "session_123.txt"), "utf8"), /### execution inst_123/u);
 89+    assert.match(readFileSync(join(artifactDir, "session", "latest.txt"), "utf8"), /session_123/u);
 90+    assert.match(
 91+      readFileSync(join(artifactDir, "session", "latest.json"), "utf8"),
 92+      /https:\/\/conductor\.makefile\.so\/artifact\/session\/session_123\.txt/u
 93+    );
 94+
 95+    assert.deepEqual(await store.getMessage(message.id), message);
 96+    assert.deepEqual(await store.getExecution(execution.instructionId), execution);
 97+    assert.deepEqual(await store.getLatestSessions(1), [session]);
 98+    assert.deepEqual(await store.listMessages({ conversationId: "conv_123" }), [message]);
 99+    assert.deepEqual(await store.listExecutions({ messageId: message.id }), [execution]);
100+    assert.deepEqual(await store.listSessions({ platform: "claude" }), [session]);
101+  } finally {
102+    store.close();
103+    rmSync(rootDir, {
104+      force: true,
105+      recursive: true
106+    });
107+  }
108+});
A packages/artifact-db/src/index.ts
+31, -0
 1@@ -0,0 +1,31 @@
 2+export { ARTIFACT_SCHEMA_SQL } from "./schema.js";
 3+export {
 4+  buildArtifactPublicUrl,
 5+  buildArtifactRelativePath,
 6+  getArtifactContentType
 7+} from "./static-gen.js";
 8+export { ArtifactStore } from "./store.js";
 9+export {
10+  ARTIFACTS_DIRNAME,
11+  ARTIFACT_DB_FILENAME,
12+  ARTIFACT_PUBLIC_PATH_SEGMENT,
13+  ARTIFACT_SCOPES,
14+  DEFAULT_SESSION_INDEX_LIMIT,
15+  DEFAULT_SUMMARY_LENGTH,
16+  type ArtifactFileKind,
17+  type ArtifactScope,
18+  type ArtifactStoreConfig,
19+  type ArtifactTextFile,
20+  type ExecutionParamsKind,
21+  type ExecutionRecord,
22+  type InsertExecutionInput,
23+  type InsertMessageInput,
24+  type ListExecutionsOptions,
25+  type ListMessagesOptions,
26+  type ListSessionsOptions,
27+  type MessageRecord,
28+  type SessionIndexEntry,
29+  type SessionRecord,
30+  type SessionTimelineEntry,
31+  type UpsertSessionInput
32+} from "./types.js";
A packages/artifact-db/src/node-shims.d.ts
+50, -0
 1@@ -0,0 +1,50 @@
 2+declare module "node:fs" {
 3+  export function existsSync(path: string): boolean;
 4+  export function mkdirSync(
 5+    path: string,
 6+    options?: {
 7+      recursive?: boolean;
 8+    }
 9+  ): void;
10+  export function readFileSync(path: string, encoding: "utf8"): string;
11+  export function readFileSync(path: string): Uint8Array;
12+  export function rmSync(
13+    path: string,
14+    options?: {
15+      force?: boolean;
16+      recursive?: boolean;
17+    }
18+  ): void;
19+  export function writeFileSync(path: string, data: string | Uint8Array): void;
20+}
21+
22+declare module "node:path" {
23+  export function dirname(path: string): string;
24+  export function join(...paths: string[]): string;
25+}
26+
27+declare module "node:sqlite" {
28+  export interface DatabaseColumnDefinition {
29+    name: string;
30+  }
31+
32+  export interface StatementRunResult {
33+    changes?: number;
34+    lastInsertRowid?: number | bigint;
35+  }
36+
37+  export class StatementSync {
38+    all(...params: unknown[]): unknown[];
39+    columns(): DatabaseColumnDefinition[];
40+    get(...params: unknown[]): unknown;
41+    run(...params: unknown[]): StatementRunResult;
42+    setReturnArrays(enabled: boolean): void;
43+  }
44+
45+  export class DatabaseSync {
46+    constructor(path: string);
47+    close(): void;
48+    exec(query: string): void;
49+    prepare(query: string): StatementSync;
50+  }
51+}
A packages/artifact-db/src/schema.ts
+62, -0
 1@@ -0,0 +1,62 @@
 2+export const ARTIFACT_SCHEMA_SQL = `
 3+CREATE TABLE IF NOT EXISTS messages (
 4+  id              TEXT PRIMARY KEY,
 5+  platform        TEXT NOT NULL,
 6+  conversation_id TEXT,
 7+  role            TEXT NOT NULL,
 8+  raw_text        TEXT NOT NULL,
 9+  summary         TEXT,
10+  observed_at     INTEGER NOT NULL,
11+  static_path     TEXT NOT NULL,
12+  page_url        TEXT,
13+  page_title      TEXT,
14+  organization_id TEXT,
15+  created_at      INTEGER NOT NULL
16+);
17+
18+CREATE INDEX IF NOT EXISTS idx_messages_conversation
19+  ON messages(conversation_id);
20+CREATE INDEX IF NOT EXISTS idx_messages_platform
21+  ON messages(platform, observed_at DESC);
22+
23+CREATE TABLE IF NOT EXISTS executions (
24+  instruction_id  TEXT PRIMARY KEY,
25+  message_id      TEXT NOT NULL,
26+  target          TEXT NOT NULL,
27+  tool            TEXT NOT NULL,
28+  params          TEXT,
29+  params_kind     TEXT NOT NULL,
30+  result_ok       INTEGER NOT NULL,
31+  result_data     TEXT,
32+  result_summary  TEXT,
33+  result_error    TEXT,
34+  http_status     INTEGER,
35+  executed_at     INTEGER NOT NULL,
36+  static_path     TEXT NOT NULL,
37+  created_at      INTEGER NOT NULL,
38+  FOREIGN KEY (message_id) REFERENCES messages(id)
39+);
40+
41+CREATE INDEX IF NOT EXISTS idx_executions_message
42+  ON executions(message_id);
43+CREATE INDEX IF NOT EXISTS idx_executions_target_tool
44+  ON executions(target, tool);
45+
46+CREATE TABLE IF NOT EXISTS sessions (
47+  id                TEXT PRIMARY KEY,
48+  platform          TEXT NOT NULL,
49+  conversation_id   TEXT,
50+  started_at        INTEGER NOT NULL,
51+  last_activity_at  INTEGER NOT NULL,
52+  message_count     INTEGER NOT NULL DEFAULT 0,
53+  execution_count   INTEGER NOT NULL DEFAULT 0,
54+  summary           TEXT,
55+  static_path       TEXT NOT NULL,
56+  created_at        INTEGER NOT NULL
57+);
58+
59+CREATE INDEX IF NOT EXISTS idx_sessions_platform
60+  ON sessions(platform, last_activity_at DESC);
61+CREATE INDEX IF NOT EXISTS idx_sessions_conversation
62+  ON sessions(conversation_id);
63+`;
A packages/artifact-db/src/static-gen.ts
+450, -0
  1@@ -0,0 +1,450 @@
  2+import {
  3+  ARTIFACT_PUBLIC_PATH_SEGMENT,
  4+  type ArtifactScope,
  5+  type ArtifactTextFile,
  6+  type ExecutionRecord,
  7+  type MessageRecord,
  8+  type SessionIndexEntry,
  9+  type SessionRecord,
 10+  type SessionTimelineEntry
 11+} from "./types.js";
 12+
 13+const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
 14+const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
 15+
 16+interface ArtifactRenderConfig {
 17+  publicBaseUrl: string | null;
 18+}
 19+
 20+export function buildArtifactRelativePath(scope: ArtifactScope, fileName: string): string {
 21+  return `${scope}/${fileName}`;
 22+}
 23+
 24+export function buildArtifactPublicUrl(
 25+  publicBaseUrl: string | null,
 26+  relativePath: string | null
 27+): string | null {
 28+  if (publicBaseUrl == null || relativePath == null) {
 29+    return null;
 30+  }
 31+
 32+  return `${publicBaseUrl}/${ARTIFACT_PUBLIC_PATH_SEGMENT}/${relativePath}`;
 33+}
 34+
 35+export function getArtifactContentType(path: string): string {
 36+  return path.endsWith(".json") ? JSON_CONTENT_TYPE : TEXT_CONTENT_TYPE;
 37+}
 38+
 39+export function buildMessageArtifactFiles(
 40+  record: MessageRecord,
 41+  config: ArtifactRenderConfig
 42+): ArtifactTextFile[] {
 43+  const txtPath = record.staticPath;
 44+  const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
 45+
 46+  return [
 47+    {
 48+      content: renderMessageText(record),
 49+      kind: "txt",
 50+      relativePath: txtPath
 51+    },
 52+    {
 53+      content: renderJson({
 54+        artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
 55+        conversation_id: record.conversationId,
 56+        created_at: formatTimestamp(record.createdAt),
 57+        id: record.id,
 58+        kind: "message",
 59+        observed_at: formatTimestamp(record.observedAt),
 60+        organization_id: record.organizationId,
 61+        page_title: record.pageTitle,
 62+        page_url: record.pageUrl,
 63+        platform: record.platform,
 64+        raw_text: record.rawText,
 65+        role: record.role,
 66+        static_path: txtPath,
 67+        summary: record.summary
 68+      }),
 69+      kind: "json",
 70+      relativePath: jsonPath
 71+    }
 72+  ];
 73+}
 74+
 75+export function buildExecutionArtifactFiles(
 76+  record: ExecutionRecord,
 77+  config: ArtifactRenderConfig,
 78+  messageStaticPath: string | null
 79+): ArtifactTextFile[] {
 80+  const txtPath = record.staticPath;
 81+  const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
 82+
 83+  return [
 84+    {
 85+      content: renderExecutionText(record, config, messageStaticPath),
 86+      kind: "txt",
 87+      relativePath: txtPath
 88+    },
 89+    {
 90+      content: renderJson({
 91+        artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
 92+        executed_at: formatTimestamp(record.executedAt),
 93+        http_status: record.httpStatus,
 94+        id: record.instructionId,
 95+        kind: "execution",
 96+        message_id: record.messageId,
 97+        message_url: buildArtifactPublicUrl(config.publicBaseUrl, messageStaticPath),
 98+        params: parseJsonText(record.params),
 99+        params_kind: record.paramsKind,
100+        result: {
101+          data: parseJsonText(record.resultData),
102+          error: record.resultError,
103+          ok: record.resultOk
104+        },
105+        static_path: txtPath,
106+        status: record.resultOk ? "ok" : "error",
107+        summary: record.resultSummary,
108+        target: record.target,
109+        tool: record.tool
110+      }),
111+      kind: "json",
112+      relativePath: jsonPath
113+    }
114+  ];
115+}
116+
117+export function buildSessionArtifactFiles(
118+  record: SessionRecord,
119+  timeline: SessionTimelineEntry[],
120+  config: ArtifactRenderConfig
121+): ArtifactTextFile[] {
122+  const txtPath = record.staticPath;
123+  const jsonPath = replaceArtifactExtension(record.staticPath, ".json");
124+
125+  return [
126+    {
127+      content: renderSessionText(record, timeline, config),
128+      kind: "txt",
129+      relativePath: txtPath
130+    },
131+    {
132+      content: renderJson({
133+        artifact_url: buildArtifactPublicUrl(config.publicBaseUrl, txtPath),
134+        conversation_id: record.conversationId,
135+        created_at: formatTimestamp(record.createdAt),
136+        execution_count: record.executionCount,
137+        id: record.id,
138+        kind: "session",
139+        last_activity_at: formatTimestamp(record.lastActivityAt),
140+        message_count: record.messageCount,
141+        platform: record.platform,
142+        started_at: formatTimestamp(record.startedAt),
143+        static_path: txtPath,
144+        summary: record.summary,
145+        timeline: timeline.map((entry) => serializeTimelineEntry(entry, config.publicBaseUrl))
146+      }),
147+      kind: "json",
148+      relativePath: jsonPath
149+    }
150+  ];
151+}
152+
153+export function buildSessionIndexArtifactFiles(
154+  entries: SessionIndexEntry[],
155+  generatedAt: number,
156+  config: ArtifactRenderConfig
157+): ArtifactTextFile[] {
158+  const txtPath = buildArtifactRelativePath("session", "latest.txt");
159+  const jsonPath = buildArtifactRelativePath("session", "latest.json");
160+
161+  return [
162+    {
163+      content: renderSessionIndexText(entries, generatedAt, config),
164+      kind: "txt",
165+      relativePath: txtPath
166+    },
167+    {
168+      content: renderJson({
169+        count: entries.length,
170+        generated_at: formatTimestamp(generatedAt),
171+        kind: "session_index",
172+        sessions: entries.map((entry) => ({
173+          conversation_id: entry.conversationId,
174+          execution_count: entry.executionCount,
175+          id: entry.id,
176+          last_activity_at: formatTimestamp(entry.lastActivityAt),
177+          latest_message_url: buildArtifactPublicUrl(config.publicBaseUrl, entry.latestMessageStaticPath),
178+          message_count: entry.messageCount,
179+          platform: entry.platform,
180+          session_url: buildArtifactPublicUrl(config.publicBaseUrl, entry.staticPath),
181+          summary: entry.summary
182+        }))
183+      }),
184+      kind: "json",
185+      relativePath: jsonPath
186+    }
187+  ];
188+}
189+
190+function renderMessageText(record: MessageRecord): string {
191+  return [
192+    renderFrontmatter({
193+      conversation_id: record.conversationId,
194+      created_at: formatTimestamp(record.createdAt),
195+      id: record.id,
196+      kind: "message",
197+      observed_at: formatTimestamp(record.observedAt),
198+      organization_id: record.organizationId,
199+      page_title: record.pageTitle,
200+      page_url: record.pageUrl,
201+      platform: record.platform,
202+      role: record.role,
203+      summary: record.summary
204+    }),
205+    "",
206+    "---",
207+    "",
208+    record.rawText
209+  ].join("\n");
210+}
211+
212+function renderExecutionText(
213+  record: ExecutionRecord,
214+  config: ArtifactRenderConfig,
215+  messageStaticPath: string | null
216+): string {
217+  const bodySections: string[] = [];
218+  const formattedParams = formatStructuredValue(record.params);
219+  const formattedResultData = formatStructuredValue(record.resultData);
220+
221+  if (formattedParams) {
222+    bodySections.push(`params:\n${formattedParams}`);
223+  }
224+
225+  if (record.resultSummary) {
226+    bodySections.push(`summary:\n${record.resultSummary}`);
227+  }
228+
229+  if (formattedResultData) {
230+    bodySections.push(`result:\n${formattedResultData}`);
231+  }
232+
233+  if (record.resultError) {
234+    bodySections.push(`error:\n${record.resultError}`);
235+  }
236+
237+  if (bodySections.length === 0) {
238+    bodySections.push("(empty)");
239+  }
240+
241+  return [
242+    renderFrontmatter({
243+      executed_at: formatTimestamp(record.executedAt),
244+      http_status: record.httpStatus,
245+      id: record.instructionId,
246+      kind: "execution",
247+      message_id: record.messageId,
248+      message_url: buildArtifactPublicUrl(config.publicBaseUrl, messageStaticPath),
249+      params_kind: record.paramsKind,
250+      status: record.resultOk ? "ok" : "error",
251+      target: record.target,
252+      tool: record.tool
253+    }),
254+    "",
255+    "---",
256+    "",
257+    bodySections.join("\n\n")
258+  ].join("\n");
259+}
260+
261+function renderSessionText(
262+  record: SessionRecord,
263+  timeline: SessionTimelineEntry[],
264+  config: ArtifactRenderConfig
265+): string {
266+  const sections = [
267+    renderFrontmatter({
268+      conversation_id: record.conversationId,
269+      created_at: formatTimestamp(record.createdAt),
270+      execution_count: record.executionCount,
271+      id: record.id,
272+      kind: "session",
273+      last_activity_at: formatTimestamp(record.lastActivityAt),
274+      message_count: record.messageCount,
275+      platform: record.platform,
276+      started_at: formatTimestamp(record.startedAt),
277+      summary: record.summary
278+    }),
279+    "",
280+    "---",
281+    ""
282+  ];
283+
284+  if (record.summary) {
285+    sections.push(record.summary, "");
286+  }
287+
288+  for (const entry of timeline) {
289+    sections.push(renderTimelineEntry(entry, config.publicBaseUrl), "");
290+  }
291+
292+  return sections.join("\n").trimEnd();
293+}
294+
295+function renderSessionIndexText(
296+  entries: SessionIndexEntry[],
297+  generatedAt: number,
298+  config: ArtifactRenderConfig
299+): string {
300+  const sections = [
301+    renderFrontmatter({
302+      count: entries.length,
303+      generated_at: formatTimestamp(generatedAt),
304+      kind: "session_index"
305+    }),
306+    "",
307+    "---",
308+    ""
309+  ];
310+
311+  for (const entry of entries) {
312+    sections.push(
313+      `## [${formatCompactTimestamp(entry.lastActivityAt)}] ${entry.platform} ${entry.conversationId ?? "no-conversation"}`,
314+      `messages: ${entry.messageCount}, executions: ${entry.executionCount}, last_activity: ${formatTimeOnly(entry.lastActivityAt)}`,
315+      `latest_message: ${buildArtifactPublicUrl(config.publicBaseUrl, entry.latestMessageStaticPath) ?? "(none)"}`,
316+      `session: ${buildArtifactPublicUrl(config.publicBaseUrl, entry.staticPath) ?? "(none)"}`,
317+      entry.summary ? `summary: ${entry.summary}` : "summary: (none)",
318+      ""
319+    );
320+  }
321+
322+  return sections.join("\n").trimEnd();
323+}
324+
325+function renderTimelineEntry(entry: SessionTimelineEntry, publicBaseUrl: string | null): string {
326+  if (entry.kind === "message") {
327+    return [
328+      `### message ${entry.id}`,
329+      `role: ${entry.role}`,
330+      `observed_at: ${formatTimestamp(entry.observedAt)}`,
331+      `url: ${buildArtifactPublicUrl(publicBaseUrl, entry.staticPath) ?? "(none)"}`,
332+      `summary: ${entry.summary ?? "(none)"}`
333+    ].join("\n");
334+  }
335+
336+  return [
337+    `### execution ${entry.instructionId}`,
338+    `target: ${entry.target}`,
339+    `tool: ${entry.tool}`,
340+    `executed_at: ${formatTimestamp(entry.executedAt)}`,
341+    `status: ${entry.resultOk ? "ok" : "error"}`,
342+    `url: ${buildArtifactPublicUrl(publicBaseUrl, entry.staticPath) ?? "(none)"}`,
343+    `summary: ${entry.resultSummary ?? "(none)"}`
344+  ].join("\n");
345+}
346+
347+function serializeTimelineEntry(entry: SessionTimelineEntry, publicBaseUrl: string | null): Record<string, unknown> {
348+  if (entry.kind === "message") {
349+    return {
350+      artifact_url: buildArtifactPublicUrl(publicBaseUrl, entry.staticPath),
351+      id: entry.id,
352+      kind: entry.kind,
353+      observed_at: formatTimestamp(entry.observedAt),
354+      page_title: entry.pageTitle,
355+      page_url: entry.pageUrl,
356+      role: entry.role,
357+      summary: entry.summary
358+    };
359+  }
360+
361+  return {
362+    artifact_url: buildArtifactPublicUrl(publicBaseUrl, entry.staticPath),
363+    executed_at: formatTimestamp(entry.executedAt),
364+    id: entry.instructionId,
365+    kind: entry.kind,
366+    message_id: entry.messageId,
367+    status: entry.resultOk ? "ok" : "error",
368+    summary: entry.resultSummary,
369+    target: entry.target,
370+    tool: entry.tool
371+  };
372+}
373+
374+function renderFrontmatter(values: Record<string, string | number | null>): string {
375+  const lines: string[] = [];
376+
377+  for (const [key, value] of Object.entries(values)) {
378+    if (value == null) {
379+      continue;
380+    }
381+
382+    lines.push(`${key}: ${value}`);
383+  }
384+
385+  return lines.join("\n");
386+}
387+
388+function renderJson(payload: Record<string, unknown>): string {
389+  return `${JSON.stringify(payload, null, 2)}\n`;
390+}
391+
392+function replaceArtifactExtension(path: string, extension: ".json" | ".txt"): string {
393+  return path.replace(/\.[^.]+$/u, extension);
394+}
395+
396+function parseJsonText(value: string | null): unknown {
397+  if (value == null) {
398+    return null;
399+  }
400+
401+  const normalized = value.trim();
402+
403+  if (normalized === "") {
404+    return "";
405+  }
406+
407+  try {
408+    return JSON.parse(normalized) as unknown;
409+  } catch {
410+    return value;
411+  }
412+}
413+
414+function formatStructuredValue(value: string | null): string {
415+  const parsed = parseJsonText(value);
416+
417+  if (parsed == null) {
418+    return "";
419+  }
420+
421+  if (typeof parsed === "string") {
422+    return parsed;
423+  }
424+
425+  return JSON.stringify(parsed, null, 2);
426+}
427+
428+function formatTimestamp(value: number): string {
429+  const date = new Date(value);
430+  const offsetMinutes = -date.getTimezoneOffset();
431+  const absoluteOffsetMinutes = Math.abs(offsetMinutes);
432+  const sign = offsetMinutes >= 0 ? "+" : "-";
433+  const offsetHours = String(Math.floor(absoluteOffsetMinutes / 60)).padStart(2, "0");
434+  const offsetRemainder = String(absoluteOffsetMinutes % 60).padStart(2, "0");
435+
436+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${offsetHours}:${offsetRemainder}`;
437+}
438+
439+function formatCompactTimestamp(value: number): string {
440+  const date = new Date(value);
441+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
442+}
443+
444+function formatTimeOnly(value: number): string {
445+  const date = new Date(value);
446+  return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
447+}
448+
449+function pad(value: number): string {
450+  return String(value).padStart(2, "0");
451+}
A packages/artifact-db/src/store.ts
+840, -0
  1@@ -0,0 +1,840 @@
  2+import {
  3+  existsSync,
  4+  mkdirSync,
  5+  readFileSync,
  6+  rmSync,
  7+  writeFileSync
  8+} from "node:fs";
  9+import { dirname, join } from "node:path";
 10+import { DatabaseSync } from "node:sqlite";
 11+
 12+import { ARTIFACT_SCHEMA_SQL } from "./schema.js";
 13+import {
 14+  buildArtifactRelativePath,
 15+  buildExecutionArtifactFiles,
 16+  buildMessageArtifactFiles,
 17+  buildSessionArtifactFiles,
 18+  buildSessionIndexArtifactFiles
 19+} from "./static-gen.js";
 20+import {
 21+  DEFAULT_SESSION_INDEX_LIMIT,
 22+  DEFAULT_SUMMARY_LENGTH,
 23+  type ArtifactStoreConfig,
 24+  type ArtifactTextFile,
 25+  type ExecutionRecord,
 26+  type ExecutionParamsKind,
 27+  type InsertExecutionInput,
 28+  type InsertMessageInput,
 29+  type ListExecutionsOptions,
 30+  type ListMessagesOptions,
 31+  type ListSessionsOptions,
 32+  type MessageRecord,
 33+  type SessionIndexEntry,
 34+  type SessionRecord,
 35+  type SessionTimelineEntry,
 36+  type UpsertSessionInput
 37+} from "./types.js";
 38+
 39+const INSERT_MESSAGE_SQL = `
 40+INSERT INTO messages (
 41+  id,
 42+  platform,
 43+  conversation_id,
 44+  role,
 45+  raw_text,
 46+  summary,
 47+  observed_at,
 48+  static_path,
 49+  page_url,
 50+  page_title,
 51+  organization_id,
 52+  created_at
 53+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
 54+`;
 55+
 56+const INSERT_EXECUTION_SQL = `
 57+INSERT INTO executions (
 58+  instruction_id,
 59+  message_id,
 60+  target,
 61+  tool,
 62+  params,
 63+  params_kind,
 64+  result_ok,
 65+  result_data,
 66+  result_summary,
 67+  result_error,
 68+  http_status,
 69+  executed_at,
 70+  static_path,
 71+  created_at
 72+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
 73+`;
 74+
 75+const UPSERT_SESSION_SQL = `
 76+INSERT INTO sessions (
 77+  id,
 78+  platform,
 79+  conversation_id,
 80+  started_at,
 81+  last_activity_at,
 82+  message_count,
 83+  execution_count,
 84+  summary,
 85+  static_path,
 86+  created_at
 87+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 88+ON CONFLICT(id) DO UPDATE SET
 89+  platform = excluded.platform,
 90+  conversation_id = excluded.conversation_id,
 91+  started_at = excluded.started_at,
 92+  last_activity_at = excluded.last_activity_at,
 93+  message_count = excluded.message_count,
 94+  execution_count = excluded.execution_count,
 95+  summary = excluded.summary,
 96+  static_path = excluded.static_path,
 97+  created_at = excluded.created_at;
 98+`;
 99+
100+interface FileMutation {
101+  existed: boolean;
102+  path: string;
103+  previousContent: Uint8Array | null;
104+}
105+
106+interface MessageRow {
107+  conversation_id: string | null;
108+  created_at: number;
109+  id: string;
110+  observed_at: number;
111+  organization_id: string | null;
112+  page_title: string | null;
113+  page_url: string | null;
114+  platform: string;
115+  raw_text: string;
116+  role: string;
117+  static_path: string;
118+  summary: string | null;
119+}
120+
121+interface ExecutionRow {
122+  created_at: number;
123+  executed_at: number;
124+  http_status: number | null;
125+  instruction_id: string;
126+  message_id: string;
127+  params: string | null;
128+  params_kind: ExecutionParamsKind;
129+  result_data: string | null;
130+  result_error: string | null;
131+  result_ok: number;
132+  result_summary: string | null;
133+  static_path: string;
134+  target: string;
135+  tool: string;
136+}
137+
138+interface SessionRow {
139+  conversation_id: string | null;
140+  created_at: number;
141+  execution_count: number;
142+  id: string;
143+  last_activity_at: number;
144+  message_count: number;
145+  platform: string;
146+  started_at: number;
147+  static_path: string;
148+  summary: string | null;
149+}
150+
151+interface TimelineMessageRow {
152+  id: string;
153+  observed_at: number;
154+  page_title: string | null;
155+  page_url: string | null;
156+  role: string;
157+  static_path: string;
158+  summary: string | null;
159+}
160+
161+interface TimelineExecutionRow {
162+  executed_at: number;
163+  instruction_id: string;
164+  message_id: string;
165+  result_ok: number;
166+  result_summary: string | null;
167+  static_path: string;
168+  target: string;
169+  tool: string;
170+}
171+
172+interface LatestMessageRow {
173+  id: string;
174+  static_path: string;
175+}
176+
177+export class ArtifactStore {
178+  private readonly artifactDir: string;
179+  private readonly db: DatabaseSync;
180+  private readonly publicBaseUrl: string | null;
181+  private readonly sessionIndexLimit: number;
182+  private readonly summaryLength: number;
183+
184+  constructor(config: ArtifactStoreConfig) {
185+    const databasePath = normalizeRequiredPath(config.databasePath, "databasePath");
186+    this.artifactDir = normalizeRequiredPath(config.artifactDir, "artifactDir");
187+    this.publicBaseUrl = normalizeOptionalBaseUrl(config.publicBaseUrl);
188+    this.sessionIndexLimit = normalizePositiveInteger(config.sessionIndexLimit, DEFAULT_SESSION_INDEX_LIMIT);
189+    this.summaryLength = normalizePositiveInteger(config.summaryLength, DEFAULT_SUMMARY_LENGTH);
190+
191+    mkdirSync(dirname(databasePath), {
192+      recursive: true
193+    });
194+    mkdirSync(this.artifactDir, {
195+      recursive: true
196+    });
197+    mkdirSync(join(this.artifactDir, "msg"), {
198+      recursive: true
199+    });
200+    mkdirSync(join(this.artifactDir, "exec"), {
201+      recursive: true
202+    });
203+    mkdirSync(join(this.artifactDir, "session"), {
204+      recursive: true
205+    });
206+
207+    this.db = new DatabaseSync(databasePath);
208+    this.db.exec("PRAGMA foreign_keys = ON;");
209+    this.db.exec(ARTIFACT_SCHEMA_SQL);
210+  }
211+
212+  close(): void {
213+    this.db.close();
214+  }
215+
216+  getArtifactsDir(): string {
217+    return this.artifactDir;
218+  }
219+
220+  async getExecution(instructionId: string): Promise<ExecutionRecord | null> {
221+    const row = this.getRow<ExecutionRow>(
222+      "SELECT * FROM executions WHERE instruction_id = ? LIMIT 1;",
223+      instructionId
224+    );
225+    return row == null ? null : mapExecutionRow(row);
226+  }
227+
228+  async getLatestSessions(limit: number = this.sessionIndexLimit): Promise<SessionRecord[]> {
229+    const rows = this.getRows<SessionRow>(
230+      `
231+      SELECT *
232+      FROM sessions
233+      ORDER BY last_activity_at DESC, created_at DESC
234+      LIMIT ?;
235+      `,
236+      normalizePositiveInteger(limit, this.sessionIndexLimit)
237+    );
238+    return rows.map(mapSessionRow);
239+  }
240+
241+  async getMessage(id: string): Promise<MessageRecord | null> {
242+    const row = this.getRow<MessageRow>("SELECT * FROM messages WHERE id = ? LIMIT 1;", id);
243+    return row == null ? null : mapMessageRow(row);
244+  }
245+
246+  async insertExecution(input: InsertExecutionInput): Promise<ExecutionRecord> {
247+    const record = buildExecutionRecord(input, this.summaryLength);
248+    const message = await this.getMessage(record.messageId);
249+
250+    if (message == null) {
251+      throw new Error(`Execution "${record.instructionId}" references unknown message "${record.messageId}".`);
252+    }
253+
254+    this.executeWrite((mutations) => {
255+      this.run(INSERT_EXECUTION_SQL, executionParams(record));
256+      this.writeArtifactFiles(
257+        buildExecutionArtifactFiles(record, this.renderConfig(), message.staticPath),
258+        mutations
259+      );
260+      this.writeArtifactFiles(
261+        buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
262+        mutations
263+      );
264+    });
265+
266+    return record;
267+  }
268+
269+  async insertMessage(input: InsertMessageInput): Promise<MessageRecord> {
270+    const record = buildMessageRecord(input, this.summaryLength);
271+
272+    this.executeWrite((mutations) => {
273+      this.run(INSERT_MESSAGE_SQL, messageParams(record));
274+      this.writeArtifactFiles(buildMessageArtifactFiles(record, this.renderConfig()), mutations);
275+      this.writeArtifactFiles(
276+        buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
277+        mutations
278+      );
279+    });
280+
281+    return record;
282+  }
283+
284+  async listExecutions(options: ListExecutionsOptions = {}): Promise<ExecutionRecord[]> {
285+    const query = [
286+      "SELECT * FROM executions",
287+      buildWhereClause(
288+        [
289+          buildCondition("message_id", options.messageId),
290+          buildCondition("target", options.target),
291+          buildCondition("tool", options.tool)
292+        ],
293+        "AND"
294+      ),
295+      "ORDER BY executed_at DESC, created_at DESC",
296+      "LIMIT ?",
297+      "OFFSET ?"
298+    ]
299+      .filter(Boolean)
300+      .join(" ");
301+
302+    const rows = this.getRows<ExecutionRow>(
303+      query,
304+      ...buildQueryParams(
305+        [options.messageId ?? undefined, options.target ?? undefined, options.tool ?? undefined],
306+        normalizeLimit(options.limit),
307+        normalizeOffset(options.offset)
308+      )
309+    );
310+
311+    return rows.map(mapExecutionRow);
312+  }
313+
314+  async listMessages(options: ListMessagesOptions = {}): Promise<MessageRecord[]> {
315+    const { clause, params } = buildConversationFilters(options.platform, options.conversationId);
316+    const rows = this.getRows<MessageRow>(
317+      [
318+        "SELECT * FROM messages",
319+        clause ? `WHERE ${clause}` : "",
320+        "ORDER BY observed_at DESC, created_at DESC",
321+        "LIMIT ?",
322+        "OFFSET ?"
323+      ]
324+        .filter(Boolean)
325+        .join(" "),
326+      ...params,
327+      normalizeLimit(options.limit),
328+      normalizeOffset(options.offset)
329+    );
330+
331+    return rows.map(mapMessageRow);
332+  }
333+
334+  async listSessions(options: ListSessionsOptions = {}): Promise<SessionRecord[]> {
335+    const { clause, params } = buildConversationFilters(options.platform, options.conversationId);
336+    const rows = this.getRows<SessionRow>(
337+      [
338+        "SELECT * FROM sessions",
339+        clause ? `WHERE ${clause}` : "",
340+        "ORDER BY last_activity_at DESC, created_at DESC",
341+        "LIMIT ?",
342+        "OFFSET ?"
343+      ]
344+        .filter(Boolean)
345+        .join(" "),
346+      ...params,
347+      normalizeLimit(options.limit),
348+      normalizeOffset(options.offset)
349+    );
350+
351+    return rows.map(mapSessionRow);
352+  }
353+
354+  async upsertSession(input: UpsertSessionInput): Promise<SessionRecord> {
355+    const record = buildSessionRecord(input, this.summaryLength);
356+
357+    this.executeWrite((mutations) => {
358+      this.run(UPSERT_SESSION_SQL, sessionParams(record));
359+      this.writeArtifactFiles(
360+        buildSessionArtifactFiles(record, this.readSessionTimeline(record), this.renderConfig()),
361+        mutations
362+      );
363+      this.writeArtifactFiles(
364+        buildSessionIndexArtifactFiles(this.readSessionIndexEntries(this.sessionIndexLimit), Date.now(), this.renderConfig()),
365+        mutations
366+      );
367+    });
368+
369+    return record;
370+  }
371+
372+  private executeWrite(operation: (mutations: FileMutation[]) => void): void {
373+    const mutations: FileMutation[] = [];
374+    this.db.exec("BEGIN;");
375+
376+    try {
377+      operation(mutations);
378+      this.db.exec("COMMIT;");
379+    } catch (error) {
380+      this.rollbackQuietly();
381+      this.restoreFiles(mutations);
382+      throw error;
383+    }
384+  }
385+
386+  private getRow<T>(query: string, ...params: Array<number | string | null>): T | null {
387+    const statement = this.db.prepare(query);
388+    return (statement.get(...params) as T | undefined) ?? null;
389+  }
390+
391+  private getRows<T>(query: string, ...params: Array<number | string | null>): T[] {
392+    const statement = this.db.prepare(query);
393+    return statement.all(...params) as T[];
394+  }
395+
396+  private readLatestMessageForSession(session: SessionRecord): LatestMessageRow | null {
397+    const { clause, params } = buildConversationFilters(session.platform, session.conversationId);
398+    return this.getRow<LatestMessageRow>(
399+      [
400+        "SELECT id, static_path",
401+        "FROM messages",
402+        clause ? `WHERE ${clause}` : "",
403+        "ORDER BY observed_at DESC, created_at DESC",
404+        "LIMIT 1"
405+      ]
406+        .filter(Boolean)
407+        .join(" "),
408+      ...params
409+    );
410+  }
411+
412+  private readSessionIndexEntries(limit: number): SessionIndexEntry[] {
413+    const sessions = this.getRows<SessionRow>(
414+      `
415+      SELECT *
416+      FROM sessions
417+      ORDER BY last_activity_at DESC, created_at DESC
418+      LIMIT ?;
419+      `,
420+      limit
421+    ).map(mapSessionRow);
422+
423+    return sessions.map((session) => {
424+      const latestMessage = this.readLatestMessageForSession(session);
425+
426+      return {
427+        conversationId: session.conversationId,
428+        executionCount: session.executionCount,
429+        id: session.id,
430+        lastActivityAt: session.lastActivityAt,
431+        latestMessageId: latestMessage?.id ?? null,
432+        latestMessageStaticPath: latestMessage?.static_path ?? null,
433+        messageCount: session.messageCount,
434+        platform: session.platform,
435+        staticPath: session.staticPath,
436+        summary: session.summary
437+      };
438+    });
439+  }
440+
441+  private readSessionTimeline(session: SessionRecord): SessionTimelineEntry[] {
442+    const { clause, params } = buildConversationFilters(session.platform, session.conversationId);
443+    const messageRows = this.getRows<TimelineMessageRow>(
444+      [
445+        "SELECT id, role, summary, observed_at, static_path, page_url, page_title",
446+        "FROM messages",
447+        clause ? `WHERE ${clause}` : "",
448+        "ORDER BY observed_at ASC, created_at ASC"
449+      ]
450+        .filter(Boolean)
451+        .join(" "),
452+      ...params
453+    );
454+    const executionRows = this.getRows<TimelineExecutionRow>(
455+      [
456+        "SELECT e.instruction_id, e.message_id, e.target, e.tool, e.result_ok, e.result_summary, e.executed_at, e.static_path",
457+        "FROM executions e",
458+        "INNER JOIN messages m ON m.id = e.message_id",
459+        clause ? `WHERE ${clause.replaceAll("platform", "m.platform").replaceAll("conversation_id", "m.conversation_id")}` : "",
460+        "ORDER BY e.executed_at ASC, e.created_at ASC"
461+      ]
462+        .filter(Boolean)
463+        .join(" "),
464+      ...params
465+    );
466+
467+    const timeline: SessionTimelineEntry[] = [
468+      ...messageRows.map((row) => ({
469+        id: row.id,
470+        kind: "message" as const,
471+        observedAt: row.observed_at,
472+        pageTitle: row.page_title,
473+        pageUrl: row.page_url,
474+        role: row.role,
475+        staticPath: row.static_path,
476+        summary: row.summary
477+      })),
478+      ...executionRows.map((row) => ({
479+        executedAt: row.executed_at,
480+        instructionId: row.instruction_id,
481+        kind: "execution" as const,
482+        messageId: row.message_id,
483+        resultOk: row.result_ok === 1,
484+        resultSummary: row.result_summary,
485+        staticPath: row.static_path,
486+        target: row.target,
487+        tool: row.tool
488+      }))
489+    ];
490+
491+    return timeline.sort((left, right) => {
492+      const leftTime = left.kind === "message" ? left.observedAt : left.executedAt;
493+      const rightTime = right.kind === "message" ? right.observedAt : right.executedAt;
494+      return leftTime - rightTime;
495+    });
496+  }
497+
498+  private renderConfig(): { publicBaseUrl: string | null } {
499+    return {
500+      publicBaseUrl: this.publicBaseUrl
501+    };
502+  }
503+
504+  private restoreFiles(mutations: FileMutation[]): void {
505+    for (let index = mutations.length - 1; index >= 0; index -= 1) {
506+      const mutation = mutations[index];
507+
508+      if (!mutation) {
509+        continue;
510+      }
511+
512+      if (!mutation.existed) {
513+        rmSync(mutation.path, {
514+          force: true
515+        });
516+        continue;
517+      }
518+
519+      if (mutation.previousContent != null) {
520+        writeFileSync(mutation.path, mutation.previousContent);
521+      }
522+    }
523+  }
524+
525+  private rollbackQuietly(): void {
526+    try {
527+      this.db.exec("ROLLBACK;");
528+    } catch {
529+      // Ignore rollback failures during error handling.
530+    }
531+  }
532+
533+  private run(query: string, params: Array<number | string | null>): void {
534+    this.db.prepare(query).run(...params);
535+  }
536+
537+  private writeArtifactFiles(files: ArtifactTextFile[], mutations: FileMutation[]): void {
538+    for (const file of files) {
539+      const path = join(this.artifactDir, file.relativePath);
540+      const existed = existsSync(path);
541+      mutations.push({
542+        existed,
543+        path,
544+        previousContent: existed ? readFileSync(path) : null
545+      });
546+      writeFileSync(path, file.content);
547+    }
548+  }
549+}
550+
551+function buildCondition(field: string, value: string | undefined): string | null {
552+  return value == null ? null : `${field} = ?`;
553+}
554+
555+function buildConversationFilters(
556+  platform?: string,
557+  conversationId?: string | null
558+): {
559+  clause: string;
560+  params: Array<string | null>;
561+} {
562+  const conditions: string[] = [];
563+  const params: Array<string | null> = [];
564+
565+  if (platform != null) {
566+    conditions.push("platform = ?");
567+    params.push(platform);
568+  }
569+
570+  if (conversationId === null) {
571+    conditions.push("conversation_id IS NULL");
572+  } else if (conversationId != null) {
573+    conditions.push("conversation_id = ?");
574+    params.push(conversationId);
575+  }
576+
577+  return {
578+    clause: conditions.join(" AND "),
579+    params
580+  };
581+}
582+
583+function buildExecutionRecord(input: InsertExecutionInput, summaryLength: number): ExecutionRecord {
584+  const params = stringifyUnknown(input.params);
585+  const resultData = stringifyUnknown(input.resultData);
586+  const resultSummary =
587+    normalizeOptionalString(input.resultSummary) ?? summarizeText(resultData ?? input.resultError ?? "", summaryLength);
588+
589+  return {
590+    createdAt: input.createdAt ?? Date.now(),
591+    executedAt: input.executedAt,
592+    httpStatus: input.httpStatus ?? null,
593+    instructionId: normalizeRequiredString(input.instructionId, "instructionId"),
594+    messageId: normalizeRequiredString(input.messageId, "messageId"),
595+    params,
596+    paramsKind: input.paramsKind ?? (params == null ? "none" : "inline_json"),
597+    resultData,
598+    resultError: normalizeOptionalString(input.resultError),
599+    resultOk: input.resultOk,
600+    resultSummary,
601+    staticPath: buildArtifactRelativePath("exec", `${normalizeRequiredString(input.instructionId, "instructionId")}.txt`),
602+    target: normalizeRequiredString(input.target, "target"),
603+    tool: normalizeRequiredString(input.tool, "tool")
604+  };
605+}
606+
607+function buildMessageRecord(input: InsertMessageInput, summaryLength: number): MessageRecord {
608+  const id = normalizeRequiredString(input.id, "id");
609+
610+  return {
611+    conversationId: normalizeOptionalString(input.conversationId),
612+    createdAt: input.createdAt ?? Date.now(),
613+    id,
614+    observedAt: input.observedAt,
615+    organizationId: normalizeOptionalString(input.organizationId),
616+    pageTitle: normalizeOptionalString(input.pageTitle),
617+    pageUrl: normalizeOptionalString(input.pageUrl),
618+    platform: normalizeRequiredString(input.platform, "platform"),
619+    rawText: normalizeRequiredString(input.rawText, "rawText"),
620+    role: normalizeRequiredString(input.role, "role"),
621+    staticPath: buildArtifactRelativePath("msg", `${id}.txt`),
622+    summary:
623+      normalizeOptionalString(input.summary) ?? summarizeText(normalizeRequiredString(input.rawText, "rawText"), summaryLength)
624+  };
625+}
626+
627+function buildQueryParams(
628+  values: Array<string | undefined>,
629+  limit: number,
630+  offset: number
631+): Array<number | string | null> {
632+  return [...values.filter((value): value is string => value != null), limit, offset];
633+}
634+
635+function buildSessionRecord(input: UpsertSessionInput, summaryLength: number): SessionRecord {
636+  const id = normalizeRequiredString(input.id, "id");
637+
638+  return {
639+    conversationId: normalizeOptionalString(input.conversationId),
640+    createdAt: input.createdAt ?? Date.now(),
641+    executionCount: input.executionCount ?? 0,
642+    id,
643+    lastActivityAt: input.lastActivityAt,
644+    messageCount: input.messageCount ?? 0,
645+    platform: normalizeRequiredString(input.platform, "platform"),
646+    startedAt: input.startedAt,
647+    staticPath: buildArtifactRelativePath("session", `${id}.txt`),
648+    summary: normalizeOptionalString(input.summary) ?? null
649+  };
650+}
651+
652+function buildWhereClause(conditions: Array<string | null>, separator: "AND"): string {
653+  const filtered = conditions.filter((condition): condition is string => condition != null);
654+  return filtered.length === 0 ? "" : `WHERE ${filtered.join(` ${separator} `)}`;
655+}
656+
657+function executionParams(record: ExecutionRecord): Array<number | string | null> {
658+  return [
659+    record.instructionId,
660+    record.messageId,
661+    record.target,
662+    record.tool,
663+    record.params,
664+    record.paramsKind,
665+    record.resultOk ? 1 : 0,
666+    record.resultData,
667+    record.resultSummary,
668+    record.resultError,
669+    record.httpStatus,
670+    record.executedAt,
671+    record.staticPath,
672+    record.createdAt
673+  ];
674+}
675+
676+function mapExecutionRow(row: ExecutionRow): ExecutionRecord {
677+  return {
678+    createdAt: row.created_at,
679+    executedAt: row.executed_at,
680+    httpStatus: row.http_status,
681+    instructionId: row.instruction_id,
682+    messageId: row.message_id,
683+    params: row.params,
684+    paramsKind: row.params_kind,
685+    resultData: row.result_data,
686+    resultError: row.result_error,
687+    resultOk: row.result_ok === 1,
688+    resultSummary: row.result_summary,
689+    staticPath: row.static_path,
690+    target: row.target,
691+    tool: row.tool
692+  };
693+}
694+
695+function mapMessageRow(row: MessageRow): MessageRecord {
696+  return {
697+    conversationId: row.conversation_id,
698+    createdAt: row.created_at,
699+    id: row.id,
700+    observedAt: row.observed_at,
701+    organizationId: row.organization_id,
702+    pageTitle: row.page_title,
703+    pageUrl: row.page_url,
704+    platform: row.platform,
705+    rawText: row.raw_text,
706+    role: row.role,
707+    staticPath: row.static_path,
708+    summary: row.summary
709+  };
710+}
711+
712+function mapSessionRow(row: SessionRow): SessionRecord {
713+  return {
714+    conversationId: row.conversation_id,
715+    createdAt: row.created_at,
716+    executionCount: row.execution_count,
717+    id: row.id,
718+    lastActivityAt: row.last_activity_at,
719+    messageCount: row.message_count,
720+    platform: row.platform,
721+    startedAt: row.started_at,
722+    staticPath: row.static_path,
723+    summary: row.summary
724+  };
725+}
726+
727+function messageParams(record: MessageRecord): Array<number | string | null> {
728+  return [
729+    record.id,
730+    record.platform,
731+    record.conversationId,
732+    record.role,
733+    record.rawText,
734+    record.summary,
735+    record.observedAt,
736+    record.staticPath,
737+    record.pageUrl,
738+    record.pageTitle,
739+    record.organizationId,
740+    record.createdAt
741+  ];
742+}
743+
744+function normalizeLimit(value: number | undefined): number {
745+  return normalizePositiveInteger(value, 50);
746+}
747+
748+function normalizeOffset(value: number | undefined): number {
749+  if (value == null) {
750+    return 0;
751+  }
752+
753+  if (!Number.isInteger(value) || value < 0) {
754+    throw new Error("offset must be a non-negative integer.");
755+  }
756+
757+  return value;
758+}
759+
760+function normalizeOptionalBaseUrl(value: string | null | undefined): string | null {
761+  const normalized = normalizeOptionalString(value);
762+  return normalized == null ? null : normalized.replace(/\/+$/u, "");
763+}
764+
765+function normalizeOptionalString(value: string | null | undefined): string | null {
766+  if (value == null) {
767+    return null;
768+  }
769+
770+  const normalized = value.trim();
771+  return normalized === "" ? null : normalized;
772+}
773+
774+function normalizePositiveInteger(value: number | undefined, fallback: number): number {
775+  if (value == null) {
776+    return fallback;
777+  }
778+
779+  if (!Number.isInteger(value) || value <= 0) {
780+    throw new Error("Expected a positive integer.");
781+  }
782+
783+  return value;
784+}
785+
786+function normalizeRequiredPath(value: string, name: string): string {
787+  const normalized = value.trim();
788+
789+  if (normalized === "") {
790+    throw new Error(`${name} must be a non-empty path.`);
791+  }
792+
793+  return normalized;
794+}
795+
796+function normalizeRequiredString(value: string, name: string): string {
797+  const normalized = value.trim();
798+
799+  if (normalized === "") {
800+    throw new Error(`${name} must be a non-empty string.`);
801+  }
802+
803+  return normalized;
804+}
805+
806+function sessionParams(record: SessionRecord): Array<number | string | null> {
807+  return [
808+    record.id,
809+    record.platform,
810+    record.conversationId,
811+    record.startedAt,
812+    record.lastActivityAt,
813+    record.messageCount,
814+    record.executionCount,
815+    record.summary,
816+    record.staticPath,
817+    record.createdAt
818+  ];
819+}
820+
821+function stringifyUnknown(value: unknown): string | null {
822+  if (value == null) {
823+    return null;
824+  }
825+
826+  if (typeof value === "string") {
827+    return value;
828+  }
829+
830+  return JSON.stringify(value, null, 2);
831+}
832+
833+function summarizeText(value: string, summaryLength: number): string | null {
834+  const normalized = value.trim();
835+
836+  if (normalized === "") {
837+    return null;
838+  }
839+
840+  return normalized.slice(0, summaryLength);
841+}
A packages/artifact-db/src/types.ts
+171, -0
  1@@ -0,0 +1,171 @@
  2+export const ARTIFACT_DB_FILENAME = "artifact.db";
  3+export const ARTIFACTS_DIRNAME = "artifacts";
  4+export const ARTIFACT_PUBLIC_PATH_SEGMENT = "artifact";
  5+export const DEFAULT_SUMMARY_LENGTH = 500;
  6+export const DEFAULT_SESSION_INDEX_LIMIT = 20;
  7+export const ARTIFACT_SCOPES = ["msg", "exec", "session"] as const;
  8+
  9+export type ArtifactScope = (typeof ARTIFACT_SCOPES)[number];
 10+export type ExecutionParamsKind = "none" | "body" | "inline_json" | "inline_string";
 11+export type ArtifactFileKind = "json" | "txt";
 12+
 13+export interface ArtifactStoreConfig {
 14+  artifactDir: string;
 15+  databasePath: string;
 16+  publicBaseUrl?: string | null;
 17+  sessionIndexLimit?: number;
 18+  summaryLength?: number;
 19+}
 20+
 21+export interface MessageRecord {
 22+  id: string;
 23+  platform: string;
 24+  conversationId: string | null;
 25+  role: string;
 26+  rawText: string;
 27+  summary: string | null;
 28+  observedAt: number;
 29+  staticPath: string;
 30+  pageUrl: string | null;
 31+  pageTitle: string | null;
 32+  organizationId: string | null;
 33+  createdAt: number;
 34+}
 35+
 36+export interface ExecutionRecord {
 37+  instructionId: string;
 38+  messageId: string;
 39+  target: string;
 40+  tool: string;
 41+  params: string | null;
 42+  paramsKind: ExecutionParamsKind;
 43+  resultOk: boolean;
 44+  resultData: string | null;
 45+  resultSummary: string | null;
 46+  resultError: string | null;
 47+  httpStatus: number | null;
 48+  executedAt: number;
 49+  staticPath: string;
 50+  createdAt: number;
 51+}
 52+
 53+export interface SessionRecord {
 54+  id: string;
 55+  platform: string;
 56+  conversationId: string | null;
 57+  startedAt: number;
 58+  lastActivityAt: number;
 59+  messageCount: number;
 60+  executionCount: number;
 61+  summary: string | null;
 62+  staticPath: string;
 63+  createdAt: number;
 64+}
 65+
 66+export interface InsertMessageInput {
 67+  id: string;
 68+  platform: string;
 69+  conversationId?: string | null;
 70+  role: string;
 71+  rawText: string;
 72+  summary?: string | null;
 73+  observedAt: number;
 74+  pageUrl?: string | null;
 75+  pageTitle?: string | null;
 76+  organizationId?: string | null;
 77+  createdAt?: number;
 78+}
 79+
 80+export interface InsertExecutionInput {
 81+  instructionId: string;
 82+  messageId: string;
 83+  target: string;
 84+  tool: string;
 85+  params?: unknown;
 86+  paramsKind?: ExecutionParamsKind;
 87+  resultOk: boolean;
 88+  resultData?: unknown;
 89+  resultSummary?: string | null;
 90+  resultError?: string | null;
 91+  httpStatus?: number | null;
 92+  executedAt: number;
 93+  createdAt?: number;
 94+}
 95+
 96+export interface UpsertSessionInput {
 97+  id: string;
 98+  platform: string;
 99+  conversationId?: string | null;
100+  startedAt: number;
101+  lastActivityAt: number;
102+  messageCount?: number;
103+  executionCount?: number;
104+  summary?: string | null;
105+  createdAt?: number;
106+}
107+
108+export interface ListMessagesOptions {
109+  conversationId?: string | null;
110+  limit?: number;
111+  offset?: number;
112+  platform?: string;
113+}
114+
115+export interface ListExecutionsOptions {
116+  limit?: number;
117+  messageId?: string;
118+  offset?: number;
119+  target?: string;
120+  tool?: string;
121+}
122+
123+export interface ListSessionsOptions {
124+  conversationId?: string | null;
125+  limit?: number;
126+  offset?: number;
127+  platform?: string;
128+}
129+
130+export interface SessionTimelineMessageEntry {
131+  kind: "message";
132+  id: string;
133+  role: string;
134+  summary: string | null;
135+  observedAt: number;
136+  staticPath: string;
137+  pageTitle: string | null;
138+  pageUrl: string | null;
139+}
140+
141+export interface SessionTimelineExecutionEntry {
142+  kind: "execution";
143+  executedAt: number;
144+  instructionId: string;
145+  messageId: string;
146+  resultOk: boolean;
147+  resultSummary: string | null;
148+  staticPath: string;
149+  target: string;
150+  tool: string;
151+}
152+
153+export type SessionTimelineEntry = SessionTimelineExecutionEntry | SessionTimelineMessageEntry;
154+
155+export interface SessionIndexEntry {
156+  conversationId: string | null;
157+  executionCount: number;
158+  id: string;
159+  lastActivityAt: number;
160+  latestMessageId: string | null;
161+  latestMessageStaticPath: string | null;
162+  messageCount: number;
163+  platform: string;
164+  staticPath: string;
165+  summary: string | null;
166+}
167+
168+export interface ArtifactTextFile {
169+  content: string;
170+  kind: ArtifactFileKind;
171+  relativePath: string;
172+}
A packages/artifact-db/tsconfig.json
+9, -0
 1@@ -0,0 +1,9 @@
 2+{
 3+  "extends": "../../tsconfig.base.json",
 4+  "compilerOptions": {
 5+    "declaration": true,
 6+    "rootDir": "src",
 7+    "outDir": "dist"
 8+  },
 9+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
10+}
M pnpm-lock.yaml
+5, -0
 1@@ -16,6 +16,9 @@ importers:
 2 
 3   apps/conductor-daemon:
 4     dependencies:
 5+      '@baa-conductor/artifact-db':
 6+        specifier: workspace:*
 7+        version: link:../../packages/artifact-db
 8       '@baa-conductor/db':
 9         specifier: workspace:*
10         version: link:../../packages/db
11@@ -27,6 +30,8 @@ importers:
12 
13   apps/worker-runner: {}
14 
15+  packages/artifact-db: {}
16+
17   packages/auth: {}
18 
19   packages/checkpointing: {}
M tasks/T-S039.md
+17, -3
 1@@ -159,17 +159,31 @@
 2 
 3 ### 开始执行
 4 
 5-- 执行者:
 6-- 开始时间:
 7+- 执行者:Codex
 8+- 开始时间:2026-03-28 17:44:00 CST
 9 - 状态变更:`待开始` → `进行中`
10 
11 ### 完成摘要
12 
13-- 完成时间:
14+- 完成时间:2026-03-28 17:59:13 CST
15 - 状态变更:`进行中` → `已完成`
16 - 修改了哪些文件:
17+  - `packages/artifact-db/`(新增 schema / types / store / static-gen / tests)
18+  - `apps/conductor-daemon/src/local-api.ts`
19+  - `apps/conductor-daemon/src/index.ts`
20+  - `apps/conductor-daemon/src/index.test.js`
21+  - `apps/conductor-daemon/package.json`
22+  - `pnpm-lock.yaml`
23 - 核心实现思路:
24+  - 新增独立 `ArtifactStore`,使用 `node:sqlite` 维护 `state/artifact.db`
25+  - 写入 message / execution / session 时同步生成 `.txt` + `.json` 静态文件,并在失败时回滚数据库和文件覆盖
26+  - 在 conductor local API 挂载 `/artifact/:scope/:file` 和 `/robots.txt`
27+  - runtime 启动时初始化 `state/artifacts/` 与 artifact 数据库,并把 store 注入 HTTP server
28 - 跑了哪些测试:
29+  - `npx pnpm -C /Users/george/code/baa-conductor-artifact-foundation -F @baa-conductor/artifact-db test`
30+  - `npx pnpm -C /Users/george/code/baa-conductor-artifact-foundation -F @baa-conductor/conductor-daemon test`
31+  - `npx pnpm -C /Users/george/code/baa-conductor-artifact-foundation build`
32 
33 ### 剩余风险
34 
35+- 仅验证了本地测试与本地 HTTP runtime,未实际对 `http://100.71.210.78:4317` 执行远程 `curl`