@plasm_lang/vercel-agent 0.3.85 → 0.3.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +8 -10
  2. package/package.json +2 -2
  3. package/src/archive/adapters.ts +4 -0
  4. package/src/archive/prod-archive-store.ts +28 -45
  5. package/src/archive/resolve-backend.ts +8 -26
  6. package/src/archive/types.ts +1 -0
  7. package/src/archive/vercel-blob-adapter.ts +5 -0
  8. package/src/authoring/schedule-manager.ts +36 -22
  9. package/src/cli/build-manifest.ts +18 -0
  10. package/src/cli/build.ts +4 -13
  11. package/src/cli/compile-authored-slots.ts +9 -0
  12. package/src/cli/init-scaffold.ts +6 -2
  13. package/src/cli/init.ts +2 -6
  14. package/src/index.ts +3 -2
  15. package/src/nitro/agent-summary.ts +7 -7
  16. package/src/nitro/build-application.ts +43 -0
  17. package/src/nitro/create-plasm-nitro.ts +16 -2
  18. package/src/nitro/ensure-workflow-builder-dirs.ts +42 -0
  19. package/src/nitro/patch-vercel-config.ts +0 -3
  20. package/src/nitro/prepare-host.ts +5 -6
  21. package/src/nitro/workflow-directives.ts +1 -0
  22. package/src/nitro/write-nitro-schedule-tasks.ts +67 -0
  23. package/src/nitro/write-workflow-dispatch-route.ts +68 -0
  24. package/src/operator/routes.ts +6 -6
  25. package/src/project-info.ts +10 -6
  26. package/src/server/plasm-handler.ts +4 -4
  27. package/src/state/blob-state-adapter.ts +65 -0
  28. package/src/state/define-state.ts +10 -2
  29. package/src/state/fs-state-adapter.ts +1 -1
  30. package/src/storage/vercel-blob.ts +47 -0
  31. package/templates/mcp-radar/README.md +19 -16
  32. package/templates/mcp-radar/agent/channels/mcp-radar.ts +7 -7
  33. package/templates/mcp-radar/agent/schedules/mcp-radar-scan.ts +3 -5
  34. package/templates/mcp-radar/lib/proof-store.ts +43 -44
  35. package/templates/mcp-radar/lib/run-radar.ts +1 -1
  36. package/templates/mcp-radar/lib/start-mcp-radar.ts +12 -0
  37. package/templates/mcp-radar/scripts/provision-vercel-storage.mjs +18 -0
  38. package/templates/mcp-radar/workflows/mcp-radar-scan.ts +26 -0
