@cairnvibe/indexer 0.2.6 → 0.2.8

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.
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ const llm_1 = require("./llm");
16
16
  const manifest_1 = require("./manifest");
17
17
  const diff_1 = require("./diff");
18
18
  const docs_1 = require("./docs");
19
+ const webmcp_1 = require("./webmcp");
19
20
  const init_1 = require("./init");
20
21
  const setup_1 = require("./setup");
21
22
  /**
@@ -180,6 +181,29 @@ async function main() {
180
181
  console.error(`wrote ${outPath}`);
181
182
  return;
182
183
  }
184
+ if (command === "webmcp") {
185
+ const manifestPath = node_path_1.default.join(node_path_1.default.resolve(dir), "ui-manifest.json");
186
+ if (!node_fs_1.default.existsSync(manifestPath)) {
187
+ console.error(`cairn webmcp: no ${manifestPath} — run \`cairn build ${dir}\` first.`);
188
+ process.exit(1);
189
+ }
190
+ const manifest = core_1.ManifestSchema.parse(JSON.parse(node_fs_1.default.readFileSync(manifestPath, "utf8")));
191
+ const component = (0, webmcp_1.generateWebMcpComponent)(manifest);
192
+ if (!component) {
193
+ console.error("cairn webmcp: no real, traced actions (apiCall-backed elements) found in this manifest — nothing safe to register. Nothing written.");
194
+ return;
195
+ }
196
+ const outPath = node_path_1.default.join(node_path_1.default.resolve(dir), "components", "CairnWebMcpTools.tsx");
197
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(outPath), { recursive: true });
198
+ node_fs_1.default.writeFileSync(outPath, component);
199
+ console.error(`wrote ${outPath}`);
200
+ console.error("");
201
+ console.error("Next step: add it once, near your Copilot widget (e.g. app/layout.tsx):");
202
+ console.error(' import { CairnWebMcpTools } from "./components/CairnWebMcpTools";');
203
+ console.error(" <CairnWebMcpTools />");
204
+ console.error("Re-run `cairn webmcp` after a fresh `cairn build` whenever this app's real actions change.");
205
+ return;
206
+ }
183
207
  console.error("usage:");
184
208
  console.error(" cairn setup [dir] (the one-command path: installs deps, asks for keys — skippable, wires the widget in, builds once, auto-rebuilds on future `npm run build`)");
185
209
  console.error(" cairn init <dir> (scaffolds the API route/server + .env.example, detects your framework — no prompts, no installs)");
@@ -188,6 +212,7 @@ async function main() {
188
212
  console.error(" cairn build <url> [--provider anthropic|groq] [--out <dir>] [--storage-state <file>] (any framework — crawls a running app; --storage-state replays a saved logged-in session for auth-gated apps)");
189
213
  console.error(" cairn diff <old-manifest.json> <new-manifest.json>");
190
214
  console.error(" cairn docs <dir> (reads <dir>/ui-manifest.json, writes <dir>/CAIRN_DOCS.md)");
215
+ console.error(" cairn webmcp <dir> (reads <dir>/ui-manifest.json, writes <dir>/components/CairnWebMcpTools.tsx — registers this app's real, traced actions as WebMCP tools any agent can call, not just Cairn)");
191
216
  process.exit(command ? 1 : 0);
192
217
  }
193
218
  main().catch((err) => {
package/dist/init.js CHANGED
@@ -35,6 +35,20 @@ function writeIfAbsent(filePath, content, result) {
35
35
  node_fs_1.default.writeFileSync(filePath, content);
36
36
  result.filesWritten.push(filePath);
37
37
  }
38
+ // Found live: if your Next.js config transpiles @cairnvibe/sdk (needed when
39
+ // importing the client widget straight from source rather than a compiled
40
+ // dist), webpack bundling the `ws` package used by the speak/transcribe/
41
+ // realtime routes silently breaks its Deepgram WebSocket connections — every
42
+ // request hung for ~10s then failed with ECONNRESET, even though the exact
43
+ // same code worked outside Next's dev bundler. Marking `ws` as a server
44
+ // external package (next.config.js) fixes it.
45
+ const WS_TRANSPILE_WARNING = [
46
+ " If your next.config.js sets transpilePackages for @cairnvibe/sdk, also",
47
+ ' add "ws" to serverExternalPackages (Next 15) or',
48
+ ' experimental.serverComponentsExternalPackages (Next 14) — webpack-',
49
+ " bundling ws otherwise silently breaks the Deepgram speak/transcribe",
50
+ " connections it uses.",
51
+ ];
38
52
  function runInit(dir, options = {}) {
39
53
  const absDir = node_path_1.default.resolve(dir);
40
54
  const pkgPath = node_path_1.default.join(absDir, "package.json");
@@ -66,6 +80,8 @@ function runInit(dir, options = {}) {
66
80
  widgetProps.push('speakEndpoint="/api/copilot/speak"', 'transcribeEndpoint="/api/copilot/transcribe"');
67
81
  }
68
82
  result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to app/layout.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ` <Copilot ${widgetProps.join(" ")} />`, "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
83
+ if (options.voice)
84
+ result.nextSteps.push(...WS_TRANSPILE_WARNING);
69
85
  }
70
86
  else if (result.framework === "next-pages-router") {
71
87
  writeIfAbsent(node_path_1.default.join(absDir, "pages", "api", "copilot.ts"), NEXT_PAGES_API_ROUTE, result);
@@ -76,6 +92,8 @@ function runInit(dir, options = {}) {
76
92
  widgetProps.push('speakEndpoint="/api/copilot/speak"', 'transcribeEndpoint="/api/copilot/transcribe"');
77
93
  }
78
94
  result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to pages/_app.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ` <Copilot ${widgetProps.join(" ")} />`, "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
95
+ if (options.voice)
96
+ result.nextSteps.push(...WS_TRANSPILE_WARNING);
79
97
  }
80
98
  else {
81
99
  writeIfAbsent(node_path_1.default.join(absDir, "cairn-server.cjs"), STANDALONE_SERVER, result);
@@ -151,7 +169,7 @@ export async function POST(request: Request) {
151
169
  if ("error" in result.body) {
152
170
  return Response.json(result.body, { status: result.status });
153
171
  }
154
- return new Response(result.body.audio, {
172
+ return new Response(result.body.stream, {
155
173
  status: result.status,
156
174
  headers: { "content-type": result.body.contentType },
157
175
  });
@@ -170,6 +188,7 @@ export async function POST(request: Request) {
170
188
  }
171
189
  `;
172
190
  const NEXT_PAGES_SPEAK_ROUTE = `import type { NextApiRequest, NextApiResponse } from "next";
191
+ import { Readable } from "node:stream";
173
192
  import { createSpeakHandler } from "@cairnvibe/sdk/speak-server";
174
193
 
175
194
  const handler = createSpeakHandler({ apiKey: process.env.DEEPGRAM_API_KEY ?? "" });
@@ -180,7 +199,8 @@ export default async function speak(req: NextApiRequest, res: NextApiResponse) {
180
199
  if ("error" in result.body) {
181
200
  return res.status(result.status).json(result.body);
182
201
  }
183
- res.status(result.status).setHeader("content-type", result.body.contentType).send(Buffer.from(result.body.audio));
202
+ res.status(result.status).setHeader("content-type", result.body.contentType);
203
+ Readable.fromWeb(result.body.stream).pipe(res);
184
204
  }
185
205
  `;
186
206
  const NEXT_PAGES_TRANSCRIBE_ROUTE = `import type { NextApiRequest, NextApiResponse } from "next";
@@ -241,11 +261,13 @@ app.post("/api/copilot", async (req, res) => {
241
261
 
242
262
  if (process.env.DEEPGRAM_API_KEY) {
243
263
  const { createSpeakHandler } = require("@cairnvibe/sdk/speak-server");
264
+ const { Readable } = require("node:stream");
244
265
  const speak = createSpeakHandler({ apiKey: process.env.DEEPGRAM_API_KEY });
245
266
  app.post("/api/copilot/speak", async (req, res) => {
246
267
  const result = await speak(req.body?.text ?? "");
247
268
  if ("error" in result.body) return res.status(result.status).json(result.body);
248
- res.status(result.status).type(result.body.contentType).send(Buffer.from(result.body.audio));
269
+ res.status(result.status).type(result.body.contentType);
270
+ Readable.fromWeb(result.body.stream).pipe(res);
249
271
  });
250
272
  }
251
273
 
package/dist/webmcp.js ADDED
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ // Cairn as a WebMCP *producer*, not just a consumer
3
+ // (https://webmachinelearning.github.io/webmcp/). The indexer already
4
+ // traces real, safe, mutating actions statically (l1-scan.ts's
5
+ // findApiCallIn — a real POST/PUT/PATCH/DELETE call an element's own
6
+ // handler already makes, never invented; GET is deliberately excluded, per
7
+ // ApiCallSchema's own doc comment, since a read-only call isn't an
8
+ // "action"). This turns that same traced set into real
9
+ // document.modelContext.registerTool() calls — so running `cairn build`
10
+ // doesn't just make a site usable by Cairn's own runtime, it also makes it
11
+ // agent-ready for any future agent that understands the standard, with zero
12
+ // Cairn lock-in (the generated component has no Cairn import at all).
13
+ //
14
+ // Pure formatting/codegen from an already-built manifest, no LLM call — same
15
+ // shape as docs.ts's generateDocsMarkdown.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.generateWebMcpComponent = generateWebMcpComponent;
18
+ /** Turns a route into a name-safe prefix — "/" (home) has no segments to
19
+ * join, "/invoices" -> "invoices", "/agents/new" -> "agents-new". */
20
+ function routePrefix(route) {
21
+ const slug = route
22
+ .split("/")
23
+ .filter(Boolean)
24
+ .join("-")
25
+ .replace(/[^a-zA-Z0-9-]/g, "-");
26
+ return slug || "home";
27
+ }
28
+ /**
29
+ * Collects every real apiCall-backed element across the whole manifest into
30
+ * one flat, deduped tool list. Deduped by (id, method, url) together, not
31
+ * id alone — id is only ever guaranteed unique WITHIN a page (l1-scan
32
+ * assigns them per-page), so two different page-scoped elements can share
33
+ * an id by coincidence and must stay two separate tools; a genuinely
34
+ * global element (e.g. a layout nav action) instead appears on every
35
+ * page's own `elements` array with the IDENTICAL apiCall each time
36
+ * (assembleManifest spreads globalElements onto every page) — matching on
37
+ * the full key is what tells "really the same action, seen N times" apart
38
+ * from "two different actions that happen to share an id".
39
+ */
40
+ function collectTools(manifest) {
41
+ const seen = new Map();
42
+ const pages = [...manifest.pages].sort((a, b) => a.route.localeCompare(b.route));
43
+ for (const page of pages) {
44
+ for (const el of page.elements) {
45
+ if (!el.apiCall)
46
+ continue;
47
+ const key = `${el.id}::${el.apiCall.method}::${el.apiCall.url}`;
48
+ if (seen.has(key))
49
+ continue;
50
+ seen.set(key, {
51
+ name: `${routePrefix(page.route)}-${el.id}`,
52
+ description: el.does,
53
+ apiCall: el.apiCall,
54
+ });
55
+ }
56
+ }
57
+ return [...seen.values()];
58
+ }
59
+ function jsStringLiteral(value) {
60
+ return JSON.stringify(value);
61
+ }
62
+ /**
63
+ * Generates a real, self-contained TSX component ("use client") that
64
+ * registers every real apiCall-backed action in the manifest as a WebMCP
65
+ * tool, on mount, at the app root — one component, added once, covers every
66
+ * page (each tool's execute() fires the same same-origin fetch the client-
67
+ * side `do` verb's own apiCall fallback already does — see verb-executor.ts's
68
+ * executeApiCall — real session cookies via credentials: "same-origin", no
69
+ * request body since static tracing only ever captures method+url). Returns
70
+ * null when the manifest has no real apiCall-backed elements — nothing
71
+ * safe to register, so nothing to scaffold.
72
+ */
73
+ function generateWebMcpComponent(manifest) {
74
+ const tools = collectTools(manifest);
75
+ if (tools.length === 0)
76
+ return null;
77
+ const registrations = tools
78
+ .map((tool) => ` register({
79
+ name: ${jsStringLiteral(tool.name)},
80
+ description: ${jsStringLiteral(tool.description)},
81
+ inputSchema: { type: "object", properties: {} },
82
+ execute: async () => {
83
+ const res = await fetch(${jsStringLiteral(tool.apiCall.url)}, { method: ${jsStringLiteral(tool.apiCall.method)}, credentials: "same-origin" });
84
+ return { ok: res.ok, status: res.status };
85
+ },
86
+ });`)
87
+ .join("\n\n");
88
+ return `// Generated by \`cairn webmcp\` from commit ${jsStringLiteral(manifest.commit)} at ${manifest.generatedAt}.
89
+ // Do not edit by hand — regenerate instead (\`npx cairn webmcp <dir>\` after
90
+ // a fresh \`cairn build\`, whenever the app's real actions change).
91
+ //
92
+ // Registers this app's own real, traced actions
93
+ // (https://webmachinelearning.github.io/webmcp/) so ANY agent that
94
+ // understands the standard — not just Cairn — can discover and call them
95
+ // directly. No Cairn import here on purpose: this keeps working even in an
96
+ // app that later removes Cairn entirely, and costs nothing on a browser
97
+ // that doesn't implement WebMCP yet (the overwhelming majority right now —
98
+ // registerTool() is checked for and simply skipped if absent).
99
+ "use client";
100
+
101
+ import { useEffect } from "react";
102
+
103
+ export function CairnWebMcpTools() {
104
+ useEffect(() => {
105
+ const modelContext = (document as unknown as { modelContext?: any }).modelContext;
106
+ if (!modelContext?.registerTool) return;
107
+
108
+ let unregistered = false;
109
+ const handles: { remove?: () => void }[] = [];
110
+ const register = (tool: unknown) => {
111
+ void modelContext
112
+ .registerTool(tool)
113
+ .then((registered: { remove?: () => void }) => {
114
+ if (unregistered) registered?.remove?.();
115
+ else handles.push(registered);
116
+ })
117
+ .catch(() => {
118
+ // A page without a real WebMCP implementation, or a tool name
119
+ // this app already registered some other way — nothing to do.
120
+ });
121
+ };
122
+
123
+ ${registrations}
124
+
125
+ return () => {
126
+ unregistered = true;
127
+ for (const handle of handles) handle?.remove?.();
128
+ };
129
+ }, []);
130
+
131
+ return null;
132
+ }
133
+ `;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/indexer",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Cairn's analyzer and installer (the `cairn` CLI) — scans Next.js source or crawls any running app, and scaffolds the backend either way.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },