@plasm_lang/vercel-agent 0.3.111 → 0.3.113

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 (36) hide show
  1. package/package.json +5 -4
  2. package/src/archive/prod-archive-store.ts +3 -2
  3. package/src/instrumentation.ts +8 -13
  4. package/src/load-env.ts +50 -2
  5. package/src/nitro/create-plasm-nitro.ts +5 -0
  6. package/src/nitro/write-workflow-dispatch-route.ts +2 -1
  7. package/src/runtime/agent-runtime.ts +4 -0
  8. package/src/server/plasm-handler.ts +12 -6
  9. package/src/server/resolve-app-options.ts +33 -0
  10. package/src/stubs/plasm-value-emitter.ts +21 -0
  11. package/src/stubs/stub-symbols.ts +34 -0
  12. package/templates/mcp-radar/README.md +11 -6
  13. package/templates/mcp-radar/agent/channels/mcp-radar.ts +21 -14
  14. package/templates/mcp-radar/agent/hooks/proof-audit.ts +16 -2
  15. package/templates/mcp-radar/agent/instructions.md +23 -11
  16. package/templates/mcp-radar/agent/instrumentation.ts +3 -4
  17. package/templates/mcp-radar/agent/skills/proof-publish.md +15 -0
  18. package/templates/mcp-radar/lib/hn-algolia-preflight.ts +42 -0
  19. package/templates/mcp-radar/lib/instructions.md +36 -0
  20. package/templates/mcp-radar/lib/mcp-radar-scan.ts +28 -0
  21. package/templates/mcp-radar/lib/mcp-radar.ts +92 -0
  22. package/templates/mcp-radar/lib/proof-extract.ts +43 -0
  23. package/templates/mcp-radar/lib/proof-store.ts +38 -0
  24. package/templates/mcp-radar/lib/provision-vercel.mjs +98 -0
  25. package/templates/mcp-radar/lib/radar-state.ts +53 -0
  26. package/templates/mcp-radar/lib/run-audit.ts +41 -0
  27. package/templates/mcp-radar/lib/run-radar.ts +37 -139
  28. package/templates/mcp-radar/lib/smoke-channel.ts +42 -0
  29. package/templates/mcp-radar/lib/start-mcp-radar.ts +1 -0
  30. package/templates/mcp-radar/lib/test-hn-algolia-preflight.mjs +27 -0
  31. package/templates/mcp-radar/package.json +21 -3
  32. package/templates/mcp-radar/scripts/provision-vercel.mjs +111 -0
  33. package/templates/mcp-radar/scripts/test-hn-algolia-preflight.mjs +27 -0
  34. package/templates/mcp-radar/scripts/test-proof-extract.mjs +24 -0
  35. package/templates/mcp-radar/workflows/mcp-radar-scan.ts +4 -4
  36. package/templates/scaffold/agent/instrumentation.ts +3 -11
@@ -0,0 +1,42 @@
1
+ /** Direct Algolia HN search for MCP radar preflight (no Plasm stub / engine required). */
2
+
3
+ const ALGOLIA_SEARCH_BY_DATE = "https://hn.algolia.com/api/v1/search_by_date";
4
+
5
+ export interface AlgoliaHit {
6
+ objectID: string;
7
+ title?: string;
8
+ url?: string;
9
+ }
10
+
11
+ export interface HnAlgoliaStoryRow {
12
+ id: string;
13
+ title?: string;
14
+ url?: string;
15
+ }
16
+
17
+ export async function fetchHnMcpStoriesByDate(options: {
18
+ query: string;
19
+ tags?: string;
20
+ perPage?: number;
21
+ }): Promise<HnAlgoliaStoryRow[]> {
22
+ const params = new URLSearchParams({
23
+ query: options.query,
24
+ tags: options.tags ?? "story",
25
+ hitsPerPage: String(options.perPage ?? 30),
26
+ });
27
+ const url = `${ALGOLIA_SEARCH_BY_DATE}?${params.toString()}`;
28
+ const response = await fetch(url, {
29
+ headers: { accept: "application/json" },
30
+ });
31
+ if (!response.ok) {
32
+ throw new Error(`Algolia HN search_by_date failed: ${response.status} ${response.statusText}`);
33
+ }
34
+ const payload = (await response.json()) as { hits?: AlgoliaHit[] };
35
+ return (payload.hits ?? [])
36
+ .map((hit) => ({
37
+ id: String(hit.objectID ?? ""),
38
+ title: hit.title,
39
+ url: hit.url,
40
+ }))
41
+ .filter((row) => row.id.length > 0);
42
+ }
@@ -0,0 +1,36 @@
1
+ # MCP Radar
2
+
3
+ You track **Model Context Protocol (MCP)** innovation on Hacker News, corroborate with Tavily, and **publish** to a live **Proof** document — all via **Plasm programs** in one federated execute session.
4
+
5
+ ## Tool order (mandatory)
6
+
7
+ 1. **`discover_capabilities`** — only when unsure which catalog entities to use.
8
+ 2. **`plasm_context`** — open or extend a session with seeds:
9
+ - `hackernews:Item`
10
+ - `tavily:SearchResult`
11
+ - `proof:Document`
12
+ - **Stable intent** (same every turn): `track MCP innovations from Hacker News and corroborate with Tavily web search`
13
+ 3. **`plasm`** — dry-run programs using teaching TSV symbols (`e#`, `m#`, `p#`, `r#`).
14
+ 4. **`plasm_run`** — live execute reviewed plans (`pcN` only).
15
+
16
+ Reuse **`logical_session_ref`** across the whole radar cycle. Do not open a new context per story.
17
+
18
+ ## HN search (Plasm)
19
+
20
+ Use `item_search_by_date` (or `item_search`) with an MCP-focused query, e.g.:
21
+
22
+ `"Model Context Protocol" OR MCP server OR MCP tool`
23
+
24
+ Filter to stories **about MCP** — not general Hacker News. Skip ids already in the Proof document body (read via `document_get_markdown` first).
25
+
26
+ ## Proof document (Plasm)
27
+
28
+ See skill **`proof-publish`**: bind share link (`document_share_bind`), `presence_update`, read markdown, append via `document_edit_v2`.
29
+
30
+ If no document exists yet, `share_link_create` then bind. Do not emit proof only in chat — **`plasm_run` must mutate Proof**.
31
+
32
+ ## Tavily
33
+
34
+ When Tavily is on the session, corroborate with `web_search`. If unavailable, set **Confidence: low** per `mcp-proof-format`.
35
+
36
+ Do not invent symbols — copy from the teaching TSV.
@@ -0,0 +1,28 @@
1
+ function workflowDispatchUrl(): string {
2
+ const explicit = process.env.PLASM_WORKFLOW_DISPATCH_URL?.trim();
3
+ if (explicit) return explicit;
4
+ const production = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
5
+ if (production) return `https://${production}/internal/workflow/dispatch`;
6
+ const vercelUrl = process.env.VERCEL_URL?.trim();
7
+ if (vercelUrl) return `https://${vercelUrl}/internal/workflow/dispatch`;
8
+ const port = process.env.PORT ?? "3000";
9
+ return `http://127.0.0.1:${port}/internal/workflow/dispatch`;
10
+ }
11
+
12
+ export async function mcpRadarScanWorkflow(_agentRoot: string, force = false, reset = false) {
13
+ "use workflow";
14
+ return mcpRadarScanStep(force, reset);
15
+ }
16
+
17
+ async function mcpRadarScanStep(force: boolean, reset: boolean) {
18
+ "use step";
19
+ const response = await fetch(workflowDispatchUrl(), {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify({ job: "mcp-radar-scan", force, reset }),
23
+ });
24
+ if (!response.ok) {
25
+ throw new Error(`workflow dispatch failed: ${response.status} ${await response.text()}`);
26
+ }
27
+ return response.json();
28
+ }
@@ -0,0 +1,92 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import { waitUntil } from "@vercel/functions";
4
+ import { defineChannel } from "@plasm_lang/vercel-agent";
5
+
6
+ import { radarStatus } from "../../lib/run-radar.js";
7
+ import { startMcpRadarRun } from "../../lib/start-mcp-radar.js";
8
+
9
+ function readJsonBody(req: IncomingMessage): Promise<unknown> {
10
+ return new Promise((resolve, reject) => {
11
+ let body = "";
12
+ req.on("data", (chunk) => {
13
+ body += chunk;
14
+ });
15
+ req.on("end", () => {
16
+ if (!body.trim()) {
17
+ resolve({});
18
+ return;
19
+ }
20
+ try {
21
+ resolve(JSON.parse(body));
22
+ } catch (err) {
23
+ reject(err);
24
+ }
25
+ });
26
+ req.on("error", reject);
27
+ });
28
+ }
29
+
30
+ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
31
+ res.statusCode = status;
32
+ res.setHeader("content-type", "application/json; charset=utf-8");
33
+ res.end(JSON.stringify(payload, null, 2));
34
+ }
35
+
36
+ export default defineChannel({
37
+ name: "mcp-radar",
38
+ routes: [
39
+ {
40
+ method: "POST",
41
+ path: "/channel/mcp-radar/run",
42
+ handler: async (req, res, ctx) => {
43
+ const body = (await readJsonBody(req)) as { force?: boolean; reset?: boolean };
44
+ const options = {
45
+ force: body.force === true,
46
+ reset: body.reset === true,
47
+ };
48
+
49
+ if (process.env.VERCEL === "1") {
50
+ waitUntil(
51
+ startMcpRadarRun(ctx, options).catch((err: unknown) => {
52
+ console.error("[mcp-radar] workflow start failed:", err);
53
+ }),
54
+ );
55
+ sendJson(res, 202, { accepted: true, workflow: true });
56
+ return;
57
+ }
58
+
59
+ const run = await startMcpRadarRun(ctx, options);
60
+ sendJson(res, 200, { accepted: true, workflow: true, run });
61
+ },
62
+ },
63
+ {
64
+ method: "POST",
65
+ path: "/channel/mcp-radar/reset",
66
+ handler: async (_req, res, _ctx) => {
67
+ sendJson(res, 200, {
68
+ ok: true,
69
+ message: 'Trigger POST /channel/mcp-radar/run with {"reset":true}',
70
+ });
71
+ },
72
+ },
73
+ {
74
+ method: "GET",
75
+ path: "/channel/mcp-radar/proof",
76
+ handler: async (_req, res, _ctx) => {
77
+ sendJson(res, 200, {
78
+ note: "Proof document lives in the proof catalog; read/write via agent Plasm session.",
79
+ sessions: "/operator/sessions",
80
+ runs: "/operator/runs",
81
+ });
82
+ },
83
+ },
84
+ {
85
+ method: "GET",
86
+ path: "/channel/mcp-radar/status",
87
+ handler: async (_req, res, ctx) => {
88
+ sendJson(res, 200, await radarStatus(ctx.agentRoot));
89
+ },
90
+ },
91
+ ],
92
+ });
@@ -0,0 +1,43 @@
1
+ const PROOF_HEADING = /^###\s+\[/;
2
+
3
+ /** Keep only `### [...]` proof blocks; drop agent preamble and troubleshooting narration. */
4
+ export function extractProofMarkdown(raw: string): string {
5
+ const lines = raw.split(/\r?\n/);
6
+ const blocks: string[] = [];
7
+ let current: string[] = [];
8
+
9
+ const flush = () => {
10
+ if (current.length === 0) return;
11
+ const block = current.join("\n").trim();
12
+ if (block) blocks.push(block);
13
+ current = [];
14
+ };
15
+
16
+ for (const line of lines) {
17
+ if (PROOF_HEADING.test(line)) {
18
+ flush();
19
+ current.push(line);
20
+ continue;
21
+ }
22
+ if (current.length > 0) {
23
+ const trimmed = line.trim();
24
+ if (trimmed === "") {
25
+ current.push(line);
26
+ continue;
27
+ }
28
+ if (line.startsWith("- ") || line.startsWith("* ")) {
29
+ current.push(line);
30
+ continue;
31
+ }
32
+ flush();
33
+ }
34
+ }
35
+ flush();
36
+
37
+ return blocks.join("\n\n").trim();
38
+ }
39
+
40
+ export function isMcpRelevant(title?: string, url?: string): boolean {
41
+ const haystack = `${title ?? ""} ${url ?? ""}`;
42
+ return /\b(MCP|Model Context Protocol|model[- ]context)\b/i.test(haystack);
43
+ }
@@ -27,6 +27,8 @@ export interface LastRunMeta {
27
27
  status: "ok" | "skipped" | "error";
28
28
  newItems: number;
29
29
  message?: string;
30
+ runIds?: string[];
31
+ logicalSessionRef?: string;
30
32
  }
31
33
 
32
34
  export type ProofStoreBackend = "fs" | "vercel";
@@ -35,6 +37,7 @@ export interface ProofStore {
35
37
  backend(): ProofStoreBackend;
36
38
  readProofMarkdown(): Promise<string>;
37
39
  appendProofRun(section: { runAt: string; body: string }): Promise<void>;
40
+ resetProofState(): Promise<void>;
38
41
  loadSeenState(): Promise<SeenState>;
39
42
  saveSeenState(state: SeenState): Promise<void>;
40
43
  addSeenIds(ids: string[]): Promise<SeenState>;
@@ -127,6 +130,26 @@ class FsProofStore implements ProofStore {
127
130
  await appendFile(proofPath(this.agentRoot), `${header}${block}\n`, "utf8");
128
131
  }
129
132
 
133
+ async resetProofState(): Promise<void> {
134
+ await mkdir(researchDir(this.agentRoot), { recursive: true });
135
+ await writeFile(proofPath(this.agentRoot), PROOF_HEADER, "utf8");
136
+ await writeFile(seenPath(this.agentRoot), `${JSON.stringify({ itemIds: [] }, null, 2)}\n`, "utf8");
137
+ await writeFile(
138
+ lastRunPath(this.agentRoot),
139
+ `${JSON.stringify(
140
+ {
141
+ at: new Date().toISOString(),
142
+ status: "skipped",
143
+ newItems: 0,
144
+ message: "reset",
145
+ } satisfies LastRunMeta,
146
+ null,
147
+ 2,
148
+ )}\n`,
149
+ "utf8",
150
+ );
151
+ }
152
+
130
153
  async loadSeenState(): Promise<SeenState> {
131
154
  try {
132
155
  const raw = await readFile(seenPath(this.agentRoot), "utf8");
@@ -184,6 +207,17 @@ class VercelProofStore implements ProofStore {
184
207
  await blobPutText(PROOF_BLOB_KEY, `${existing}${header}${block}\n`);
185
208
  }
186
209
 
210
+ async resetProofState(): Promise<void> {
211
+ await blobPutText(PROOF_BLOB_KEY, PROOF_HEADER);
212
+ await blobPutJson(SEEN_BLOB_KEY, { itemIds: [] });
213
+ await blobPutJson(LAST_RUN_BLOB_KEY, {
214
+ at: new Date().toISOString(),
215
+ status: "skipped",
216
+ newItems: 0,
217
+ message: "reset",
218
+ });
219
+ }
220
+
187
221
  async loadSeenState(): Promise<SeenState> {
188
222
  return parseSeenState(await blobGetJson<SeenState>(SEEN_BLOB_KEY));
189
223
  }
@@ -235,6 +269,10 @@ export async function appendProofRun(
235
269
  return resolveProofStore(agentRoot).appendProofRun(section);
236
270
  }
237
271
 
272
+ export async function resetProofState(agentRoot: string): Promise<void> {
273
+ return resolveProofStore(agentRoot).resetProofState();
274
+ }
275
+
238
276
  export async function loadSeenState(agentRoot: string): Promise<SeenState> {
239
277
  return resolveProofStore(agentRoot).loadSeenState();
240
278
  }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Eve-aligned Vercel bootstrap for mcp-radar:
4
+ * - Sync secrets from monorepo `.env` → Vercel project env
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const projectRoot = path.resolve(scriptDir, "..");
13
+
14
+ function run(command, args, options = {}) {
15
+ const result = spawnSync(command, args, options);
16
+ if (result.status !== 0) {
17
+ process.exit(result.status ?? 1);
18
+ }
19
+ return result;
20
+ }
21
+
22
+ function parseEnvFile(filePath) {
23
+ const vars = new Map();
24
+ if (!existsSync(filePath)) return vars;
25
+ const text = readFileSync(filePath, "utf8");
26
+ for (const line of text.split(/\r?\n/)) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith("#")) continue;
29
+ const withoutExport = trimmed.startsWith("export ")
30
+ ? trimmed.slice("export ".length).trim()
31
+ : trimmed;
32
+ const eq = withoutExport.indexOf("=");
33
+ if (eq <= 0) continue;
34
+ const key = withoutExport.slice(0, eq).trim();
35
+ let value = withoutExport.slice(eq + 1).trim();
36
+ if (
37
+ (value.startsWith('"') && value.endsWith('"')) ||
38
+ (value.startsWith("'") && value.endsWith("'"))
39
+ ) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ vars.set(key, value);
43
+ }
44
+ return vars;
45
+ }
46
+
47
+ function resolveMonorepoEnv() {
48
+ const files = [];
49
+ let dir = projectRoot;
50
+ for (let depth = 0; depth < 8; depth++) {
51
+ const candidate = path.join(dir, ".env");
52
+ if (existsSync(candidate)) files.push(candidate);
53
+ const parent = path.dirname(dir);
54
+ if (parent === dir) break;
55
+ dir = parent;
56
+ }
57
+ const merged = new Map();
58
+ for (const file of files.reverse()) {
59
+ for (const [key, value] of parseEnvFile(file)) {
60
+ if (value.trim()) merged.set(key, value);
61
+ }
62
+ }
63
+ return merged;
64
+ }
65
+
66
+ function envAlreadySet(name) {
67
+ const result = run("vercel", ["env", "ls"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
68
+ return (result.stdout ?? "").includes(name);
69
+ }
70
+
71
+ function syncEnvVar(name, value) {
72
+ if (!value?.trim()) {
73
+ console.warn(`[provision] skip ${name}: not found in monorepo .env`);
74
+ return;
75
+ }
76
+ if (envAlreadySet(name)) {
77
+ console.log(`[provision] ${name} already set on Vercel — skipping`);
78
+ return;
79
+ }
80
+ for (const target of ["production", "preview"]) {
81
+ console.log(`[provision] adding ${name} → ${target}…`);
82
+ run("vercel", ["env", "add", name, target], {
83
+ input: value,
84
+ stdio: ["pipe", "inherit", "inherit"],
85
+ });
86
+ }
87
+ }
88
+
89
+ const env = resolveMonorepoEnv();
90
+
91
+ console.log("Syncing env from monorepo .env → Vercel project…");
92
+ syncEnvVar("TAVILY_API_TOKEN", env.get("TAVILY_API_TOKEN"));
93
+ syncEnvVar("PLASM_TENANT_SCOPE", env.get("PLASM_TENANT_SCOPE") ?? "mcp-radar");
94
+ syncEnvVar("PROOF_API_TOKEN", env.get("PROOF_API_TOKEN"));
95
+ syncEnvVar("PROOF_SHARE_URL", env.get("PROOF_SHARE_URL"));
96
+ syncEnvVar("PROOF_DOCUMENT_SLUG", env.get("PROOF_DOCUMENT_SLUG"));
97
+
98
+ console.log("Done. Redeploy production so env bindings take effect.");
@@ -0,0 +1,53 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface LastRunMeta {
5
+ at: string;
6
+ status: "ok" | "skipped" | "error";
7
+ message?: string;
8
+ runIds?: string[];
9
+ logicalSessionRef?: string;
10
+ }
11
+
12
+ function researchDir(agentRoot: string): string {
13
+ return path.join(agentRoot, ".plasm", "research");
14
+ }
15
+
16
+ function lastRunPath(agentRoot: string): string {
17
+ return path.join(researchDir(agentRoot), "last-run.json");
18
+ }
19
+
20
+ export async function loadLastRun(agentRoot: string): Promise<LastRunMeta | null> {
21
+ try {
22
+ const raw = await readFile(lastRunPath(agentRoot), "utf8");
23
+ return JSON.parse(raw) as LastRunMeta;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export async function saveLastRun(agentRoot: string, meta: LastRunMeta): Promise<void> {
30
+ await mkdir(researchDir(agentRoot), { recursive: true });
31
+ await writeFile(lastRunPath(agentRoot), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
32
+ }
33
+
34
+ /** Host infra — not a substitute for Plasm catalog calls. */
35
+ export function gatewayConfigured(): boolean {
36
+ if (
37
+ process.env.AI_GATEWAY_API_KEY?.trim() ||
38
+ process.env.AI_API_GATEWAY_KEY?.trim() ||
39
+ process.env.AI_GATEWAY_KEY?.trim()
40
+ ) {
41
+ return true;
42
+ }
43
+ return (
44
+ process.env.VERCEL === "1" ||
45
+ Boolean(process.env.VERCEL_DEPLOYMENT_ID?.trim()) ||
46
+ Boolean(process.env.VERCEL_ENV?.trim())
47
+ );
48
+ }
49
+
50
+ /** Outbound Tavily auth present on host — agent still calls Tavily via Plasm. */
51
+ export function tavilyConfigured(): boolean {
52
+ return Boolean(process.env.TAVILY_API_TOKEN?.trim());
53
+ }
@@ -0,0 +1,41 @@
1
+ export interface RunAuditRecord {
2
+ runIds: string[];
3
+ planCommitRefs: string[];
4
+ logicalSessionRef?: string;
5
+ intent?: string;
6
+ }
7
+
8
+ let pending: RunAuditRecord = {
9
+ runIds: [],
10
+ planCommitRefs: [],
11
+ };
12
+
13
+ export function resetRunAudit(): void {
14
+ pending = { runIds: [], planCommitRefs: [] };
15
+ }
16
+
17
+ export function recordRunAudit(detail: Record<string, unknown>): void {
18
+ const runId = typeof detail.runId === "string" ? detail.runId : undefined;
19
+ const planCommitRef =
20
+ typeof detail.planCommitRef === "string"
21
+ ? detail.planCommitRef
22
+ : typeof detail.runRef === "string"
23
+ ? detail.runRef
24
+ : undefined;
25
+ const logicalSessionRef =
26
+ typeof detail.logicalSessionRef === "string" ? detail.logicalSessionRef : undefined;
27
+ const intent = typeof detail.intent === "string" ? detail.intent : undefined;
28
+
29
+ if (runId && !pending.runIds.includes(runId)) pending.runIds.push(runId);
30
+ if (planCommitRef && !pending.planCommitRefs.includes(planCommitRef)) {
31
+ pending.planCommitRefs.push(planCommitRef);
32
+ }
33
+ if (logicalSessionRef) pending.logicalSessionRef = logicalSessionRef;
34
+ if (intent) pending.intent = intent;
35
+ }
36
+
37
+ export function drainRunAudit(): RunAuditRecord {
38
+ const snapshot = { ...pending, runIds: [...pending.runIds], planCommitRefs: [...pending.planCommitRefs] };
39
+ resetRunAudit();
40
+ return snapshot;
41
+ }