@@ -9,6 +9,7 @@ import type { PreparedPlasmHost } from "./prepare-host.js";
9
9
  import {
10
10
  isVercelBuildEnvironment,
11
11
  plasmNitroBuildDir,
12
+ PLASM_NITRO_BUILD_DIR,
12
13
  plasmNitroOutputDir,
13
14
  plasmNitroRoutesDir,
14
15
  vercelOutputDir,
@@ -16,12 +17,16 @@ import {
16
17
  import { createPlasmVercelOptions } from "./vercel-build-output-config.js";
17
18
 
18
19
  const SERVER_TRACE_DEPS = [
20
+ "@ai-sdk/otel",
21
+ "@opentelemetry/api",
19
22
  "@plasm_lang/engine",
20
23
  "@plasm_lang/vercel-agent",
21
24
  "@vercel/functions",
22
25
  "@vercel/blob",
23
- "@vercel/kv",
24
26
  "@vercel/otel",
27
+ "ai",
28
+ "workflow",
29
+ "workflow/api",
25
30
  ];
26
31
 
27
32
  function resolveNitroPreset(dev: boolean): NitroConfig["preset"] {
@@ -70,6 +75,7 @@ export async function createPlasmNitro(
70
75
  const config: NitroConfig = {
71
76
  rootDir: host.projectRoot,
72
77
  buildDir: plasmNitroBuildDir(host.projectRoot),
78
+ scanDirs: [path.join(host.projectRoot, PLASM_NITRO_BUILD_DIR)],
73
79
  preset,
74
80
  dev,
75
81
  serverDir: false,
@@ -77,6 +83,15 @@ export async function createPlasmNitro(
77
83
  handlers: [{ route: "/**", handler: catchAllHandler }],
78
84
  publicAssets,
79
85
  modules,
86
+ experimental: {
87
+ tasks: Object.keys(host.scheduledTasks).length > 0,
88
+ },
89
+ scheduledTasks: host.scheduledTasks,
90
+ workflow: host.workflowEnabled
91
+ ? {
92
+ dirs: ["workflows"],
93
+ }
94
+ : undefined,
80
95
  traceDeps: [
81
96
  ...traceDeps,
82
97
  "./agent/**",
@@ -96,7 +111,6 @@ export async function createPlasmNitro(
96
111
  },
97
112
  config: {
98
113
  ...vercelConfig,
99
- crons: host.scheduleCrons,
100
114
  },
101
115
  }
102
116
  : undefined,
@@ -0,0 +1,42 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ const WORKFLOW_DIRS_NEEDLE =
6
+ "dirs: ['.'], // Different apps that use nitro have different directories";
7
+ const WORKFLOW_DIRS_REPLACEMENT =
8
+ "dirs: nitro.options.workflow?.dirs ?? ['workflows'], // plasm: scoped workflow scan";
9
+
10
+ async function patchBuildersFile(buildersPath: string): Promise<void> {
11
+ const source = await readFile(buildersPath, "utf8");
12
+ if (!source.includes(WORKFLOW_DIRS_NEEDLE)) return;
13
+ await writeFile(
14
+ buildersPath,
15
+ source.replaceAll(WORKFLOW_DIRS_NEEDLE, WORKFLOW_DIRS_REPLACEMENT),
16
+ "utf8",
17
+ );
18
+ }
19
+
20
+ /** @workflow/nitro hardcodes dirs: ['.'] — scope to nitro.options.workflow.dirs for Eve apps. */
21
+ export async function ensureWorkflowBuilderDirs(projectRoot: string): Promise<void> {
22
+ const candidates = new Set<string>();
23
+ candidates.add(path.join(projectRoot, "node_modules/@workflow/nitro/dist/builders.js"));
24
+
25
+ try {
26
+ const require = createRequire(import.meta.url);
27
+ const nitroEntry = require.resolve("@workflow/nitro");
28
+ candidates.add(path.join(path.dirname(nitroEntry), "builders.js"));
29
+ } catch {
30
+ // framework package may not resolve @workflow/nitro in all contexts
31
+ }
32
+
33
+ await Promise.all(
34
+ [...candidates].map(async (buildersPath) => {
35
+ try {
36
+ await patchBuildersFile(buildersPath);
37
+ } catch {
38
+ // optional copy
39
+ }
40
+ }),
41
+ );
42
+ }
@@ -29,9 +29,6 @@ export async function patchVercelOutputConfig(
29
29
  config.framework = {
30
30
  version: frameworkPackageVersion(),
31
31
  };
32
- if (host.scheduleCrons.length > 0) {
33
- config.crons = host.scheduleCrons;
34
- }
35
32
 
36
33
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
37
34
  }
@@ -5,7 +5,7 @@ import type { CompiledSlotMap } from "../cli/compile-authored-slots.js";
5
5
  import { loadAuthoredSlots } from "../authoring/slot-loader.js";
6
6
  import type { AgentDefinition } from "../define-agent.js";
7
7
  import { walkAgentProject, type ProjectDiscovery } from "../discovery/project-walker.js";
8
- import { exportScheduleCronManifest } from "../authoring/schedule-manager.js";
8
+ import { exportScheduleTaskManifest } from "../authoring/schedule-manager.js";
9
9
  import { projectUsesWorkflowDirectives } from "./workflow-directives.js";
10
10
 
11
11
  export interface PreparedPlasmHost {
@@ -15,6 +15,7 @@ export interface PreparedPlasmHost {
15
15
  discovery: ProjectDiscovery;
16
16
  compiledSlots: CompiledSlotMap;
17
17
  scheduleCrons: Array<{ path: string; schedule: string }>;
18
+ scheduledTasks: Record<string, string | string[]>;
18
19
  externalDeps: string[];
19
20
  workflowEnabled: boolean;
20
21
  }
@@ -46,10 +47,7 @@ export async function preparePlasmHost(options: {
46
47
  compiledSlots,
47
48
  });
48
49
 
49
- const scheduleCrons = exportScheduleCronManifest(loadedSlots.schedules).crons.map((c) => ({
50
- path: c.path,
51
- schedule: c.schedule,
52
- }));
50
+ const taskManifest = exportScheduleTaskManifest(loadedSlots.schedules);
53
51
 
54
52
  const externalDeps = definition.build?.externalDependencies ?? [];
55
53
  const workflowConfigured = definition.experimental?.workflow !== undefined;
@@ -62,7 +60,8 @@ export async function preparePlasmHost(options: {
62
60
  definition,
63
61
  discovery,
64
62
  compiledSlots,
65
- scheduleCrons,
63
+ scheduleCrons: [],
64
+ scheduledTasks: taskManifest.scheduledTasks,
66
65
  externalDeps,
67
66
  workflowEnabled,
68
67
  };
@@ -28,6 +28,7 @@ async function walkTsFiles(dir: string): Promise<string[]> {
28
28
  /** True when authored sources contain Workflow SDK directives (needs @workflow/nitro). */
29
29
  export async function projectUsesWorkflowDirectives(projectRoot: string): Promise<boolean> {
30
30
  const roots = [
31
+ path.join(projectRoot, "workflows"),
31
32
  path.join(projectRoot, "agent"),
32
33
  path.join(projectRoot, "lib"),
33
34
  ];
@@ -0,0 +1,67 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { CompiledSlotMap } from "../cli/compile-authored-slots.js";
5
+ import type { LoadedSchedule } from "../authoring/slot-loader.js";
6
+ import { PLASM_NITRO_BUILD_DIR } from "./paths.js";
7
+
8
+ function compiledScheduleImport(
9
+ schedule: LoadedSchedule,
10
+ agentRoot: string,
11
+ compiledSlots: CompiledSlotMap,
12
+ projectRoot: string,
13
+ ): string {
14
+ const relFromAgent = path.relative(agentRoot, schedule.sourcePath);
15
+ const compiled = compiledSlots[relFromAgent];
16
+ if (!compiled) {
17
+ throw new Error(
18
+ `missing compiled schedule slot for ${schedule.definition.name} (${relFromAgent})`,
19
+ );
20
+ }
21
+ const relImport = path
22
+ .relative(path.join(projectRoot, PLASM_NITRO_BUILD_DIR, "tasks"), path.join(projectRoot, compiled))
23
+ .replace(/\\/g, "/");
24
+ return relImport.startsWith(".") ? relImport : `./${relImport}`;
25
+ }
26
+
27
+ /** Emit Nitro scheduled tasks (Eve-aligned — no HTTP /internal/cron routes). */
28
+ export async function writeNitroScheduleTasks(options: {
29
+ projectRoot: string;
30
+ agentRoot: string;
31
+ schedules: LoadedSchedule[];
32
+ compiledSlots: CompiledSlotMap;
33
+ }): Promise<void> {
34
+ const { projectRoot, agentRoot, schedules, compiledSlots } = options;
35
+ const tasksDir = path.join(projectRoot, PLASM_NITRO_BUILD_DIR, "tasks");
36
+ await mkdir(tasksDir, { recursive: true });
37
+
38
+ for (const schedule of schedules) {
39
+ const name = schedule.definition.name;
40
+ const importPath = compiledScheduleImport(schedule, agentRoot, compiledSlots, projectRoot);
41
+ const source = `import path from "node:path";
42
+
43
+ import agentDefinition from "../../../agent/agent.js";
44
+ import { createPlasmApp } from "@plasm_lang/vercel-agent/server";
45
+ import scheduleSlot from "${importPath}";
46
+
47
+ const agentRoot = path.join(process.cwd(), "agent");
48
+
49
+ export default {
50
+ meta: {
51
+ description: ${JSON.stringify(`Plasm schedule: ${name}`)},
52
+ },
53
+ async run() {
54
+ const app = await createPlasmApp({
55
+ agentRoot,
56
+ definition: agentDefinition,
57
+ mode: "prod",
58
+ sessions: false,
59
+ });
60
+ const definition = scheduleSlot.default ?? scheduleSlot;
61
+ await definition.handler(app.getAuthoringContext());
62
+ },
63
+ };
64
+ `;
65
+ await writeFile(path.join(tasksDir, `${name}.ts`), source, "utf8");
66
+ }
67
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+
4
+ const WORKFLOW_DISPATCH_ROUTE = "/internal/workflow/dispatch";
5
+
6
+ /** Emit workflow dispatch route for durable steps (no Plasm imports in workflow bundles). */
7
+ export async function writeWorkflowDispatchRoute(projectRoot: string): Promise<string> {
8
+ const routesDir = path.join(projectRoot, ".plasm", "nitro", "routes", "internal", "workflow");
9
+ await mkdir(routesDir, { recursive: true });
10
+ const routePath = path.join(routesDir, "dispatch.post.ts");
11
+ const source = `import path from "node:path";
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+
14
+ import agentDefinition from "../../../../agent/agent.js";
15
+ import { createPlasmApp } from "@plasm_lang/vercel-agent/server";
16
+
17
+ const agentRoot = path.join(process.cwd(), "agent");
18
+
19
+ type NitroNodeEvent = {
20
+ node: { req: IncomingMessage; res: ServerResponse };
21
+ };
22
+
23
+ async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
24
+ const chunks: Buffer[] = [];
25
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
26
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
27
+ if (!raw) return {};
28
+ return JSON.parse(raw) as Record<string, unknown>;
29
+ }
30
+
31
+ export default async (event: NitroNodeEvent) => {
32
+ const { req, res } = event.node;
33
+ if ((req.method ?? "GET").toUpperCase() !== "POST") {
34
+ res.statusCode = 405;
35
+ res.end("Method Not Allowed");
36
+ return;
37
+ }
38
+
39
+ const body = await readJson(req);
40
+ const job = String(body.job ?? "");
41
+ const force = body.force === true;
42
+
43
+ if (job !== "mcp-radar-scan") {
44
+ res.statusCode = 404;
45
+ res.setHeader("content-type", "application/json; charset=utf-8");
46
+ res.end(JSON.stringify({ error: "unknown_job", job }));
47
+ return;
48
+ }
49
+
50
+ const app = await createPlasmApp({
51
+ agentRoot,
52
+ definition: agentDefinition,
53
+ mode: "prod",
54
+ sessions: false,
55
+ });
56
+ const { runRadar } = await import("../../../../lib/run-radar.js");
57
+ const result = await runRadar(app.getAuthoringContext(), { force });
58
+
59
+ res.statusCode = 200;
60
+ res.setHeader("content-type", "application/json; charset=utf-8");
61
+ res.end(JSON.stringify(result));
62
+ };
63
+ `;
64
+ await writeFile(routePath, source, "utf8");
65
+ return WORKFLOW_DISPATCH_ROUTE;
66
+ }
67
+
68
+ export { WORKFLOW_DISPATCH_ROUTE };
@@ -200,23 +200,23 @@ export function createOperatorRoutes(ctx: OperatorRouteContext): OperatorHandler
200
200
  },
201
201
 
202
202
  async listTraces() {
203
- const { LocalArchiveStore } = await import("../archive/index.js");
204
- const archive = LocalArchiveStore.fromAgentRoot(ctx.agentRoot);
203
+ const { createArchiveStore } = await import("../archive/resolve-backend.js");
204
+ const archive = createArchiveStore(ctx.agentRoot);
205
205
  const tenantScope = ctx.tenantScope ?? "local";
206
206
  const traces = await archive.listTraces(tenantScope);
207
207
  return { traces };
208
208
  },
209
209
 
210
210
  async listArchives() {
211
- const { LocalArchiveStore } = await import("../archive/index.js");
212
- const archive = LocalArchiveStore.fromAgentRoot(ctx.agentRoot);
211
+ const { createArchiveStore } = await import("../archive/resolve-backend.js");
212
+ const archive = createArchiveStore(ctx.agentRoot);
213
213
  const { plans, runs, paths } = await archive.listArchives();
214
214
  return { plans, runs, paths };
215
215
  },
216
216
 
217
217
  async listRuns() {
218
- const { LocalArchiveStore } = await import("../archive/index.js");
219
- const archive = LocalArchiveStore.fromAgentRoot(ctx.agentRoot);
218
+ const { createArchiveStore } = await import("../archive/resolve-backend.js");
219
+ const archive = createArchiveStore(ctx.agentRoot);
220
220
  const runs = await archive.listRuns();
221
221
  return { runs };
222
222
  },
@@ -7,7 +7,7 @@ import type { AgentDefinition } from "./define-agent.js";
7
7
  import type { ProjectDiscovery } from "./discovery/project-walker.js";
8
8
  import { walkAgentProject } from "./discovery/project-walker.js";
9
9
  import { isNativeEngineAvailable } from "./engine/napi-binding.js";
10
- import { exportScheduleCronManifest } from "./authoring/schedule-manager.js";
10
+ import { exportScheduleTaskManifest } from "./authoring/schedule-manager.js";
11
11
  import { isGatewayConfigured } from "./gateway-model.js";
12
12
  import type { LoadedProjectSlots } from "./authoring/slot-loader.js";
13
13
  import { frameworkPackageVersion } from "./package-version.js";
@@ -38,7 +38,8 @@ export interface ProjectInfoRoutes {
38
38
  sessionContinue: string;
39
39
  sessionStream: string;
40
40
  channels: Array<{ method: string; path: string }>;
41
- scheduleCrons: string[];
41
+ scheduledTasks: string[];
42
+ scheduleDevDispatch: string[];
42
43
  operator: string;
43
44
  }
44
45
 
@@ -75,7 +76,7 @@ export interface ProjectInfoPayload {
75
76
  modelOptions: AgentDefinition["modelOptions"] | null;
76
77
  build: AgentDefinition["build"] | null;
77
78
  experimental: AgentDefinition["experimental"] | null;
78
- scheduleCrons: ReturnType<typeof exportScheduleCronManifest> | null;
79
+ scheduledTasks: ReturnType<typeof exportScheduleTaskManifest> | null;
79
80
  sessions: { active: number };
80
81
  routes: ProjectInfoRoutes;
81
82
  };
@@ -176,14 +177,14 @@ export async function collectProjectInfo(
176
177
  };
177
178
 
178
179
  if (dev) {
179
- const scheduleCrons = exportScheduleCronManifest(loadedSlots.schedules);
180
+ const scheduledTasks = exportScheduleTaskManifest(loadedSlots.schedules);
180
181
  payload.dev = {
181
182
  model: dev.definition.model,
182
183
  compaction: dev.definition.compaction ?? null,
183
184
  modelOptions: dev.definition.modelOptions ?? null,
184
185
  build: dev.definition.build ?? null,
185
186
  experimental: dev.definition.experimental ?? null,
186
- scheduleCrons,
187
+ scheduledTasks,
187
188
  sessions: { active: dev.sessionCount },
188
189
  routes: {
189
190
  health: "GET /plasm/v1/health",
@@ -192,7 +193,10 @@ export async function collectProjectInfo(
192
193
  sessionContinue: "POST /plasm/v1/session/:sessionId",
193
194
  sessionStream: "GET /plasm/v1/session/:id/stream",
194
195
  channels: listChannelRoutes(loadedSlots.channels),
195
- scheduleCrons: scheduleCrons.crons.map((c) => `GET ${c.path}`),
196
+ scheduledTasks: scheduledTasks.tasks.map((t) => `nitro:task/${t.name}`),
197
+ scheduleDevDispatch: scheduledTasks.tasks.map(
198
+ (t) => `POST /internal/schedule/${t.name}`,
199
+ ),
196
200
  operator: "GET /operator",
197
201
  },
198
202
  };
@@ -6,7 +6,7 @@ import {
6
6
  createAgentFromDefinition,
7
7
  resolveAgentDefinition,
8
8
  } from "../define-agent.js";
9
- import { readBuildManifest } from "../cli/build.js";
9
+ import { readBuildManifest } from "../cli/build-manifest.js";
10
10
  import { createAuthoringContext, type AuthoringContext } from "../authoring/context.js";
11
11
  import { tryHandleChannelRoute } from "../authoring/channel-dispatch.js";
12
12
  import {
@@ -20,8 +20,8 @@ import {
20
20
  type SubagentRegistry,
21
21
  } from "../authoring/subagent-loader.js";
22
22
  import {
23
+ tryHandleScheduleDevDispatch,
23
24
  startScheduleTimers,
24
- tryHandleScheduleCronRoute,
25
25
  type ScheduleHandle,
26
26
  } from "../authoring/schedule-manager.js";
27
27
  import { walkAgentProject, type ProjectDiscovery } from "../discovery/project-walker.js";
@@ -143,13 +143,13 @@ export async function handlePlasmRequest(
143
143
 
144
144
  const loadedSlots = await app.getLoadedSlots();
145
145
  if (loadedSlots?.schedules.length) {
146
- const handledCron = tryHandleScheduleCronRoute(
146
+ const handledSchedule = tryHandleScheduleDevDispatch(
147
147
  req,
148
148
  res,
149
149
  loadedSlots.schedules,
150
150
  app.getAuthoringContext(),
151
151
  );
152
- if (handledCron) return;
152
+ if (handledSchedule) return;
153
153
  }
154
154
 
155
155
  if (loadedSlots?.channels.length) {
@@ -0,0 +1,65 @@
1
+ import type { SymbolRegistrySnapshot } from "../symbol-registry.js";
2
+ import type { AgentSessionState } from "../session-state.js";
3
+ import {
4
+ blobGetJson,
5
+ blobList,
6
+ blobPutJson,
7
+ } from "../storage/vercel-blob.js";
8
+ import type { AgentStateStore, StateBackend } from "./define-state.js";
9
+ import { intentKey } from "./fs-state-adapter.js";
10
+
11
+ function sessionBlobKey(tenantScope: string, intent: string): string {
12
+ return `plasm/state/${tenantScope}/sessions/${intentKey(intent)}.json`;
13
+ }
14
+
15
+ function symbolsBlobKey(tenantScope: string): string {
16
+ return `plasm/state/${tenantScope}/symbols.json`;
17
+ }
18
+
19
+ function sessionPrefix(tenantScope: string): string {
20
+ return `plasm/state/${tenantScope}/sessions/`;
21
+ }
22
+
23
+ export class BlobStateAdapter implements AgentStateStore {
24
+ constructor(
25
+ private readonly agentRoot: string,
26
+ private readonly tenantScope: string,
27
+ ) {
28
+ void this.agentRoot;
29
+ }
30
+
31
+ backend(): StateBackend {
32
+ return "blob";
33
+ }
34
+
35
+ async get(intent: string): Promise<AgentSessionState | null> {
36
+ return blobGetJson<AgentSessionState>(sessionBlobKey(this.tenantScope, intent));
37
+ }
38
+
39
+ async put(state: AgentSessionState): Promise<void> {
40
+ await blobPutJson(sessionBlobKey(this.tenantScope, state.intent), state);
41
+ }
42
+
43
+ async listIntents(): Promise<string[]> {
44
+ const prefix = sessionPrefix(this.tenantScope);
45
+ const paths = await blobList(prefix);
46
+ const intents: string[] = [];
47
+ for (const pathname of paths) {
48
+ if (!pathname.endsWith(".json")) continue;
49
+ const state = await blobGetJson<AgentSessionState>(pathname);
50
+ if (state?.intent) intents.push(state.intent);
51
+ }
52
+ return intents;
53
+ }
54
+
55
+ async getSymbolRegistry(tenantId: string): Promise<SymbolRegistrySnapshot | null> {
56
+ return blobGetJson<SymbolRegistrySnapshot>(symbolsBlobKey(tenantId));
57
+ }
58
+
59
+ async putSymbolRegistry(
60
+ tenantId: string,
61
+ snapshot: SymbolRegistrySnapshot,
62
+ ): Promise<void> {
63
+ await blobPutJson(symbolsBlobKey(tenantId), snapshot);
64
+ }
65
+ }
@@ -1,11 +1,14 @@
1
1
  import type { AgentWorkflowWorldDefinition } from "../define-agent.js";
2
+ import { isVercelHosted } from "../gateway-model.js";
2
3
  import type { SymbolRegistrySnapshot } from "../symbol-registry.js";
3
4
  import type { AgentSessionState, SessionStore } from "../session-state.js";
5
+ import { hasLinkedBlobStore } from "../storage/vercel-blob.js";
6
+ import { BlobStateAdapter } from "./blob-state-adapter.js";
4
7
  import { FsStateAdapter } from "./fs-state-adapter.js";
5
8
  import { KvStateAdapter } from "./kv-state-adapter.js";
6
9
  import { PostgresStateAdapter } from "./postgres-state-adapter.js";
7
10
 
8
- export type StateBackend = "fs" | "kv" | "postgres";
11
+ export type StateBackend = "fs" | "blob" | "kv" | "postgres";
9
12
 
10
13
  export interface AgentStateStore extends SessionStore {
11
14
  backend(): StateBackend;
@@ -17,12 +20,15 @@ export function resolveStateBackend(
17
20
  world?: AgentWorkflowWorldDefinition,
18
21
  ): StateBackend {
19
22
  const explicit = process.env.PLASM_STATE_BACKEND?.trim().toLowerCase();
20
- if (explicit === "fs" || explicit === "kv" || explicit === "postgres") {
23
+ if (explicit === "fs" || explicit === "blob" || explicit === "kv" || explicit === "postgres") {
21
24
  return explicit;
22
25
  }
23
26
  if (process.env.KV_REST_API_URL?.trim() || process.env.PLASM_KV_REST_API_URL?.trim()) {
24
27
  return "kv";
25
28
  }
29
+ if (isVercelHosted() || hasLinkedBlobStore()) {
30
+ return "blob";
31
+ }
26
32
  if (
27
33
  process.env.WORKFLOW_POSTGRES_URL?.trim() ||
28
34
  process.env.DATABASE_URL?.trim() ||
@@ -47,6 +53,8 @@ export function createAgentStateStore(options: {
47
53
  const backend = options.backend ?? resolveStateBackend(options.world);
48
54
  const tenantScope = options.tenantScope ?? "local";
49
55
  switch (backend) {
56
+ case "blob":
57
+ return new BlobStateAdapter(options.agentRoot, tenantScope);
50
58
  case "kv":
51
59
  return new KvStateAdapter(options.agentRoot, tenantScope);
52
60
  case "postgres":
@@ -8,7 +8,7 @@ import {
8
8
  } from "../session-state.js";
9
9
  import type { AgentStateStore, StateBackend } from "./define-state.js";
10
10
 
11
- function intentKey(intent: string): string {
11
+ export function intentKey(intent: string): string {
12
12
  return Buffer.from(intent, "utf8").toString("base64url");
13
13
  }
14
14
 
@@ -0,0 +1,47 @@
1
+ /** Eve-aligned Vercel Blob helpers — OIDC on hosted Vercel, no manual tokens required. */
2
+
3
+ export function hasLinkedBlobStore(): boolean {
4
+ return Boolean(
5
+ process.env.BLOB_READ_WRITE_TOKEN?.trim() ||
6
+ process.env.BLOB_STORE_ID?.trim() ||
7
+ process.env.PLASM_RUN_ARTIFACTS_URL?.trim(),
8
+ );
9
+ }
10
+
11
+ export async function blobGetText(key: string): Promise<string | null> {
12
+ const { head } = await import("@vercel/blob");
13
+ const meta = await head(key).catch(() => null);
14
+ if (!meta?.url) return null;
15
+ const response = await fetch(meta.url);
16
+ if (!response.ok) return null;
17
+ return response.text();
18
+ }
19
+
20
+ export async function blobPutText(key: string, body: string): Promise<void> {
21
+ const { put } = await import("@vercel/blob");
22
+ await put(key, body, { access: "public", addRandomSuffix: false });
23
+ }
24
+
25
+ export async function blobGetJson<T>(key: string): Promise<T | null> {
26
+ const text = await blobGetText(key);
27
+ if (!text) return null;
28
+ return JSON.parse(text) as T;
29
+ }
30
+
31
+ export async function blobPutJson(key: string, value: unknown): Promise<void> {
32
+ await blobPutText(key, `${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ export async function blobList(prefix: string): Promise<string[]> {
36
+ const { list } = await import("@vercel/blob");
37
+ const keys: string[] = [];
38
+ let cursor: string | undefined;
39
+ do {
40
+ const page = await list({ prefix, cursor });
41
+ for (const blob of page.blobs) {
42
+ keys.push(blob.pathname);
43
+ }
44
+ cursor = page.hasMore ? page.cursor : undefined;
45
+ } while (cursor);
46
+ return keys;
47
+ }
@@ -26,24 +26,25 @@ The canonical template source is this directory (`examples/mcp-radar-agent/`).
26
26
 
27
27
  ## Deploy to Vercel
28
28
 
29
- Low-friction production path (eve-aligned): `vercel.json` + catch-all `api/[[...path]].ts` mounts the same routes as local dev (channels, cron, `/plasm/v1/info`).
29
+ Eve-aligned production path: link Blob once, deploy **no manual env vars on Vercel** for Gateway, Blob, or cron auth.
30
30
 
31
31
  ```bash
32
- cd examples/mcp-radar-agent # or your bootstrapped project
32
+ cd examples/mcp-radar-agent
33
33
  plasm-agent link
34
+ node scripts/provision-vercel-storage.mjs # vercel blob create-store → project OIDC
34
35
  plasm-agent build
35
- vercel env pull .env.local # AI_GATEWAY_API_KEY, CRON_SECRET, KV, Blob
36
- vercel deploy
36
+ vercel deploy --prod
37
37
  curl -s "$DEPLOY_URL/channel/mcp-radar/status" | jq .
38
38
  ```
39
39
 
40
- | Variable | Deploy |
41
- |----------|--------|
42
- | `AI_GATEWAY_API_KEY` | Required for agent synthesis |
43
- | `CRON_SECRET` | Required Vercel Cron hits `/internal/cron/mcp-radar-scan` |
44
- | `KV_REST_API_URL` + `KV_REST_API_TOKEN` | Durable seen-items + last-run state |
45
- | `BLOB_READ_WRITE_TOKEN` | Durable proof markdown log |
46
- | `TAVILY_API_TOKEN` | Optional corroboration |
40
+ | Concern | On Vercel |
41
+ |---------|-----------|
42
+ | AI Gateway | OIDC via linked project — no API key |
43
+ | Proof log + dedupe state | `@vercel/blob` only (markdown + JSON objects) |
44
+ | Cron | Platform `x-vercel-cron` no `CRON_SECRET` required |
45
+ | Local dev | `vercel env pull .env.local` + optional `AI_GATEWAY_API_KEY` |
46
+
47
+ `vercel blob create-store` wires Blob to the project; runtime auth is OIDC (same model as [eve content agent](https://github.com/vercel-labs/eve-content-agent-template)).
47
48
 
48
49
  On Vercel, `POST /channel/mcp-radar/run` returns `202` and continues in the background via `waitUntil`. Locally it runs synchronously for smoke tests.
49
50
 
@@ -59,7 +60,7 @@ cp .env.example .env.local
59
60
 
60
61
  | Variable | Required | Role |
61
62
  |----------|----------|------|
62
- | `AI_GATEWAY_API_KEY` | Yes (live runs) | Agent model turns |
63
+ | `AI_GATEWAY_API_KEY` | Local only | Agent model turns off-Vercel |
63
64
  | `TAVILY_API_TOKEN` | Optional | Tavily corroboration; HN-only when unset |
64
65
 
65
66
  Build CGS stubs (symlinked catalogs):
@@ -82,7 +83,7 @@ Slash commands in the interactive TUI: `/info`, `/catalogs`, `/new`, `/quit`.
82
83
 
83
84
  ## Channel API
84
85
 
85
- Start headless dev server (`npm run dev:headless`), then:
86
+ Start headless dev server (`npm run dev`), then:
86
87
 
87
88
  ```bash
88
89
  BASE=http://127.0.0.1:3000
@@ -102,16 +103,18 @@ curl -s -X POST "$BASE/channel/mcp-radar/run" -H 'content-type: application/json
102
103
 
103
104
  Proof artifact (local fs): [`agent/research/mcp-innovations-proof.md`](./agent/research/mcp-innovations-proof.md)
104
105
 
105
- Dedupe state (local fs): `agent/.plasm/research/seen-hn-items.json` (gitignored). On Vercel deploy, proof + dedupe use Blob + KV when env vars are set.
106
+ Dedupe state (local fs): `agent/.plasm/research/seen-hn-items.json` (gitignored). On Vercel, proof + dedupe + last-run use Blob paths under `research/`.
106
107
 
107
108
  ## Schedule
108
109
 
109
- `agent/schedules/mcp-radar-scan.ts` runs every **6 hours** (`0 */6 * * *`). Vercel Cron calls `/internal/cron/mcp-radar-scan` with `Authorization: Bearer $CRON_SECRET`.
110
+ `agent/schedules/mcp-radar-scan.ts` runs every **6 hours** (`0 */6 * * *`) as a **Nitro scheduled task** (Vercel Cron auto-wired at deploy). Durable execution uses **Vercel Workflows** (`workflow/api` `start()`).
111
+
112
+ Manual dev trigger: `POST /internal/schedule/mcp-radar-scan`
110
113
 
111
114
  ## Evals
112
115
 
113
116
  ```bash
114
- npm run eval # requires AI_GATEWAY_API_KEY
117
+ npm run eval # requires AI_GATEWAY_API_KEY locally
115
118
  ```
116
119
 
117
120
  Live eval: `evals/mcp-radar-discover.eval.ts`