- 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
+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 }
+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);
+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,
+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,
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
+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+}
+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+});
+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";
+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+}
+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+`;
+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+}
+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+}
+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+}
+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+}
+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: {}
+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`