@llamaventures/cli 1.25.0 → 2.0.0

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/bin/llama-mcp.mjs CHANGED
@@ -1,28 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // llama-mcp stdio MCP server for Llama Command. Pairs with the `llama`
4
- // CLI in the same package; both share auth + HTTP via lib/client.mjs.
5
- //
6
- // Wire into Claude Code / Cursor / Claude Desktop / OpenClaw via your
7
- // agent's MCP config — see README for snippets. Auth is identical to the
8
- // CLI: gcloud (preferred) → $LLAMA_TOKEN → ~/.llama/token.
9
-
10
- import { createRequire } from "module";
11
- import { randomUUID } from "crypto";
3
+ import { createRequire } from "node:module";
12
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
6
  import { z } from "zod";
15
7
  import {
16
8
  getAuthHeaders,
17
- getBaseUrl,
18
9
  readBriefing,
19
10
  request,
20
- requestSse,
21
11
  setClientRuntime,
22
12
  } from "../lib/client.mjs";
23
-
24
- const requireFromHere = createRequire(import.meta.url);
25
- const { version: PKG_VERSION } = requireFromHere("../package.json");
26
13
  import {
27
14
  clearExternalSession,
28
15
  getExternalSessionStatus,
@@ -30,38 +17,17 @@ import {
30
17
  startExternalSession,
31
18
  uploadExternalFile,
32
19
  } from "../lib/external.mjs";
20
+ import {
21
+ buildDealReadPath,
22
+ buildDealSearchPath,
23
+ prepareDealCommand,
24
+ } from "../lib/deal-actions.mjs";
33
25
 
26
+ const requireFromHere = createRequire(import.meta.url);
27
+ const { version: PKG_VERSION } = requireFromHere("../package.json");
34
28
  setClientRuntime({ client: "mcp" });
35
29
 
36
- function newHtmlUploadId() {
37
- return `mcp-${randomUUID()}`;
38
- }
39
-
40
- function normalizeUploadId(value) {
41
- if (typeof value !== "string" || !value.trim()) return null;
42
- const id = value.trim();
43
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) {
44
- throw new Error("clientUploadId must be 1-128 chars: letters, numbers, dot, underscore, colon, or hyphen");
45
- }
46
- return id;
47
- }
48
-
49
- // Wrap a request() call into the MCP CallToolResult shape. Catches errors
50
- // (NO_AUTH / 401 / 5xx / network) and surfaces them as `isError: true`
51
- // content so the calling agent sees a clean error string instead of the
52
- // MCP transport closing.
53
- async function callApi(method, path, body, opts = {}) {
54
- try {
55
- const result = await request(method, path, body, opts);
56
- const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
57
- return { content: [{ type: "text", text }] };
58
- } catch (err) {
59
- return {
60
- content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
61
- isError: true,
62
- };
63
- }
64
- }
30
+ const server = new McpServer({ name: "llama-mcp", version: PKG_VERSION });
65
31
 
66
32
  function textResult(text, isError = false) {
67
33
  return {
@@ -70,2098 +36,349 @@ function textResult(text, isError = false) {
70
36
  };
71
37
  }
72
38
 
73
- function jsonResult(value, isError = false) {
74
- return textResult(JSON.stringify(value, null, 2), isError);
75
- }
76
-
77
- async function callWorkflow(dealId, type, fields) {
78
- try {
79
- const path = `/api/deals/${encodeURIComponent(dealId)}/workflow`;
80
- // @core-api-operation GET /api/deals/{dealId}/workflow
81
- const current = await request("GET", path);
82
- const expectedRevision = current?.workflow?.revision;
83
- if (!Number.isInteger(expectedRevision)) {
84
- return textResult("Error: Investment Workflow V2 is not initialized for this deal.", true);
85
- }
86
- // @core-api-operation POST /api/deals/{dealId}/workflow
87
- return callApi("POST", path, {
88
- type,
89
- requestId: `mcp:${type}:${randomUUID()}`,
90
- expectedRevision,
91
- ...fields,
92
- });
93
- } catch (err) {
94
- return textResult(`Error: ${err?.message ?? String(err)}`, true);
95
- }
96
- }
97
-
98
- function splitSources(value) {
99
- if (Array.isArray(value)) return value.filter(Boolean);
100
- if (!value || value === true) return undefined;
101
- return String(value)
102
- .split(",")
103
- .map((s) => s.trim())
104
- .filter(Boolean);
105
- }
106
-
107
- function buildEnrichmentAgentMessage(args = {}) {
108
- if (args.message) return String(args.message);
109
- const sources = splitSources(args.sources) ?? [
110
- "website",
111
- "github",
112
- "linkedin",
113
- "yc",
114
- "launch",
115
- "web",
116
- "monid",
117
- ];
118
- const budget = args.budgetCents ?? "50";
119
- const memo = args.generateMemo
120
- ? "Generate memo only after enrichment because the caller explicitly requested it."
121
- : "Do not generate memo.";
122
- return [
123
- "Run server-side deal enrichment for this deal.",
124
- `Use sources: ${sources.join(", ")}.`,
125
- `Private Monid budget cap: ${budget} cents.`,
126
- "Read the enrichment harness first, then collect current company/founder evidence.",
127
- "Write canonical evidence links, sourced deal facts, stable deal fields, and typed factual values where supported.",
128
- "For typed factual values, call read_typed_factual_layer first and use upsert_typed_fact for queryable fields.",
129
- "Search snippets alone are not high-confidence evidence; fetch direct sources where possible.",
130
- memo,
131
- "End with what was written, what was skipped, and open questions.",
132
- ].join(" ");
133
- }
134
-
135
- function summarizeAgentEvents(events = []) {
136
- return events
137
- .flatMap((event) => {
138
- if (event.tool_use?.name) return [{ type: "tool_use", name: event.tool_use.name }];
139
- if (event.tool_result?.name) {
140
- return [
141
- {
142
- type: "tool_result",
143
- name: event.tool_result.name,
144
- ok: event.tool_result.ok ?? null,
145
- summary: event.tool_result.summary ?? null,
146
- },
147
- ];
148
- }
149
- if (event.error) return [{ type: "error", error: String(event.error) }];
150
- return [];
151
- })
152
- .slice(-80);
153
- }
154
-
155
- async function runDealAgentTool({ dealId, message, title = "MCP agent run" }) {
156
- try {
157
- const thread = await request("POST", `/api/deals/${encodeURIComponent(dealId)}/threads`, { title });
158
- if (!thread?.id) throw new Error("Thread creation did not return an id");
159
- const result = await requestSse(
160
- "POST",
161
- `/api/deals/${encodeURIComponent(dealId)}/threads/${encodeURIComponent(thread.id)}`,
162
- { message },
163
- );
164
- return textResult(
165
- JSON.stringify(
166
- {
167
- ok: true,
168
- threadId: thread.id,
169
- text: result.text,
170
- toolEvents: summarizeAgentEvents(result.events),
171
- },
172
- null,
173
- 2,
174
- ),
175
- );
176
- } catch (err) {
177
- return textResult(`Error: ${err?.message ?? String(err)}`, true);
178
- }
179
- }
180
-
181
- // Append a block to a deal brief. The /blocks route only accepts atomic
182
- // full-array PUTs (no POST), so we GET current blocks, prepend the new
183
- // one (matches UI default since 2026-05-03), and PUT the merged array.
184
- // Server stamps identity meta on PUT; we don't send any.
185
- async function addBriefBlock(dealId, block, cueAuthorized = false) {
39
+ async function callApi(method, path, body) {
186
40
  try {
187
- const id = globalThis.crypto.randomUUID();
188
- const cur = await request("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`);
189
- const existing = Array.isArray(cur?.blocks) ? cur.blocks : [];
190
- const result = await request(
191
- "PUT",
192
- `/api/deals/${encodeURIComponent(dealId)}/blocks`,
193
- { blocks: [{ id, ...block }, ...existing], cue_authorized: cueAuthorized === true }
194
- );
195
- const text = JSON.stringify(
196
- { ok: result?.ok ?? true, id, count: result?.count ?? existing.length + 1 },
197
- null,
198
- 2
199
- );
200
- return { content: [{ type: "text", text }] };
201
- } catch (err) {
202
- return {
203
- content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
204
- isError: true,
205
- };
41
+ const result = await request(method, path, body);
42
+ return textResult(typeof result === "string" ? result : JSON.stringify(result, null, 2));
43
+ } catch (error) {
44
+ return textResult(`Error: ${error?.message ?? String(error)}`, true);
206
45
  }
207
46
  }
208
47
 
209
- const server = new McpServer({
210
- name: "llama-mcp",
211
- version: PKG_VERSION,
48
+ const originSchema = z.object({
49
+ kind: z.enum(["user", "agent", "system"]),
50
+ originalUserUtterance: z.string().min(1).optional(),
51
+ originatingChatRecordId: z.string().uuid().optional(),
212
52
  });
213
53
 
214
- // ============================================================
215
- // Auth + diagnostics
216
- // ============================================================
217
-
218
- server.registerTool(
219
- "auth_status",
220
- {
221
- description:
222
- "Verify Llama Command credentials and return current user identity. " +
223
- "Call this first if any other tool returns Error[NO_AUTH] or Error[UNAUTHORIZED].",
224
- inputSchema: {},
225
- },
226
- async () => {
227
- const headers = await getAuthHeaders();
228
- if (Object.keys(headers).length === 0) {
229
- return {
230
- content: [
231
- {
232
- type: "text",
233
- text:
234
- "Error[NO_AUTH]: No credentials found. Mint a token at " +
235
- "https://command.llamaventures.vc/settings/tokens, then save the " +
236
- "llc_... value to ~/.llama/token (mode 0600), or set $LLAMA_TOKEN.",
237
- },
238
- ],
239
- isError: true,
240
- };
241
- }
242
- return callApi("GET", "/api/me");
243
- }
244
- );
245
-
246
- server.registerTool(
247
- "agent_bootstrap",
248
- {
249
- description:
250
- "Fetch the live Llama Command + Llama OS runtime manifest. Use this at " +
251
- "the start of an agent session to discover current skills, the skill " +
252
- "bundle version, and the object-inspection contract. Unlike the bundled " +
253
- "agent_briefing prompt, this comes from authenticated Command runtime.",
254
- inputSchema: {
255
- limit: z.number().optional().describe("number of skill summaries to include; default 25"),
256
- },
257
- },
258
- async ({ limit } = {}) => {
259
- const params = new URLSearchParams();
260
- params.set("clientVersion", PKG_VERSION);
261
- if (limit) params.set("limit", String(limit));
262
- return callApi("GET", `/api/agent/manifest${params.toString() ? `?${params}` : ""}`);
263
- }
264
- );
265
-
266
- server.registerTool(
267
- "skills_search",
268
- {
269
- description:
270
- "Search the authenticated Llama OS runtime skill library. Call this " +
271
- "before choosing a workflow for Llama pipeline/wiki/DD/research/ops tasks. " +
272
- "Returns summaries only; call skills_read for the exact SKILL.md.",
273
- inputSchema: {
274
- q: z.string().describe("workflow/task query, e.g. 'wiki delete tombstone' or 'deal DD memo'"),
275
- limit: z.number().optional().describe("default 20"),
276
- },
277
- },
278
- async ({ q, limit }) => {
279
- const params = new URLSearchParams({ q });
280
- if (limit) params.set("limit", String(limit));
281
- return callApi("GET", `/api/agent/skills?${params}`);
282
- }
283
- );
284
-
285
- server.registerTool(
286
- "pref_list",
287
- {
288
- description:
289
- "List standing agent preferences (team scope + the caller's user scope). " +
290
- "These are injected into every server-side agent turn. Use status=proposed " +
291
- "to review pending proposals awaiting approval.",
292
- inputSchema: {
293
- status: z.enum(["active", "proposed", "retired", "all"]).optional()
294
- .describe("filter; defaults to active"),
295
- },
296
- },
297
- async ({ status }) => {
298
- const params = new URLSearchParams();
299
- if (status) params.set("status", status);
300
- return callApi("GET", `/api/agent/preferences${params.toString() ? `?${params}` : ""}`);
301
- }
302
- );
303
-
304
- server.registerTool(
305
- "pref_add",
306
- {
307
- description:
308
- "Save a standing preference so every Llama agent follows it from the next " +
309
- "turn on. Use when the user states a durable way they want agents to work " +
310
- "(style, workflow, defaults). Content is hard-capped at 280 chars — if it " +
311
- "does not fit, it is a procedure and belongs in a skill. Team scope needs " +
312
- "system-admin approval; own user scope activates immediately.",
313
- inputSchema: {
314
- key: z.string().describe("short slug, e.g. reply-style.conclusion-first"),
315
- content: z.string().describe("the preference, max 280 chars"),
316
- scope: z.enum(["user", "team"]).optional().describe("default user (the caller)"),
317
- evidence: z.string().optional().describe("what prompted this (run, correction)"),
318
- },
319
- },
320
- async ({ key, content, scope, evidence }) =>
321
- callApi("POST", "/api/agent/preferences", { key, content, scope, evidence })
322
- );
323
-
324
- server.registerTool(
325
- "pref_set_status",
326
- {
327
- description:
328
- "Approve (activate) or retire a standing preference by id. Own user scope " +
329
- "is self-service; team scope requires a system admin.",
330
- inputSchema: {
331
- id: z.number().describe("preference id from pref_list"),
332
- status: z.enum(["active", "retired"]).describe("new status"),
333
- },
334
- },
335
- async ({ id, status }) =>
336
- callApi("PATCH", `/api/agent/preferences/${encodeURIComponent(String(id))}`, { status })
337
- );
338
-
339
- server.registerTool(
340
- "skills_read",
341
- {
342
- description:
343
- "Read one runtime Llama OS skill by slug. Use after skills_search. " +
344
- "Returns the full SKILL.md content from Llama Command; public npm does " +
345
- "not bundle private skill text.",
346
- inputSchema: {
347
- slug: z.string().describe("skill slug, e.g. llama-command or llama-wiki"),
348
- },
349
- },
350
- async ({ slug }) => callApi("GET", `/api/agent/skills/${encodeURIComponent(slug)}`)
351
- );
352
-
353
- server.registerTool(
354
- "object_inspect",
355
- {
356
- description:
357
- "Explain a Llama Command URL or object id. Use for 404s, deleted wiki " +
358
- "pages, notifier links, deal URLs, brief blocks, HTML docs, and unknown " +
359
- "Command objects before guessing that the system is broken.",
360
- inputSchema: {
361
- q: z.string().optional().describe("URL or compact query, e.g. wiki:my-slug or a Command URL"),
362
- type: z.string().optional().describe("explicit object type if not using q"),
363
- id: z.string().optional().describe("explicit object id if not using q"),
364
- lang: z.enum(["en", "zh"]).optional().describe("wiki language; default en"),
365
- },
366
- },
367
- async ({ q, type, id, lang } = {}) => {
368
- const params = new URLSearchParams();
369
- if (q) params.set("q", q);
370
- if (type) params.set("type", type);
371
- if (id) params.set("id", id);
372
- if (lang) params.set("lang", lang);
373
- return callApi("GET", `/api/agent/explain?${params}`);
374
- }
375
- );
376
-
54
+ // Deal is deliberately a closed four-tool surface. Do not add split resource
55
+ // tools here: one more tool is one more branch every agent must reason about.
377
56
  server.registerTool(
378
- "activity_query",
57
+ "search_deals",
379
58
  {
380
- description:
381
- "Read Command's curated agent activity projection. Use this before " +
382
- "scanning raw timelines or event-bus payloads. Examples: new deals in " +
383
- "the past 24h, deals with meaningful updates in the past 7d, or recent " +
384
- "fact/memo/brief events. Returns source ids so callers can drill down.",
59
+ description: "Search compact Live Deal Page candidates. Always use this before creating a company.",
385
60
  inputSchema: {
386
- kind: z
387
- .enum(["events", "new_deals", "updated_deals"])
388
- .default("events")
389
- .describe("events = raw curated rows; new_deals = deal.created; updated_deals = grouped meaningful deal updates"),
390
- since: z.string().optional().describe("24h, 7d, 30d, or ISO timestamp; default 24h"),
391
- limit: z.number().optional().describe("default 50, cap 100"),
392
- dealId: z.string().optional().describe("optional single deal UUID"),
393
- entity: z.enum(["deal", "wiki", "all"]).optional().describe("default deal"),
394
- verb: z.string().optional().describe("comma-separated activity verbs, e.g. fact.added,brief.revised"),
395
- cursor: z.number().optional().describe("pagination cursor from next_cursor"),
396
- minSignificance: z.number().optional().describe("1..3; default 2, new_deals default 3"),
61
+ q: z.string().optional(),
62
+ state: z.enum(["active", "archived", "trashed"]).optional(),
63
+ limit: z.number().int().min(1).max(2000).optional(),
397
64
  },
398
65
  },
399
- async ({ kind, since, limit, dealId, entity, verb, cursor, minSignificance } = {}) => {
400
- const params = new URLSearchParams({ kind: kind || "events" });
401
- if (since) params.set("since", since);
402
- if (limit) params.set("limit", String(limit));
403
- if (dealId) params.set("deal_id", dealId);
404
- if (entity) params.set("entity", entity);
405
- if (verb) params.set("verb", verb);
406
- if (cursor) params.set("cursor", String(cursor));
407
- if (minSignificance) params.set("min_sig", String(minSignificance));
408
- return callApi("GET", `/api/agent/activity?${params}`);
409
- }
66
+ // @core-api-operation GET /api/occam/deals
67
+ async ({ q, state, limit } = {}) => callApi("GET", buildDealSearchPath(q, { state, limit })),
410
68
  );
411
69
 
412
- // ============================================================
413
- // Deals — read
414
- // ============================================================
415
-
416
70
  server.registerTool(
417
- "deal_search",
71
+ "read_deal",
418
72
  {
419
- description:
420
- "Search the Llama Ventures deal pipeline. Fuzzy match on company name, " +
421
- "founders, description, founder info, notes, deal owner, source, source direction, and location. " +
422
- "Returns up to `limit` deals (default 200, cap 1000).",
73
+ description: "Read one Deal. Live Page is always returned; expand only the resource needed.",
423
74
  inputSchema: {
424
- q: z.string().optional().describe("fuzzy search query across all text fields"),
425
- companyName: z.string().optional().describe("fuzzy match on companyName only"),
426
- founder: z.string().optional().describe("fuzzy match on founders / founderInfo"),
427
- owner: z.string().optional().describe("fuzzy match on dealOwner"),
428
- status: z
429
- .string()
430
- .optional()
431
- .describe(
432
- "exact match on 'Our Stage' (Interested, Outreached, Sourced, First Meeting, Diligence, Partner Meeting, Term Sheet, Invested, Passed, Stalled, Future, Unknown). Interested means we want to record/track before contact; Outreached means contact was logged but no effective relationship/response exists yet."
433
- ),
434
- theirStage: z.string().optional().describe("exact match on 'Their Stage'"),
435
- stage: z
436
- .string()
437
- .optional()
438
- .describe(
439
- "exact match on Round (Pre-Seed, Seed, Series A, Series B, Series C+, Stealth)"
440
- ),
441
- sourceDirection: z
442
- .string()
443
- .optional()
444
- .describe("exact match on source direction: Inbound, Outbound, or Unknown"),
445
- limit: z.number().optional().describe("max results (default 200, cap 1000)"),
446
- offset: z.number().optional(),
75
+ dealId: z.string().min(1),
76
+ detail: z.enum(["overview", "memory", "files", "conversation", "history", "all"]).optional(),
447
77
  },
448
78
  },
449
- async (args = {}) => {
450
- const params = new URLSearchParams();
451
- for (const [k, v] of Object.entries(args)) {
452
- if (v != null && v !== "") params.set(k, String(v));
453
- }
454
- return callApi("GET", `/api/deals${params.toString() ? `?${params}` : ""}`);
455
- }
79
+ // @core-api-operation GET /api/occam/deals/{dealId}
80
+ async ({ dealId, detail }) => callApi("GET", buildDealReadPath(dealId, detail || "overview")),
456
81
  );
457
82
 
458
83
  server.registerTool(
459
- "deal_show",
84
+ "create_deal",
460
85
  {
461
- description:
462
- "Get the full canonical record for one deal by uuid. Includes status, " +
463
- "stage, founders, owner, source, sourceDirection, valuation, all whitelisted writable fields, " +
464
- "and the `extra` JSONB blob.",
86
+ description: "Create one Deal intent. Core owns Drive, initial resources, idempotency, and Events.",
465
87
  inputSchema: {
466
- dealId: z.string().describe("deal uuid"),
88
+ companyName: z.string().min(1).max(240),
89
+ companyKey: z.string().min(1).max(240).optional(),
90
+ page: z.record(z.string(), z.any()).optional(),
91
+ information: z.array(z.record(z.string(), z.any())).optional(),
92
+ origin: originSchema,
93
+ idempotencyKey: z.string().min(1).max(240).optional(),
467
94
  },
468
95
  },
469
- async ({ dealId }) => callApi("GET", `/api/deals/${encodeURIComponent(dealId)}`)
96
+ async (input) => callApi("POST", "/api/occam/deals/commands", prepareDealCommand("create", input)),
470
97
  );
471
98
 
472
- // ============================================================
473
- // Deals — write
474
- // ============================================================
475
-
476
99
  server.registerTool(
477
- "deal_create",
100
+ "write_deal",
478
101
  {
479
- description:
480
- "Create a new pipeline deal. Source defaults to the caller's user record. " +
481
- "Owner is an audited responsibility label, not an operating-permission boundary.",
102
+ description: "The only Deal mutation tool: input.submit, information.put, page.patch, or artifact.put.",
482
103
  inputSchema: {
483
- companyName: z.string(),
484
- description: z.string().optional().describe("one-liner: what they do"),
485
- website: z.string().optional(),
486
- founders: z.string().optional().describe("comma-separated names"),
487
- founderInfo: z.string().optional().describe("LinkedIn URLs / background blob"),
488
- stage: z
489
- .string()
490
- .optional()
491
- .describe("Pre-Seed | Seed | Series A | Series B | Series C+ | Stealth"),
492
- status: z
493
- .string()
494
- .optional()
495
- .describe(
496
- "Our Stage workflow position. Use Interested when we want to record/track before contact; use Outreached when we contacted/logged them and have no response/effective relationship; use Sourced only once there is a response, intro, meeting, or other real relationship signal."
497
- ),
498
- source: z.string().optional().describe("free-form sourced-by; exact team-member names also attach source_user_id"),
499
- dealOwner: z
500
- .string()
501
- .optional()
502
- .describe("owner override. Use an exact /api/field-options dealOwner value, a user email, or numeric user id."),
503
- sourceDirection: z
504
- .string()
505
- .optional()
506
- .describe("Separate direction tag: Inbound if the deal came into the firm; Outbound if Llama found/listed/reached out first; Unknown if unclear."),
507
- notes: z.string().optional(),
508
- location: z.string().optional(),
104
+ operation: z.enum(["input.submit", "information.put", "page.patch", "artifact.put"]),
105
+ dealId: z.string().uuid(),
106
+ patch: z.record(z.string(), z.any()).optional(),
107
+ expectedRevision: z.number().int().nonnegative().optional(),
108
+ informationId: z.string().uuid().optional(),
109
+ type: z.string().min(1).max(120).optional(),
110
+ labels: z.array(z.string()).optional(),
111
+ subject: z.record(z.string(), z.any()).optional(),
112
+ value: z.any().optional(),
113
+ expectedVersion: z.number().int().positive().optional(),
114
+ format: z.string().min(1).max(120).optional(),
115
+ content: z.any().optional(),
116
+ source: z.record(z.string(), z.any()).optional(),
117
+ artifactId: z.string().uuid().optional(),
118
+ kind: z.string().min(1).max(120).optional(),
119
+ title: z.string().min(1).max(500).optional(),
120
+ mimeType: z.string().min(1).max(240).optional(),
121
+ contentBase64: z.string().min(1).optional(),
122
+ storageKey: z.string().min(1).optional(),
123
+ storageUrl: z.string().url().optional(),
124
+ byteSize: z.number().int().nonnegative().optional(),
125
+ sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
126
+ metadata: z.record(z.string(), z.any()).optional(),
127
+ origin: originSchema,
128
+ idempotencyKey: z.string().min(1).max(240).optional(),
509
129
  },
510
130
  },
511
- async (args) => callApi("POST", "/api/deals/create", args)
131
+ async (input) => callApi("POST", "/api/occam/deals/commands", prepareDealCommand("write", input)),
512
132
  );
513
133
 
514
134
  server.registerTool(
515
- "deal_update",
135
+ "auth_status",
516
136
  {
517
- description:
518
- "Update a single non-workflow field on a deal. Writable fields: theirStage, " +
519
- "notes, stage, dealOwner, source, sourceDirection, description, website, location, founders, " +
520
- "proposedAmount, roundSize, valuation, sector, subsector, foundedYear, leadInvestor, " +
521
- "investors. Logs a field_change event in deal_events.",
522
- inputSchema: {
523
- dealId: z.string(),
524
- field: z.string().describe("camelCase field name (see description for whitelist)"),
525
- value: z.union([z.string(), z.number(), z.null()]).describe("new value"),
526
- },
137
+ description: "Verify Command credentials and current user identity.",
138
+ inputSchema: {},
527
139
  },
528
- async ({ dealId, field, value }) => {
529
- if (field === "status") {
530
- return textResult("Error: Direct status writes are retired. Use the workflow_* tools.", true);
140
+ async () => {
141
+ const headers = await getAuthHeaders();
142
+ if (!Object.keys(headers).length) {
143
+ return textResult(
144
+ "Error[NO_AUTH]: run `llama auth login`, or save a valid llc_ token with `llama token set`.",
145
+ true,
146
+ );
531
147
  }
532
- return callApi("POST", "/api/deals/update", { dealId, field, value });
533
- }
534
- );
535
-
536
- server.registerTool(
537
- "workflow_show",
538
- {
539
- description: "Read the canonical Investment Workflow V2 state, revision, current transition, and blockers for a deal.",
540
- inputSchema: { dealId: z.string() },
541
- },
542
- async ({ dealId }) => callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/workflow`),
543
- );
544
-
545
- server.registerTool(
546
- "workflow_initialize",
547
- {
548
- description: "Persist the canonical Investment Workflow V2 bootstrap state for an unmigrated deal without changing its semantic stage. Intended for audited migration and recovery.",
549
- inputSchema: { dealId: z.string(), reason: z.string().min(1) },
148
+ return callApi("GET", "/api/me");
550
149
  },
551
- async ({ dealId, reason }) => callWorkflow(dealId, "initialize", { reason }),
552
150
  );
553
151
 
554
152
  server.registerTool(
555
- "workflow_request_partner_support",
153
+ "agent_bootstrap",
556
154
  {
557
- description: "Submit a formal Partner Support request. Core refuses it until every current stage requirement is satisfied or waived.",
558
- inputSchema: { dealId: z.string(), partnerId: z.string(), reason: z.string().min(1) },
155
+ description: "Fetch the authenticated runtime contract and visible Llama OS skills.",
156
+ inputSchema: { limit: z.number().int().min(1).max(100).optional() },
559
157
  },
560
- async ({ dealId, partnerId, reason }) => callWorkflow(dealId, "request_partner_support", { partnerId, reason }),
561
- );
562
-
563
- server.registerTool(
564
- "workflow_decide_partner_support",
565
- {
566
- description: "Record the named Partner's own Support, Need more, or Pass decision. Core verifies caller identity; never use on another person's behalf.",
567
- inputSchema: { dealId: z.string(), decision: z.enum(["support", "need_more", "pass"]), reason: z.string().min(1) },
158
+ async ({ limit } = {}) => {
159
+ const params = new URLSearchParams({ clientVersion: PKG_VERSION });
160
+ if (limit) params.set("limit", String(limit));
161
+ return callApi("GET", `/api/agent/manifest?${params}`);
568
162
  },
569
- async ({ dealId, decision, reason }) => callWorkflow(dealId, "partner_support_decision", { decision, reason }),
570
163
  );
571
164
 
572
165
  server.registerTool(
573
- "workflow_proceed",
166
+ "skills_search",
574
167
  {
575
- description: "Apply the current formal workflow transition. Core re-evaluates every guard atomically.",
576
- inputSchema: { dealId: z.string(), transitionKey: z.string(), reason: z.string().min(1) },
168
+ description: "Search authenticated runtime skills; read only the relevant result.",
169
+ inputSchema: { q: z.string().min(1), limit: z.number().int().min(1).max(100).optional() },
577
170
  },
578
- async ({ dealId, transitionKey, reason }) => callWorkflow(dealId, "proceed", { transitionKey, reason }),
579
- );
580
-
581
- server.registerTool(
582
- "workflow_resolve_guard",
583
- {
584
- description: "Record an audited workflow guard resolution. Waivers require a concrete reason and remain visible in history.",
585
- inputSchema: { dealId: z.string(), guardKey: z.string(), status: z.enum(["satisfied", "unsatisfied", "waived"]), reason: z.string().min(1) },
171
+ async ({ q, limit }) => {
172
+ const params = new URLSearchParams({ q });
173
+ if (limit) params.set("limit", String(limit));
174
+ return callApi("GET", `/api/agent/skills?${params}`);
586
175
  },
587
- async ({ dealId, guardKey, status, reason }) => callWorkflow(dealId, "resolve_guard", { guardKey, status, reason }),
588
176
  );
589
177
 
590
178
  server.registerTool(
591
- "workflow_control",
179
+ "skills_read",
592
180
  {
593
- description: "Hold, resume, pass, restore, or return a deal through the canonical workflow control path.",
594
- inputSchema: { dealId: z.string(), action: z.enum(["hold", "resume", "pass", "restore", "return"]), reason: z.string().min(1), holdDisposition: z.enum(["stalled", "future"]).optional() },
181
+ description: "Read one authenticated runtime skill by slug.",
182
+ inputSchema: { slug: z.string().min(1) },
595
183
  },
596
- async ({ dealId, action, reason, holdDisposition }) => callWorkflow(dealId, "control", { action, reason, ...(holdDisposition ? { holdDisposition } : {}) }),
184
+ async ({ slug }) => callApi("GET", `/api/agent/skills/${encodeURIComponent(slug)}`),
597
185
  );
598
186
 
599
187
  server.registerTool(
600
- "workflow_vote",
188
+ "pref_list",
601
189
  {
602
- description: "Cast the signed-in Partner's own Formal IC vote through Investment Workflow V2.",
603
- inputSchema: { dealId: z.string(), vote: z.enum(["yes", "no"]), reason: z.string().min(1) },
190
+ description: "List standing agent preferences.",
191
+ inputSchema: { status: z.enum(["active", "proposed", "retired", "all"]).optional() },
604
192
  },
605
- async ({ dealId, vote, reason }) => callWorkflow(dealId, "cast_vote", { vote, reason }),
606
- );
607
-
608
- server.registerTool(
609
- "workflow_update_execution_status",
610
- {
611
- description: "Update the canonical post-IC execution status. Available only after the formal IC decision.",
612
- inputSchema: { dealId: z.string(), executionStatus: z.enum(["Term Sheet", "Verbal Commit", "Invested"]), reason: z.string().min(1) },
193
+ async ({ status } = {}) => {
194
+ const params = new URLSearchParams();
195
+ if (status) params.set("status", status);
196
+ return callApi("GET", `/api/agent/preferences${params.size ? `?${params}` : ""}`);
613
197
  },
614
- async ({ dealId, executionStatus, reason }) => callWorkflow(dealId, "update_execution_status", { executionStatus, reason }),
615
198
  );
616
199
 
617
- // ============================================================
618
- // Deal facts (research substrate + trust ladder)
619
- // ============================================================
620
-
621
200
  server.registerTool(
622
- "deal_ingest",
201
+ "pref_add",
623
202
  {
624
- description:
625
- "Preferred write tool when one source yields multiple facts, or facts plus a Feed note. " +
626
- "Commits the packet atomically, canonicalizes fact categories, skips exact source-aware " +
627
- "duplicates, and is safe to retry with the same idempotencyKey. Use deal_fact_add only " +
628
- "for a genuinely single fact. Canonical categories: company_basics, team, product, market, " +
629
- "financials, fundraise, risk, milestone, meta.",
203
+ description: "Save a durable user or team preference; procedures belong in skills.",
630
204
  inputSchema: {
631
- dealId: z.string(),
632
- idempotencyKey: z
633
- .string()
634
- .optional()
635
- .describe("Stable key for retries. If omitted, the server derives one from packet content."),
636
- source: z
637
- .object({
638
- kind: z.enum(["deck", "web", "meeting_note", "email", "human", "agent_inference"]).optional(),
639
- title: z.string().optional(),
640
- url: z.string().optional(),
641
- contentHash: z.string().optional(),
642
- })
643
- .optional(),
644
- facts: z
645
- .array(z.object({
646
- category: z.string(),
647
- claim: z.string(),
648
- source: z.string().optional(),
649
- sourceUrl: z.string().optional(),
650
- sourceKind: z.enum(["deck", "web", "meeting_note", "email", "human", "agent_inference"]).optional(),
651
- confidence: z.enum(["high", "medium", "low"]).optional(),
652
- attested: z.boolean().optional(),
653
- }))
654
- .max(50)
655
- .optional(),
656
- note: z.string().optional().describe("Opinion, impression, or context to add to the deal Feed."),
205
+ key: z.string().min(1),
206
+ content: z.string().min(1).max(280),
207
+ scope: z.enum(["user", "team"]).optional(),
208
+ evidence: z.string().optional(),
657
209
  },
658
210
  },
659
- async ({ dealId, ...packet }) => {
660
- // @core-api-operation POST /api/deals/{dealId}/ingest
661
- return callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/ingest`, packet);
662
- }
211
+ async ({ key, content, scope, evidence }) =>
212
+ callApi("POST", "/api/agent/preferences", { key, content, scope, evidence }),
663
213
  );
664
214
 
665
215
  server.registerTool(
666
- "deal_fact_list",
216
+ "pref_set_status",
667
217
  {
668
- description:
669
- "List a deal's recorded facts (the research substrate). Each fact carries a " +
670
- "category, a claim, a source/sourceUrl, a confidence, and a trust rung (unverified → " +
671
- "agent-verified → human-vouched → endorsed) plus who/what recorded it.",
672
- inputSchema: {
673
- dealId: z.string(),
674
- },
218
+ description: "Activate or retire a standing preference.",
219
+ inputSchema: { id: z.number().int().positive(), status: z.enum(["active", "retired"]) },
675
220
  },
676
- async ({ dealId }) =>
677
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/facts`)
221
+ async ({ id, status }) => callApi("PATCH", `/api/agent/preferences/${id}`, { status }),
678
222
  );
679
223
 
680
224
  server.registerTool(
681
- "deal_fact_add",
225
+ "object_inspect",
682
226
  {
683
- description:
684
- "Record a factual claim about a deal. RESPONSIBILITY: set `attested` honestly — " +
685
- "true ONLY if you actually verified the claim against its cited source (the fact " +
686
- "is then stored at trust level 'agent-verified'); false/omitted if you are relaying " +
687
- "something unconfirmed (stored 'unverified', which is the honest default). You CANNOT " +
688
- "mark a fact as human-confirmed — only a person can raise it to 'human-vouched'. " +
689
- "`confidence` is how certain the claim is; `attested` is whether YOU take responsibility " +
690
- "for having checked it. `source` is a human-readable provenance label; `sourceUrl` is a " +
691
- "canonical URL that round-trips as sourceUrl/source_url. Category is free text; common " +
692
- "values include founders | financials | product | market | team | company_basics | risk | fundraise | milestone | meta.",
227
+ description: "Explain a Command URL or object before guessing that it is broken.",
693
228
  inputSchema: {
694
- dealId: z.string(),
695
- category: z.string(),
696
- claim: z.string(),
697
- source: z.string().optional().describe("where you found this (URL, 'deck p3', 'LinkedIn')"),
698
- sourceUrl: z.string().optional().describe("canonical URL for the evidence, if separate from source"),
699
- confidence: z.enum(["high", "medium", "low"]).optional(),
700
- attested: z
701
- .boolean()
702
- .optional()
703
- .describe("true → stored 'agent-verified'; false/omitted → 'unverified'. Answer honestly."),
229
+ q: z.string().optional(),
230
+ type: z.string().optional(),
231
+ id: z.string().optional(),
232
+ lang: z.enum(["en", "zh"]).optional(),
704
233
  },
705
234
  },
706
- async ({ dealId, category, claim, source, sourceUrl, confidence, attested }) =>
707
- callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/facts`, {
708
- category,
709
- claim,
710
- source: source ?? "",
711
- ...(sourceUrl ? { sourceUrl } : {}),
712
- confidence: confidence ?? "medium",
713
- attested: attested === true,
714
- })
715
- );
716
-
717
- server.registerTool(
718
- "deal_fact_verify",
719
- {
720
- description:
721
- "Verify a recorded fact. status='confirmed' vouches for it (raises to 'human-vouched'); " +
722
- "status='disputed' marks it contradicted. Trust-ladder guardrails apply server-side " +
723
- "(external-org callers are capped at 'unverified'; only Partners reach 'endorsed'). " +
724
- "Optionally pass correctedValue when disputing.",
725
- inputSchema: {
726
- dealId: z.string(),
727
- factId: z.union([z.string(), z.number()]),
728
- status: z.enum(["confirmed", "disputed"]),
729
- correctedValue: z.string().optional(),
730
- },
235
+ async ({ q, type, id, lang } = {}) => {
236
+ const params = new URLSearchParams();
237
+ if (q) params.set("q", q);
238
+ if (type) params.set("type", type);
239
+ if (id) params.set("id", id);
240
+ if (lang) params.set("lang", lang);
241
+ return callApi("GET", `/api/agent/explain?${params}`);
731
242
  },
732
- async ({ dealId, factId, status, correctedValue }) =>
733
- callApi(
734
- "PATCH",
735
- `/api/deals/${encodeURIComponent(dealId)}/facts/${encodeURIComponent(String(factId))}`,
736
- { status, ...(correctedValue !== undefined ? { correctedValue } : {}) }
737
- )
738
243
  );
739
244
 
740
- // ============================================================
741
- // Brief blocks
742
- // ============================================================
743
-
744
245
  server.registerTool(
745
- "brief_blocks",
246
+ "wiki_search",
746
247
  {
747
- description:
748
- "List all brief blocks for a deal. Blocks are typed (text, link, embed, callout) " +
749
- "and ordered. Each has stable id, optional meta (locked, by_agent, sourceSection).",
750
- inputSchema: {
751
- dealId: z.string(),
752
- },
248
+ description: "Search institutional knowledge in the internal Wiki.",
249
+ inputSchema: { q: z.string().min(1) },
753
250
  },
754
- async ({ dealId }) =>
755
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/blocks`)
251
+ async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`),
756
252
  );
757
253
 
758
254
  server.registerTool(
759
- "deal_feed",
255
+ "wiki_read",
760
256
  {
761
- description:
762
- "The unified, time-sorted stream of every contribution to a deal — " +
763
- "facts + notes/discussion + legacy posts, merged at query time, newest first. " +
764
- "Shows contributions from ANYONE — a teammate, their AI assistant, or an " +
765
- "autonomous system agent; nothing is hidden. Each item carries `who` (the " +
766
- "accountable person, null only for principal-less system writes) and `agent` " +
767
- "(the assistant/system label when an AI did the writing, null when a human " +
768
- "typed it) so you can tell human-typed from assistant-drafted. The AI's " +
769
- "regenerable brief synthesis is NOT here (that's the Memo) — only " +
770
- "facts + discussion notes. Each item: kind (fact|note), ts, who, agent, " +
771
- "origin, text, and for facts: source + trust rung + category.",
772
- inputSchema: {
773
- dealId: z.string(),
774
- },
257
+ description: "Read one Wiki article by exact slug.",
258
+ inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
775
259
  },
776
- async ({ dealId }) =>
777
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/feed`)
260
+ async ({ slug, lang }) =>
261
+ callApi("GET", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`),
778
262
  );
779
263
 
780
264
  server.registerTool(
781
- "brief_add_text",
265
+ "wiki_save",
782
266
  {
783
- description:
784
- "Prepend a markdown text block to a deal brief. Supports markdown + mermaid diagrams.",
267
+ description: "Create or update cross-Deal institutional knowledge, not Deal data.",
785
268
  inputSchema: {
786
- dealId: z.string(),
787
- heading: z.string().optional().describe("optional block heading"),
788
- body: z.string().describe("markdown body"),
789
- cueAuthorized: z.boolean().optional().describe(
790
- "true only after the user explicitly approved every resolved cue recipient",
791
- ),
269
+ slug: z.string().min(1),
270
+ title: z.string().min(1),
271
+ content: z.string(),
272
+ sources: z.array(z.string()).min(1),
273
+ content_type: z.enum(["markdown", "html"]).optional(),
792
274
  },
793
275
  },
794
- async ({ dealId, heading, body, cueAuthorized }) =>
795
- addBriefBlock(dealId, { type: "text", heading: heading ?? "", body }, cueAuthorized)
276
+ async ({ slug, title, content, sources, content_type }) =>
277
+ callApi("POST", "/api/wiki/save", { slug, title, content, sources, content_type }),
796
278
  );
797
279
 
798
280
  server.registerTool(
799
- "brief_add_link",
281
+ "wiki_delete",
800
282
  {
801
- description:
802
- "Prepend a link block to a deal brief. Server fetches og:image + title via /api/link-preview.",
803
- inputSchema: {
804
- dealId: z.string(),
805
- url: z.string(),
806
- label: z.string().optional().describe("optional human-readable label"),
807
- cueAuthorized: z.boolean().optional().describe(
808
- "true only after the user explicitly approved every resolved cue recipient",
809
- ),
810
- },
283
+ description: "Soft-delete a Wiki article.",
284
+ inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
811
285
  },
812
- async ({ dealId, url, label, cueAuthorized }) =>
813
- addBriefBlock(dealId, { type: "link", url, label: label ?? "" }, cueAuthorized)
286
+ async ({ slug, lang }) =>
287
+ callApi("DELETE", `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`),
814
288
  );
815
289
 
816
290
  server.registerTool(
817
- "brief_add_callout",
291
+ "wiki_restore",
818
292
  {
819
- description:
820
- "Prepend a callout block to a deal brief. Use for emphasized insights or warnings.",
821
- inputSchema: {
822
- dealId: z.string(),
823
- tone: z.string().describe("insight | warning | info | success"),
824
- heading: z.string().optional(),
825
- body: z.string(),
826
- cueAuthorized: z.boolean().optional().describe(
827
- "true only after the user explicitly approved every resolved cue recipient",
828
- ),
829
- },
293
+ description: "Restore a soft-deleted Wiki article.",
294
+ inputSchema: { slug: z.string().min(1), lang: z.enum(["en", "zh"]).optional() },
830
295
  },
831
- async ({ dealId, tone, heading, body, cueAuthorized }) =>
832
- addBriefBlock(dealId, { type: "callout", tone, heading: heading ?? "", body }, cueAuthorized)
296
+ async ({ slug, lang }) =>
297
+ callApi("POST", `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang === "zh" ? "zh" : "en"}`),
833
298
  );
834
299
 
835
300
  server.registerTool(
836
- "brief_edit",
301
+ "pitch_start",
837
302
  {
838
- description:
839
- "Edit an existing brief block in place. Pass only the fields you want to change " +
840
- "(heading/body/url/label/description/tone). Meta toggles: locked (protect from bulk " +
841
- "overwrite), hidden (fold), sourceSection (route watcher writes). Snapshots the prior " +
842
- "version to history (reversible via brief_restore_version).",
843
- inputSchema: {
844
- dealId: z.string(),
845
- blockId: z.string(),
846
- heading: z.string().optional(),
847
- body: z.string().optional(),
848
- url: z.string().optional(),
849
- label: z.string().optional(),
850
- description: z.string().optional(),
851
- tone: z.string().optional(),
852
- locked: z.boolean().optional(),
853
- hidden: z.boolean().optional(),
854
- sourceSection: z.string().optional(),
855
- cueAuthorized: z.boolean().optional().describe(
856
- "true only after the user explicitly approved every resolved cue recipient",
857
- ),
858
- },
303
+ description: "Start an external founder pitch session; no internal token required.",
304
+ inputSchema: { name: z.string().min(1).max(100), email: z.string().email() },
859
305
  },
860
- async ({ dealId, blockId, heading, body, url, label, description, tone, locked, hidden, sourceSection, cueAuthorized }) => {
861
- const patch = {};
862
- for (const [k, v] of Object.entries({ heading, body, url, label, description, tone })) {
863
- if (v !== undefined) patch[k] = v;
306
+ async ({ name, email }) => {
307
+ try {
308
+ return textResult(JSON.stringify(await startExternalSession({ name, email }), null, 2));
309
+ } catch (error) {
310
+ return textResult(`Error: ${error?.message ?? String(error)}`, true);
864
311
  }
865
- const meta = {};
866
- if (locked !== undefined) meta.locked = locked;
867
- if (hidden !== undefined) meta.hidden = hidden;
868
- if (sourceSection !== undefined) meta.sourceSection = sourceSection;
869
- if (Object.keys(meta).length > 0) patch.meta = meta;
870
- if (cueAuthorized === true) patch.cue_authorized = true;
871
- return callApi(
872
- "PATCH",
873
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`,
874
- patch
875
- );
876
- }
877
- );
878
-
879
- server.registerTool(
880
- "brief_delete",
881
- {
882
- description:
883
- "Soft-delete a brief block (reversible via brief_restore). Locked blocks are refused.",
884
- inputSchema: { dealId: z.string(), blockId: z.string() },
885
312
  },
886
- async ({ dealId, blockId }) =>
887
- callApi("DELETE", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}`)
888
313
  );
889
314
 
890
315
  server.registerTool(
891
- "brief_restore",
316
+ "pitch_send_message",
892
317
  {
893
- description: "Restore a soft-deleted brief block.",
894
- inputSchema: { dealId: z.string(), blockId: z.string() },
318
+ description: "Relay the founder's exact message to the external intake agent.",
319
+ inputSchema: { message: z.string().min(1).max(8000) },
320
+ },
321
+ async ({ message }) => {
322
+ try {
323
+ return textResult(JSON.stringify(await sendExternalMessage(message), null, 2));
324
+ } catch (error) {
325
+ return textResult(`Error: ${error?.message ?? String(error)}`, true);
326
+ }
895
327
  },
896
- async ({ dealId, blockId }) =>
897
- callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/restore`)
898
328
  );
899
329
 
900
330
  server.registerTool(
901
- "brief_history",
331
+ "pitch_upload_file",
902
332
  {
903
- description:
904
- "List the content-version history of a brief block (every overwrite is snapshotted). " +
905
- "Use the returned history id with brief_restore_version.",
906
- inputSchema: {
907
- dealId: z.string(),
908
- blockId: z.string(),
909
- limit: z.number().optional(),
910
- },
333
+ description: "Attach a local file to the active external pitch session.",
334
+ inputSchema: { path: z.string().min(1) },
335
+ },
336
+ async ({ path }) => {
337
+ try {
338
+ return textResult(JSON.stringify(await uploadExternalFile(path), null, 2));
339
+ } catch (error) {
340
+ return textResult(`Error: ${error?.message ?? String(error)}`, true);
341
+ }
911
342
  },
912
- async ({ dealId, blockId, limit }) => {
913
- const qs = limit ? `?limit=${encodeURIComponent(String(limit))}` : "";
914
- return callApi(
915
- "GET",
916
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history${qs}`
917
- );
918
- }
919
343
  );
920
344
 
921
345
  server.registerTool(
922
- "brief_restore_version",
923
- {
924
- description:
925
- "Restore a brief block to a specific historical version (find historyId via brief_history). " +
926
- "Itself reversible — the outgoing version is snapshotted before replacement.",
927
- inputSchema: {
928
- dealId: z.string(),
929
- blockId: z.string(),
930
- historyId: z.number(),
931
- },
932
- },
933
- async ({ dealId, blockId, historyId }) =>
934
- callApi(
935
- "POST",
936
- `/api/deals/${encodeURIComponent(dealId)}/blocks/${encodeURIComponent(blockId)}/history`,
937
- { history_id: historyId }
938
- )
346
+ "pitch_status",
347
+ { description: "Inspect the active external pitch session.", inputSchema: {} },
348
+ async () => textResult(JSON.stringify(getExternalSessionStatus(), null, 2)),
939
349
  );
940
350
 
941
- // ============================================================
942
- // Wiki (knowledge base)
943
- // ============================================================
944
-
945
351
  server.registerTool(
946
- "wiki_search",
947
- {
948
- description:
949
- "Search the Llama Ventures internal wiki — deal context, company profiles, " +
950
- "industry frameworks, partner-curated knowledge. Returns excerpts. " +
951
- "For full article content use `wiki_read`.",
952
- inputSchema: {
953
- q: z.string().describe("search query"),
954
- },
352
+ "pitch_finalize",
353
+ { description: "Clear local pitch session state after completion or abandonment.", inputSchema: {} },
354
+ async () => {
355
+ const before = getExternalSessionStatus();
356
+ clearExternalSession();
357
+ return textResult(JSON.stringify({ cleared: before.active, previous_session: before }, null, 2));
955
358
  },
956
- async ({ q }) => callApi("GET", `/api/wiki/search?q=${encodeURIComponent(q)}`)
957
359
  );
958
360
 
959
- server.registerTool(
960
- "wiki_read",
361
+ server.registerPrompt(
362
+ "agent_briefing",
961
363
  {
962
- description:
963
- "Read a single wiki article from the configured Llama Command " +
964
- "deployment by exact slug. Returns title, frontmatter, full " +
965
- "markdown content, and rendered HTML.\n\n" +
966
- "USE THIS — DO NOT WebFetch — whenever the user gives you a " +
967
- "wiki URL whose path is `/wiki/<slug>`. Extract the slug from " +
968
- "the URL path and call this tool with it. WebFetch against the " +
969
- "browser URL goes through session-cookie auth — your agent " +
970
- "doesn't have one — so it will look like a permission denial " +
971
- "even though your token is fine.\n\n" +
972
- "If you only have a topic name, use `wiki_search` first to " +
973
- "find the slug.",
974
- inputSchema: {
975
- slug: z
976
- .string()
977
- .describe(
978
- "exact kebab-case slug — the last path segment of the wiki URL"
979
- ),
980
- lang: z
981
- .enum(["en", "zh"])
982
- .optional()
983
- .describe("article language (default 'en')"),
984
- },
985
- },
986
- async ({ slug, lang }) =>
987
- callApi(
988
- "GET",
989
- `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`
990
- )
991
- );
992
-
993
- server.registerTool(
994
- "wiki_save",
995
- {
996
- description:
997
- "Create or update a wiki page — Llama's CROSS-DEAL / institutional " +
998
- "knowledge surface (sector landscape · market map · thesis · framework · " +
999
- "methodology · anything not tied to ONE specific deal). Renders at " +
1000
- "/wiki/<slug>. " +
1001
- "**Routing — decide BEFORE calling:** " +
1002
- "(a) Deal-specific HTML (IC memo for X, dashboard for X) → use " +
1003
- "`html_upload` instead, NOT this. " +
1004
- "(b) Cross-deal / institutional (this tool) → /wiki/<slug>. " +
1005
- "(c) Founder-facing public share → Netlify only when user explicitly " +
1006
- "says so; Llama Command outranks Netlify for everything internal. " +
1007
- "By default `content` is markdown with attribution blocks " +
1008
- "(**[Name · YYYY-MM-DD · source · fact|opinion]**) for traceability. " +
1009
- "Set `content_type: 'html'` to deploy a standalone HTML page as the " +
1010
- "wiki entry (full-viewport sandboxed iframe takeover on /wiki/<slug>; " +
1011
- "the HTML itself is the page — no wiki chrome). `sources` is a " +
1012
- "separate citation list (URLs, doc names, or meeting references) — " +
1013
- "at least one required; URLs inside `content` do not count. For HTML " +
1014
- "asset bundles use the `llama wiki save --file ... --assets ...` CLI " +
1015
- "path; MCP only supports single-file HTML.",
1016
- inputSchema: {
1017
- slug: z.string().describe("kebab-case slug"),
1018
- title: z.string(),
1019
- content: z
1020
- .string()
1021
- .describe(
1022
- "body — markdown source by default, or raw HTML when content_type='html'"
1023
- ),
1024
- sources: z
1025
- .array(z.string())
1026
- .min(1)
1027
- .describe(
1028
- "citation list — URLs, doc names, or meeting references. At least one required."
1029
- ),
1030
- content_type: z
1031
- .enum(["markdown", "html"])
1032
- .optional()
1033
- .describe(
1034
- "'markdown' (default) renders via the wiki markdown pipeline. " +
1035
- "'html' stores the body as a standalone HTML page (sandboxed iframe)."
1036
- ),
1037
- },
1038
- },
1039
- async ({ slug, title, content, sources, content_type }) =>
1040
- callApi("POST", "/api/wiki/save", {
1041
- slug,
1042
- title,
1043
- content,
1044
- sources,
1045
- ...(content_type ? { content_type } : {}),
1046
- })
1047
- );
1048
-
1049
- server.registerTool(
1050
- "wiki_delete",
1051
- {
1052
- description:
1053
- "Soft-delete a wiki page (reversible). The entry stops appearing in " +
1054
- "reads / search / backlinks; for HTML entries the standalone page + " +
1055
- "assets stop resolving too. Restore with wiki_restore. Use when the " +
1056
- "user asks to remove / delete / retire a wiki entry.",
1057
- inputSchema: {
1058
- slug: z.string().describe("kebab-case slug"),
1059
- lang: z.enum(["en", "zh"]).optional().describe("default: en"),
1060
- },
1061
- },
1062
- async ({ slug, lang }) =>
1063
- callApi(
1064
- "DELETE",
1065
- `/api/wiki/${encodeURIComponent(slug)}?lang=${lang === "zh" ? "zh" : "en"}`
1066
- )
1067
- );
1068
-
1069
- server.registerTool(
1070
- "wiki_restore",
1071
- {
1072
- description:
1073
- "Restore a soft-deleted wiki page (undo wiki_delete). Brings back the " +
1074
- "entry + (for HTML entries) its standalone page and assets.",
1075
- inputSchema: {
1076
- slug: z.string().describe("kebab-case slug"),
1077
- lang: z.enum(["en", "zh"]).optional().describe("default: en"),
1078
- },
1079
- },
1080
- async ({ slug, lang }) =>
1081
- callApi(
1082
- "POST",
1083
- `/api/wiki/${encodeURIComponent(slug)}/restore?lang=${lang === "zh" ? "zh" : "en"}`
1084
- )
1085
- );
1086
-
1087
- // ============================================================
1088
- // Timeline + posts
1089
- // ============================================================
1090
-
1091
- server.registerTool(
1092
- "timeline",
1093
- {
1094
- description:
1095
- "Get the activity timeline for a deal — field changes, owner approvals, " +
1096
- "brief edits, posts, watcher events. Append-only audit log.",
1097
- inputSchema: {
1098
- dealId: z.string(),
1099
- },
1100
- },
1101
- async ({ dealId }) =>
1102
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/timeline`)
1103
- );
1104
-
1105
- server.registerTool(
1106
- "post",
1107
- {
1108
- description:
1109
- "Post a message to a deal's timeline. Cue-free posts are autonomous. " +
1110
- "Explicit or implicit teammate cues create inbox/email notifications and require " +
1111
- "cueAuthorized=true only after explicit user permission.",
1112
- inputSchema: {
1113
- dealId: z.string(),
1114
- message: z.string(),
1115
- cueAuthorized: z.boolean().optional().describe(
1116
- "true only after the user explicitly approved cueing every resolved recipient",
1117
- ),
1118
- },
1119
- },
1120
- async ({ dealId, message, cueAuthorized }) =>
1121
- callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/posts`, {
1122
- message,
1123
- cue_authorized: cueAuthorized === true,
1124
- })
1125
- );
1126
-
1127
- // ============================================================
1128
- // Mentions / inbox
1129
- // ============================================================
1130
-
1131
- server.registerTool(
1132
- "mentions_list",
1133
- {
1134
- description:
1135
- "List @-mentions. Default scope: unresolved mentions where the caller is the " +
1136
- "recipient. Set everyone=true for team-wide visibility (mutual observability).",
1137
- inputSchema: {
1138
- everyone: z
1139
- .boolean()
1140
- .optional()
1141
- .describe("if true, list all team mentions; otherwise just for the caller"),
1142
- includeResolved: z
1143
- .boolean()
1144
- .optional()
1145
- .describe("if true, also include already-resolved mentions"),
1146
- },
1147
- },
1148
- async ({ everyone, includeResolved } = {}) => {
1149
- const params = new URLSearchParams();
1150
- if (everyone) params.set("everyone", "1");
1151
- else params.set("for_me", "1");
1152
- if (!includeResolved) params.set("unresolved", "1");
1153
- return callApi("GET", `/api/mentions?${params}`);
1154
- }
1155
- );
1156
-
1157
- server.registerTool(
1158
- "mentions_resolve",
1159
- {
1160
- description: "Mark an @-mention as resolved (clears it from the recipient's open cues).",
1161
- inputSchema: { mentionId: z.union([z.string(), z.number()]) },
1162
- },
1163
- async ({ mentionId }) =>
1164
- callApi("POST", `/api/mentions/${encodeURIComponent(String(mentionId))}/resolve`)
1165
- );
1166
-
1167
- // ============================================================
1168
- // Brief refresh (signal-driven re-evaluation)
1169
- // ============================================================
1170
-
1171
- server.registerTool(
1172
- "deal_refresh_brief",
1173
- {
1174
- description:
1175
- "Trigger a stale-section re-evaluation of a deal's brief. Pass force=true to bypass the " +
1176
- "debounce. Returns a runId (or null if debounced / deal inactive).",
1177
- inputSchema: {
1178
- dealId: z.string(),
1179
- force: z.boolean().optional(),
1180
- },
1181
- },
1182
- async ({ dealId, force }) =>
1183
- callApi(
1184
- "POST",
1185
- `/api/deals/${encodeURIComponent(dealId)}/refresh-brief${force ? "?force=1" : ""}`
1186
- )
1187
- );
1188
-
1189
- server.registerTool(
1190
- "deal_agent_run",
1191
- {
1192
- description:
1193
- "Run Llama Command's server-side Deal Agent inside a deal thread. " +
1194
- "Use this when the user explicitly wants the service agent to execute " +
1195
- "a deal-scoped task instead of the local MCP client doing the work.",
1196
- inputSchema: {
1197
- dealId: z.string().describe("deal uuid"),
1198
- message: z.string().describe("task instruction for the server-side Deal Agent"),
1199
- title: z.string().optional().describe("optional thread title; defaults to MCP agent run"),
1200
- },
1201
- },
1202
- async ({ dealId, message, title }) =>
1203
- runDealAgentTool({ dealId, message, title: title || "MCP agent run" })
1204
- );
1205
-
1206
- server.registerTool(
1207
- "deal_enrich",
1208
- {
1209
- description:
1210
- "Run the Llama Command deal enrichment planner/trigger for one deal. " +
1211
- "Default is dry-run: returns evidence plan, source plan, Monid budget/config " +
1212
- "status, and planned writes without changing facts/links/memo. With " +
1213
- "apply=true and executor=server_agent, this starts the server-side Deal " +
1214
- "Agent unless harnessOnly=true. Set apply=true only when the user " +
1215
- "explicitly wants the enrichment run recorded/applied. " +
1216
- "generateMemo never defaults on; pass true only when the user explicitly asks " +
1217
- "for Memo generation after enrichment.",
1218
- inputSchema: {
1219
- dealId: z.string().describe("deal uuid"),
1220
- dryRun: z.boolean().optional().describe("default true unless apply=true"),
1221
- apply: z.boolean().optional().describe("record/apply the enrichment intent server-side"),
1222
- executor: z
1223
- .enum(["server_agent", "external_agent", "planner"])
1224
- .optional()
1225
- .describe("who will execute the harness; server_agent starts Deal Agent when apply=true"),
1226
- sources: z
1227
- .array(z.enum(["website", "github", "linkedin", "yc", "launch", "web", "monid"]))
1228
- .optional()
1229
- .describe("source families to use; defaults to the standard enrichment set"),
1230
- budgetCents: z
1231
- .number()
1232
- .int()
1233
- .min(0)
1234
- .max(500)
1235
- .optional()
1236
- .describe("Monid spend cap for this run, in cents; default is 50 when Monid is requested"),
1237
- generateMemo: z
1238
- .boolean()
1239
- .optional()
1240
- .describe("explicitly request memo regeneration after enrichment; default false"),
1241
- harnessOnly: z
1242
- .boolean()
1243
- .optional()
1244
- .describe("when true, return/apply the enrichment harness endpoint instead of starting Deal Agent"),
1245
- message: z
1246
- .string()
1247
- .optional()
1248
- .describe("optional override instruction for the server-side Deal Agent"),
1249
- },
1250
- },
1251
- async ({ dealId, dryRun, apply, executor, sources, budgetCents, generateMemo, harnessOnly, message }) => {
1252
- const effectiveExecutor = executor ?? "server_agent";
1253
- if (apply === true && effectiveExecutor === "server_agent" && harnessOnly !== true) {
1254
- return runDealAgentTool({
1255
- dealId,
1256
- title: "MCP enrichment",
1257
- message: buildEnrichmentAgentMessage({ sources, budgetCents, generateMemo, message }),
1258
- });
1259
- }
1260
- return callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/enrich`, {
1261
- dryRun,
1262
- apply,
1263
- executor: effectiveExecutor,
1264
- sources,
1265
- budgetCents,
1266
- generateMemo,
1267
- });
1268
- }
1269
- );
1270
-
1271
- // ============================================================
1272
- // External pitch (founder intake) — no Llama Command token required
1273
- // ============================================================
1274
- //
1275
- // These tools let an MCP-native agent (Claude Code / Cursor / OpenClaw /
1276
- // Codex / etc.) help its user pitch a company to Llama Ventures by relaying
1277
- // the conversation through our /api/external/* surface. True A2A: the
1278
- // founder's agent talks to ours, structured intake gets captured, and a
1279
- // 12-dimension verdict is returned.
1280
- //
1281
- // Anti-abuse rate limits are server-enforced. The MCP tools surface
1282
- // any server-side rejections as text back to the agent.
1283
-
1284
- function asTextResult(text, isError = false) {
1285
- return {
1286
- content: [{ type: "text", text }],
1287
- ...(isError ? { isError: true } : {}),
1288
- };
1289
- }
1290
-
1291
- server.registerTool(
1292
- "pitch_start",
1293
- {
1294
- description:
1295
- "Start a new pitch session with Llama Ventures' intake agent. Use this " +
1296
- "when a founder (the user) wants to pitch their company to Llama. " +
1297
- "Requires their name + email. Returns a session_id; the conversation " +
1298
- "is then maintained via pitch_send_message until the agent finalizes. " +
1299
- "Server-enforced rate limits apply (per-IP, per-email, per-session). " +
1300
- "No Llama Command token needed.",
1301
- inputSchema: {
1302
- name: z.string().describe("the founder's full name (max 100 chars)"),
1303
- email: z.string().describe("the founder's email (deliverable, not a disposable domain)"),
1304
- },
1305
- },
1306
- async ({ name, email }) => {
1307
- try {
1308
- const session = await startExternalSession({ name, email });
1309
- return asTextResult(
1310
- JSON.stringify(
1311
- {
1312
- session_id: session.session_id,
1313
- name: session.name,
1314
- email: session.email,
1315
- started_at: session.started_at,
1316
- note: "Session active. Use pitch_send_message to relay the founder's pitch to Llama's intake agent. Use pitch_upload_file to attach decks / one-pagers. The intake agent will auto-finalize once it has enough signal.",
1317
- },
1318
- null,
1319
- 2
1320
- )
1321
- );
1322
- } catch (err) {
1323
- return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
1324
- }
1325
- }
1326
- );
1327
-
1328
- server.registerTool(
1329
- "pitch_send_message",
1330
- {
1331
- description:
1332
- "Relay a message from the founder to Llama Ventures' intake agent. " +
1333
- "Returns the intake agent's reply. The intake agent will ask follow-up " +
1334
- "questions, request files (use pitch_upload_file), and eventually " +
1335
- "auto-finalize the pitch — at which point the response includes " +
1336
- "`finalize_payload` with a confirmation_summary and a 12-dimension " +
1337
- "verdict (overall green/yellow/red + per-dimension notes).",
1338
- inputSchema: {
1339
- message: z.string().describe("the founder's message (max 8000 chars)"),
1340
- },
1341
- },
1342
- async ({ message }) => {
1343
- try {
1344
- const result = await sendExternalMessage(message);
1345
- const out = {
1346
- text: result.text,
1347
- finalized: result.finalized,
1348
- finalize_payload: result.finalize_payload,
1349
- };
1350
- return asTextResult(JSON.stringify(out, null, 2));
1351
- } catch (err) {
1352
- return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
1353
- }
1354
- }
1355
- );
1356
-
1357
- server.registerTool(
1358
- "pitch_upload_file",
1359
- {
1360
- description:
1361
- "Attach a file (deck, one-pager, deck PDF, screenshot, etc.) to the " +
1362
- "active pitch session. Server allows pdf / pptx / ppt / docx / doc / " +
1363
- "xlsx / xls / png / jpg / webp / heic / heif / txt / md, with " +
1364
- "server-enforced size and per-session count limits. " +
1365
- "Returns a drive_file_id; the intake agent will " +
1366
- "pick the file up via list_uploaded_files / read_uploaded_file on its " +
1367
- "next turn (so call pitch_send_message with a one-line note like " +
1368
- "'I just uploaded our pitch deck' so the agent knows to look).",
1369
- inputSchema: {
1370
- path: z.string().describe("absolute or relative filesystem path to the file"),
1371
- },
1372
- },
1373
- async ({ path: filePath }) => {
1374
- try {
1375
- const result = await uploadExternalFile(filePath);
1376
- return asTextResult(JSON.stringify(result, null, 2));
1377
- } catch (err) {
1378
- return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
1379
- }
1380
- }
1381
- );
1382
-
1383
- server.registerTool(
1384
- "pitch_status",
1385
- {
1386
- description:
1387
- "Show the current pitch session state — session_id, started_at, idle " +
1388
- "minutes, finalized flag. Useful when the agent isn't sure if a " +
1389
- "session is still active.",
1390
- inputSchema: {},
1391
- },
1392
- async () => {
1393
- try {
1394
- const status = getExternalSessionStatus();
1395
- return asTextResult(JSON.stringify(status, null, 2));
1396
- } catch (err) {
1397
- return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
1398
- }
1399
- }
1400
- );
1401
-
1402
- server.registerTool(
1403
- "pitch_finalize",
1404
- {
1405
- description:
1406
- "Clear the local pitch session state. Note: this does not force the " +
1407
- "server-side intake agent to finalize — the agent decides that on its " +
1408
- "own once the pitch is sufficient. Use this for cleanup after a session " +
1409
- "ends, or to abandon a session early. The server-side session will " +
1410
- "naturally expire after the server's idle timeout.",
1411
- inputSchema: {},
1412
- },
1413
- async () => {
1414
- try {
1415
- const before = getExternalSessionStatus();
1416
- clearExternalSession();
1417
- return asTextResult(
1418
- JSON.stringify(
1419
- {
1420
- cleared: before.active,
1421
- previous_session: before.active ? before : null,
1422
- note: "Local pitch session state cleared. Server-side session may still be active until its idle timeout.",
1423
- },
1424
- null,
1425
- 2
1426
- )
1427
- );
1428
- } catch (err) {
1429
- return asTextResult(`Error: ${err?.message ?? String(err)}`, true);
1430
- }
1431
- }
1432
- );
1433
-
1434
- // ============================================================
1435
- // Memo — long-form HTML investment memo (the Memo tab in the UI)
1436
- // ============================================================
1437
-
1438
- server.registerTool(
1439
- "memo_show",
1440
- {
1441
- description:
1442
- "Fetch the current memo for a deal. Returns the envelope: memo " +
1443
- "(html, version, source, updated_by, updated_at), mode " +
1444
- "('composed' = server-generated, 'override' = hand-written), and " +
1445
- "inflight (if a server-side regeneration is in progress). html " +
1446
- "can be 50-100KB — be deliberate about including it in your reply.",
1447
- inputSchema: {
1448
- dealId: z.string().describe("deal uuid"),
1449
- },
1450
- },
1451
- async ({ dealId }) =>
1452
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/memo`)
1453
- );
1454
-
1455
- server.registerTool(
1456
- "memo_regenerate",
1457
- {
1458
- description:
1459
- "Trigger server-side regeneration of the deal memo. Synchronous: " +
1460
- "returns the final result (version, model, duration_ms, degraded) " +
1461
- "once the composer finishes. Typical duration 2-3 minutes. Use " +
1462
- "tier='opus' for high-stakes deals (higher cost, deeper analysis). " +
1463
- "Pass `instructions` to steer THIS regeneration (e.g. 'focus on team " +
1464
- "risk', 'frame as a follow-on') — applied across all panels, never " +
1465
- "overrides the facts or the verdict.",
1466
- inputSchema: {
1467
- dealId: z.string().describe("deal uuid"),
1468
- tier: z
1469
- .enum(["sonnet", "opus"])
1470
- .optional()
1471
- .describe("LLM tier (default: sonnet)"),
1472
- instructions: z
1473
- .string()
1474
- .optional()
1475
- .describe(
1476
- "Free-text steering for this regeneration only, e.g. 'focus on team risk'. Applied to all panels; never overrides verified facts or the verdict anchor."
1477
- ),
1478
- },
1479
- },
1480
- async ({ dealId, tier, instructions }) =>
1481
- callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/memo`, {
1482
- action: "regenerate",
1483
- stream: false,
1484
- model: tier ?? "sonnet",
1485
- instructions: instructions || undefined,
1486
- })
1487
- );
1488
-
1489
- server.registerTool(
1490
- "memo_save",
1491
- {
1492
- description:
1493
- "Save hand-written HTML as a manual override for a deal's memo. " +
1494
- "Manual overrides take precedence over auto-composed memos on " +
1495
- "read. Pass the full HTML document including <!DOCTYPE html>, " +
1496
- "<style>, and <body> — it's rendered as-is in a sandboxed iframe.",
1497
- inputSchema: {
1498
- dealId: z.string().describe("deal uuid"),
1499
- html: z
1500
- .string()
1501
- .describe("full HTML document"),
1502
- },
1503
- },
1504
- async ({ dealId, html }) =>
1505
- callApi("PUT", `/api/deals/${encodeURIComponent(dealId)}/memo`, { html })
1506
- );
1507
-
1508
- server.registerTool(
1509
- "memo_reset",
1510
- {
1511
- description:
1512
- "Reset memo state. Default drops only the manual override row " +
1513
- "(next read falls back to the auto-composed version, if any). " +
1514
- "Pass scope='all' to drop every version for the deal — destructive, " +
1515
- "use sparingly.",
1516
- inputSchema: {
1517
- dealId: z.string().describe("deal uuid"),
1518
- scope: z
1519
- .enum(["override_only", "all"])
1520
- .optional()
1521
- .describe("default: override_only"),
1522
- },
1523
- },
1524
- async ({ dealId, scope }) =>
1525
- callApi("DELETE", `/api/deals/${encodeURIComponent(dealId)}/memo`, {
1526
- scope: scope ?? "override_only",
1527
- })
1528
- );
1529
-
1530
- // ============================================================
1531
- // Deal page HTML — hand-authored sandboxed page per deal
1532
- // ============================================================
1533
- //
1534
- // Each deal has its own /deals/<id>/browse page that renders a
1535
- // hand-authored HTML in a sandboxed iframe (allow-scripts, no
1536
- // same-origin). Uploads from any caller (web UI, CLI, agent, MCP)
1537
- // create a new monotonic version + trigger SSE push so any open
1538
- // viewer refreshes in real time. Old versions are soft-deleted on
1539
- // replace and can be restored.
1540
-
1541
- // All html_* tools take an optional documentSlug param. Default 'main'.
1542
- // Each deal can hold multiple named documents (different HTMLs); use
1543
- // html_docs_list to discover slugs.
1544
- const INLINE_HTML_UPLOAD_LIMIT = 50 * 1024;
1545
- const MAX_HTML_BYTES = 5 * 1024 * 1024;
1546
- const MAX_ASSET_BYTES = 50 * 1024 * 1024;
1547
- const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
1548
-
1549
- function htmlUrl(dealId, slug) {
1550
- return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug ?? "main")}/html`;
1551
- }
1552
-
1553
- function looksLikeHtml(html) {
1554
- const head = String(html || "").trim().slice(0, 256).toLowerCase();
1555
- return head.startsWith("<!doctype html") || head.startsWith("<html");
1556
- }
1557
-
1558
- function mimeForAsset(path) {
1559
- const ext = (String(path).split(".").pop() || "").toLowerCase();
1560
- return (
1561
- {
1562
- jpg: "image/jpeg",
1563
- jpeg: "image/jpeg",
1564
- png: "image/png",
1565
- gif: "image/gif",
1566
- webp: "image/webp",
1567
- svg: "image/svg+xml",
1568
- ico: "image/x-icon",
1569
- avif: "image/avif",
1570
- css: "text/css",
1571
- js: "text/javascript",
1572
- json: "application/json",
1573
- woff: "font/woff",
1574
- woff2: "font/woff2",
1575
- ttf: "font/ttf",
1576
- otf: "font/otf",
1577
- mp4: "video/mp4",
1578
- webm: "video/webm",
1579
- pdf: "application/pdf",
1580
- }[ext] || "application/octet-stream"
1581
- );
1582
- }
1583
-
1584
- async function detectSiblingAssetsDir(filePath) {
1585
- const { existsSync, statSync } = await import("node:fs");
1586
- const { dirname, basename, extname, join } = await import("node:path");
1587
- const dir = dirname(filePath);
1588
- const stem = basename(filePath, extname(filePath));
1589
- const candidates = [
1590
- `${stem}_files`,
1591
- `${stem} files`,
1592
- `${basename(filePath)}_files`,
1593
- ];
1594
- for (const name of candidates) {
1595
- const p = join(dir, name);
1596
- if (existsSync(p) && statSync(p).isDirectory()) return p;
1597
- }
1598
- return null;
1599
- }
1600
-
1601
- async function collectAssets(assetsRoot) {
1602
- const { readFileSync, readdirSync, statSync } = await import("node:fs");
1603
- const { join, relative, sep, basename } = await import("node:path");
1604
- const rootStat = statSync(assetsRoot);
1605
- if (!rootStat.isDirectory()) {
1606
- throw new Error(`assetsDir must point to a directory: ${assetsRoot}`);
1607
- }
1608
- const collected = [];
1609
- const walk = (dir) => {
1610
- for (const name of readdirSync(dir)) {
1611
- const absPath = join(dir, name);
1612
- const st = statSync(absPath);
1613
- if (st.isDirectory()) {
1614
- walk(absPath);
1615
- } else if (st.isFile()) {
1616
- const relPath = relative(assetsRoot, absPath).split(sep).join("/");
1617
- collected.push({ absPath, relPath, bytes: st.size });
1618
- }
1619
- }
1620
- };
1621
- walk(assetsRoot);
1622
- if (collected.length === 0) {
1623
- throw new Error(`assetsDir is empty: ${assetsRoot}`);
1624
- }
1625
- const rootName = basename(assetsRoot);
1626
- const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
1627
- const finalPaths = looksLikeSavePageDir
1628
- ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
1629
- : collected;
1630
- let totalBytes = 0;
1631
- for (const item of finalPaths) {
1632
- if (item.relPath.split("/").some((seg) => seg === "..")) {
1633
- throw new Error(`asset path "${item.relPath}" contains "..", refused`);
1634
- }
1635
- if (item.bytes > MAX_ASSET_BYTES) {
1636
- throw new Error(
1637
- `asset "${item.relPath}" is ${item.bytes} bytes; cap is ${MAX_ASSET_BYTES}`,
1638
- );
1639
- }
1640
- totalBytes += item.bytes;
1641
- if (totalBytes > MAX_BUNDLE_BYTES) {
1642
- throw new Error(`total asset bytes exceeds ${MAX_BUNDLE_BYTES}`);
1643
- }
1644
- }
1645
- return {
1646
- assets: finalPaths.map((item) => ({
1647
- ...item,
1648
- data: readFileSync(item.absPath),
1649
- contentType: mimeForAsset(item.relPath),
1650
- })),
1651
- totalBytes,
1652
- };
1653
- }
1654
-
1655
- async function uploadHtmlFromFile({
1656
- dealId,
1657
- filePath,
1658
- documentSlug,
1659
- source = "agent",
1660
- assetsDir,
1661
- autoDetectAssets = true,
1662
- verify = true,
1663
- clientUploadId,
1664
- }) {
1665
- const { readFileSync, statSync } = await import("node:fs");
1666
- const st = statSync(filePath);
1667
- if (!st.isFile()) throw new Error(`filePath must point to a file: ${filePath}`);
1668
- const html = readFileSync(filePath, "utf8");
1669
- if (!html.trim()) throw new Error("HTML body is empty.");
1670
- const htmlBytes = Buffer.byteLength(html, "utf8");
1671
- if (htmlBytes > MAX_HTML_BYTES) {
1672
- throw new Error(
1673
- `HTML body is ${(htmlBytes / 1024 / 1024).toFixed(2)} MB; cap is 5 MB.`,
1674
- );
1675
- }
1676
- if (!looksLikeHtml(html)) {
1677
- throw new Error("HTML must start with <!doctype html> or <html.");
1678
- }
1679
-
1680
- let effectiveAssetsDir = assetsDir || null;
1681
- if (!effectiveAssetsDir && autoDetectAssets !== false) {
1682
- effectiveAssetsDir = await detectSiblingAssetsDir(filePath);
1683
- }
1684
- const uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
1685
-
1686
- let body;
1687
- if (!effectiveAssetsDir) {
1688
- body = await request("PUT", htmlUrl(dealId, documentSlug), {
1689
- html,
1690
- source,
1691
- client_upload_id: uploadId,
1692
- }, {
1693
- headers: { "X-Llama-Upload-Id": uploadId },
1694
- });
1695
- } else {
1696
- const { assets, totalBytes } = await collectAssets(effectiveAssetsDir);
1697
- const form = new FormData();
1698
- form.append("html", html);
1699
- form.append("source", source);
1700
- form.append("client_upload_id", uploadId);
1701
- for (const asset of assets) {
1702
- form.append(
1703
- `asset:${asset.relPath}`,
1704
- new Blob([asset.data], { type: asset.contentType }),
1705
- asset.relPath,
1706
- );
1707
- }
1708
- const headers = await getAuthHeaders();
1709
- const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
1710
- method: "PUT",
1711
- headers: { ...headers, "X-Llama-Upload-Id": uploadId },
1712
- body: form,
1713
- });
1714
- body = await res.json().catch(() => ({}));
1715
- if (!res.ok) {
1716
- throw new Error(
1717
- `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
1718
- );
1719
- }
1720
- body = { ...body, asset_bytes: body.asset_bytes ?? totalBytes };
1721
- }
1722
-
1723
- let verified = { ok: false, skipped: true };
1724
- if (verify !== false) {
1725
- const latest = await request("GET", htmlUrl(dealId, documentSlug));
1726
- if (latest?.empty) throw new Error("verification failed: document came back empty after upload");
1727
- if (body?.version != null && Number(latest.version) !== Number(body.version)) {
1728
- throw new Error(`verification failed: expected version ${body.version}, got ${latest.version}`);
1729
- }
1730
- if (body?.bytes != null && latest.bytes != null && Number(latest.bytes) !== Number(body.bytes)) {
1731
- throw new Error(`verification failed: expected ${body.bytes} bytes, got ${latest.bytes}`);
1732
- }
1733
- if (body?.sha256 && latest.sha256 && String(latest.sha256) !== String(body.sha256)) {
1734
- throw new Error(`verification failed: expected sha256 ${body.sha256}, got ${latest.sha256}`);
1735
- }
1736
- verified = {
1737
- ok: true,
1738
- version: latest.version,
1739
- bytes: latest.bytes,
1740
- sha256: latest.sha256,
1741
- created_at: latest.created_at,
1742
- };
1743
- }
1744
-
1745
- return {
1746
- ok: true,
1747
- document_slug: documentSlug || "main",
1748
- version: body?.version,
1749
- bytes: body?.bytes ?? verified.bytes ?? htmlBytes,
1750
- sha256: body?.sha256 ?? verified.sha256,
1751
- client_upload_id: body?.client_upload_id ?? uploadId,
1752
- idempotent_replay: body?.idempotent_replay,
1753
- asset_count: body?.asset_count,
1754
- asset_bytes: body?.asset_bytes,
1755
- assets_dir: effectiveAssetsDir,
1756
- verified,
1757
- viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(documentSlug || "main")}`,
1758
- };
1759
- }
1760
-
1761
- server.registerTool(
1762
- "html_show",
1763
- {
1764
- description:
1765
- "Read the current hand-authored HTML 'deal page' for a deal. " +
1766
- "Returns {empty: true} if no one has uploaded HTML yet, or " +
1767
- "{empty: false, version, html, bytes, sha256, uploaded_by, source, " +
1768
- "created_at}. The HTML can be 5-500KB — be deliberate about " +
1769
- "including the body in your reply. Use html_versions if you " +
1770
- "just want the version list without the body. Each deal can " +
1771
- "have multiple named docs — pass documentSlug to target a " +
1772
- "non-'main' one (use html_docs_list to discover them).",
1773
- inputSchema: {
1774
- dealId: z.string().describe("deal uuid"),
1775
- documentSlug: z
1776
- .string()
1777
- .optional()
1778
- .describe("default: 'main'. Use html_docs_list to discover slugs."),
1779
- },
1780
- },
1781
- async ({ dealId, documentSlug }) =>
1782
- callApi("GET", htmlUrl(dealId, documentSlug))
1783
- );
1784
-
1785
- server.registerTool(
1786
- "html_upload",
1787
- {
1788
- description:
1789
- "Upload (PUT) a new HTML version for a SPECIFIC DEAL's /browse page " +
1790
- "(deal-scoped artifact: IC memo for X · dashboard for X · 2×2 for X). " +
1791
- "Renders at /deals/<id>/browse/<slug>. " +
1792
- "**Routing — pick the right destination BEFORE calling this:** " +
1793
- "(a) Deal-specific HTML (this tool) → /deals/<id>/browse/<slug>. " +
1794
- "(b) Cross-deal / institutional / thesis / sector landscape → use " +
1795
- "`wiki_save` with content_type='html' instead (/wiki/<slug>). " +
1796
- "(c) Founder-facing public share link → escape to Netlify only when " +
1797
- "the user explicitly says 'share with founder' / 'publish publicly'; " +
1798
- "Llama Command outranks Netlify for everything internal. " +
1799
- "Creates a NEW version row — the previous version is retained " +
1800
- "and restorable. Triggers SSE push so any open viewer auto- " +
1801
- "refreshes. Constraints: HTML body MUST start with " +
1802
- "<!doctype html> or <html (case-insensitive); max 5 MB. Reliability guard: " +
1803
- "this inline-string tool refuses bodies over 50KB; use html_upload_file " +
1804
- "or `llama html publish --file` for memos/reports. ALWAYS " +
1805
- "call html_show first if anything exists — replace only the " +
1806
- "relevant section, don't lose unrelated content. Source defaults " +
1807
- "to 'agent' for MCP-originated uploads. Pass documentSlug to " +
1808
- "target a non-'main' doc — auto-creates the doc if it doesn't exist.",
1809
- inputSchema: {
1810
- dealId: z.string().describe("deal uuid"),
1811
- html: z.string().describe("complete HTML document"),
1812
- documentSlug: z
1813
- .string()
1814
- .optional()
1815
- .describe("default: 'main'"),
1816
- source: z
1817
- .enum(["web", "cli", "agent"])
1818
- .optional()
1819
- .describe("default: agent"),
1820
- clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same small inline upload"),
1821
- },
1822
- },
1823
- async ({ dealId, html, documentSlug, source, clientUploadId }) => {
1824
- const bytes = Buffer.byteLength(String(html || ""), "utf8");
1825
- if (bytes > INLINE_HTML_UPLOAD_LIMIT) {
1826
- return textResult(
1827
- `Error: html_upload received ${(bytes / 1024).toFixed(1)} KB of inline HTML. ` +
1828
- `For reliability, do not pass large HTML through MCP tool arguments. ` +
1829
- `Use html_upload_file({ dealId, filePath, documentSlug }) or run ` +
1830
- `\`llama html publish <deal-id-or-name> --file <path> --doc <slug>\` instead.`,
1831
- true,
1832
- );
1833
- }
1834
- let uploadId;
1835
- try {
1836
- uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
1837
- } catch (err) {
1838
- return textResult(`Error: ${err?.message ?? String(err)}`, true);
1839
- }
1840
- return callApi("PUT", htmlUrl(dealId, documentSlug), {
1841
- html,
1842
- source: source ?? "agent",
1843
- client_upload_id: uploadId,
1844
- }, {
1845
- headers: { "X-Llama-Upload-Id": uploadId },
1846
- });
1847
- }
1848
- );
1849
-
1850
- server.registerTool(
1851
- "html_upload_file",
1852
- {
1853
- description:
1854
- "Agent-safe HTML upload from a LOCAL FILE PATH. Use this instead " +
1855
- "of html_upload for any substantial memo/report; it avoids moving " +
1856
- "large HTML through the model/tool-call context. Reads filePath on " +
1857
- "the machine running this MCP server, preflights size/HTML shape, " +
1858
- "optionally auto-detects a sibling *_files asset folder, uploads, " +
1859
- "then reads the document back to verify version/bytes/sha256. For a higher " +
1860
- "level CLI flow that can resolve deal names and choose create/update, " +
1861
- "run `llama html publish <deal-id-or-name> --file <path>`.",
1862
- inputSchema: {
1863
- dealId: z.string().describe("deal uuid"),
1864
- filePath: z.string().describe("absolute or relative local filesystem path to the HTML file"),
1865
- documentSlug: z.string().optional().describe("default: 'main'"),
1866
- source: z.enum(["web", "cli", "agent"]).optional().describe("default: agent"),
1867
- assetsDir: z.string().optional().describe("optional local directory of relative assets"),
1868
- autoDetectAssets: z.boolean().optional().describe("default true; detects sibling *_files folders"),
1869
- verify: z.boolean().optional().describe("default true; read-after-write verification"),
1870
- clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same failed upload"),
1871
- },
1872
- },
1873
- async ({ dealId, filePath, documentSlug, source, assetsDir, autoDetectAssets, verify, clientUploadId }) => {
1874
- try {
1875
- const result = await uploadHtmlFromFile({
1876
- dealId,
1877
- filePath,
1878
- documentSlug,
1879
- source: source ?? "agent",
1880
- assetsDir,
1881
- autoDetectAssets,
1882
- verify,
1883
- clientUploadId,
1884
- });
1885
- return jsonResult(result);
1886
- } catch (err) {
1887
- return textResult(`Error: ${err?.message ?? String(err)}`, true);
1888
- }
1889
- },
1890
- );
1891
-
1892
- server.registerTool(
1893
- "html_versions",
1894
- {
1895
- description:
1896
- "List version history for a deal's /browse page HTML. Returns " +
1897
- "an array of {version, bytes, sha256, uploaded_by, source, created_at, " +
1898
- "deleted_at} — newest first, including soft-deleted versions. " +
1899
- "Use to find a target version for html_restore.",
1900
- inputSchema: {
1901
- dealId: z.string().describe("deal uuid"),
1902
- documentSlug: z.string().optional().describe("default: 'main'"),
1903
- },
1904
- },
1905
- async ({ dealId, documentSlug }) =>
1906
- callApi("GET", `${htmlUrl(dealId, documentSlug)}/history`)
1907
- );
1908
-
1909
- server.registerTool(
1910
- "html_restore",
1911
- {
1912
- description:
1913
- "Restore an old HTML version by copying it forward as a new " +
1914
- "version (so the latest pointer moves to the restored content). " +
1915
- "Use html_versions first to discover the version number. " +
1916
- "Triggers SSE push.",
1917
- inputSchema: {
1918
- dealId: z.string().describe("deal uuid"),
1919
- version: z.number().int().positive().describe("version to restore"),
1920
- documentSlug: z.string().optional().describe("default: 'main'"),
1921
- },
1922
- },
1923
- async ({ dealId, version, documentSlug }) =>
1924
- callApi("POST", `${htmlUrl(dealId, documentSlug)}/restore/${version}`)
1925
- );
1926
-
1927
- server.registerTool(
1928
- "html_docs_list",
1929
- {
1930
- description:
1931
- "List all documents (HTML 'pages') on a deal. Each deal can " +
1932
- "hold multiple — like a folder of files. Returns an array of " +
1933
- "{slug, title, preview_url, created_by, latest_version, " +
1934
- "latest_bytes, latest_uploaded_by, latest_updated_at}. The " +
1935
- "'main' slug is the default doc; non-main slugs are explicit.",
1936
- inputSchema: {
1937
- dealId: z.string().describe("deal uuid"),
1938
- },
1939
- },
1940
- async ({ dealId }) =>
1941
- callApi("GET", `/api/deals/${encodeURIComponent(dealId)}/documents`)
1942
- );
1943
-
1944
- server.registerTool(
1945
- "html_docs_create",
1946
- {
1947
- description:
1948
- "Create a NEW named document slot on a deal (metadata only — " +
1949
- "upload HTML separately via html_upload with the same slug). " +
1950
- "Slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — lowercase alnum + " +
1951
- "hyphen/underscore. Examples: 'ic-onepager', 'founder-brief', " +
1952
- "'market-map'. Title is for display; defaults to the slug.",
1953
- inputSchema: {
1954
- dealId: z.string().describe("deal uuid"),
1955
- slug: z.string().describe("URL-safe id, e.g. 'ic-onepager'"),
1956
- title: z.string().optional().describe("display title; defaults to slug"),
1957
- },
1958
- },
1959
- async ({ dealId, slug, title }) =>
1960
- callApi("POST", `/api/deals/${encodeURIComponent(dealId)}/documents`, {
1961
- slug,
1962
- title: title ?? slug,
1963
- })
1964
- );
1965
-
1966
- server.registerTool(
1967
- "html_docs_archive",
1968
- {
1969
- description:
1970
- "Archive a non-'main' doc — hides it from the selection page. " +
1971
- "HTML/asset versions are retained and the doc can be 'un-archived' " +
1972
- "later (currently via direct DB or by ensureDealDocument). The " +
1973
- "'main' doc cannot be archived (it's the default slot).",
1974
- inputSchema: {
1975
- dealId: z.string().describe("deal uuid"),
1976
- slug: z.string().describe("slug to archive (must not be 'main')"),
1977
- },
1978
- },
1979
- async ({ dealId, slug }) =>
1980
- callApi(
1981
- "DELETE",
1982
- `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug)}`
1983
- )
1984
- );
1985
-
1986
- server.registerTool(
1987
- "html_upload_bundle",
1988
- {
1989
- description:
1990
- "Legacy small inline upload for HTML + binary assets as one atomic version. " +
1991
- "For substantial memos/reports or 'Save Page As Complete' exports, use " +
1992
- "html_upload_file with filePath + assetsDir instead so large HTML/assets " +
1993
- "do not move through the model/tool-call context. This inline bundle " +
1994
- "tool refuses payloads over 50KB. The server stores HTML + each asset as " +
1995
- "one transactional bundle (deal_browse_assets " +
1996
- "table), rewrites the HTML refs to version-pinned URLs at " +
1997
- "/api/deals/<id>/asset/<path>?v=N, and triggers SSE push. " +
1998
- "Constraints: HTML <= 5 MB; each asset <= 50 MB; total bundle " +
1999
- "<= 100 MB. Asset paths must match the relative refs in the HTML " +
2000
- "(no leading './', no '..' segments).",
2001
- inputSchema: {
2002
- dealId: z.string().describe("deal uuid"),
2003
- html: z.string().describe("complete HTML document"),
2004
- assets: z
2005
- .array(
2006
- z.object({
2007
- path: z
2008
- .string()
2009
- .describe(
2010
- "relative path matching the HTML's src/href ref " +
2011
- "(e.g. 'images/cover.png' or 'Foo_files/img.jpg')",
2012
- ),
2013
- contentType: z
2014
- .string()
2015
- .describe("MIME type, e.g. 'image/jpeg', 'font/woff2'"),
2016
- base64: z
2017
- .string()
2018
- .describe("base64-encoded file bytes (NO data:URI prefix)"),
2019
- }),
2020
- )
2021
- .min(1)
2022
- .describe("at least one asset (use html_upload if no assets)"),
2023
- documentSlug: z
2024
- .string()
2025
- .optional()
2026
- .describe("default: 'main'"),
2027
- source: z
2028
- .enum(["web", "cli", "agent"])
2029
- .optional()
2030
- .describe("default: agent"),
2031
- clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same bundle upload"),
2032
- },
2033
- },
2034
- async ({ dealId, html, assets, documentSlug, source, clientUploadId }) => {
2035
- const inlineBytes =
2036
- Buffer.byteLength(String(html || ""), "utf8") +
2037
- assets.reduce((sum, a) => sum + Buffer.byteLength(String(a.base64 || ""), "utf8"), 0);
2038
- if (inlineBytes > INLINE_HTML_UPLOAD_LIMIT) {
2039
- return textResult(
2040
- `Error: html_upload_bundle received ${(inlineBytes / 1024).toFixed(1)} KB of inline tool-call payload. ` +
2041
- `For reliability, do not pass large HTML/assets through MCP arguments. ` +
2042
- `Use html_upload_file({ dealId, filePath, documentSlug, assetsDir }) or run ` +
2043
- `\`llama html publish <deal-id-or-name> --file <path> --assets <dir>\` instead.`,
2044
- true,
2045
- );
2046
- }
2047
- let uploadId;
2048
- try {
2049
- uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
2050
- } catch (err) {
2051
- return textResult(`Error: ${err?.message ?? String(err)}`, true);
2052
- }
2053
- const form = new FormData();
2054
- form.append("html", html);
2055
- form.append("source", source ?? "agent");
2056
- form.append("client_upload_id", uploadId);
2057
- for (const a of assets) {
2058
- const bytes = Buffer.from(a.base64, "base64");
2059
- form.append(
2060
- `asset:${a.path}`,
2061
- new Blob([bytes], { type: a.contentType || "application/octet-stream" }),
2062
- a.path,
2063
- );
2064
- }
2065
- const headers = await getAuthHeaders();
2066
- const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
2067
- method: "PUT",
2068
- headers: { ...headers, "X-Llama-Upload-Id": uploadId }, // let fetch set multipart Content-Type with boundary
2069
- body: form,
2070
- });
2071
- const body = await res.json().catch(() => ({}));
2072
- if (!res.ok) {
2073
- throw new Error(
2074
- `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
2075
- );
2076
- }
2077
- return body;
2078
- },
2079
- );
2080
-
2081
- server.registerTool(
2082
- "html_reset",
2083
- {
2084
- description:
2085
- "Soft-delete the latest HTML version for a deal. The /browse " +
2086
- "page reverts to its empty state (drop / paste / CLI / agent " +
2087
- "invitation). Old versions are retained and restorable via " +
2088
- "html_restore.",
2089
- inputSchema: {
2090
- dealId: z.string().describe("deal uuid"),
2091
- documentSlug: z.string().optional().describe("default: 'main'"),
2092
- },
2093
- },
2094
- async ({ dealId, documentSlug }) =>
2095
- callApi("DELETE", htmlUrl(dealId, documentSlug))
2096
- );
2097
-
2098
- // ============================================================
2099
- // Prompts
2100
- // ============================================================
2101
- //
2102
- // MCP-native agents discover prompts via prompts/list — they can fetch
2103
- // and adopt them without any user-side prompt engineering.
2104
-
2105
- server.registerPrompt(
2106
- "agent_briefing",
2107
- {
2108
- description:
2109
- "Onboard yourself as a Llama Ventures teammate. Returns the workflow " +
2110
- "contract: identity, Pipeline First rule, content capture, autonomy " +
2111
- "levels (L0/L1/L2/L3), communication style, error recovery, CLI/MCP " +
2112
- "reference, and boundaries. Read this once, internalise it, operate " +
2113
- "accordingly. Same content as `llama agent-onboard` from the CLI. " +
2114
- "For the live private Llama OS skill library, call agent_bootstrap, " +
2115
- "skills_search, and skills_read.",
364
+ description: "Fetch the authenticated current agent contract; bundled text is only an offline fallback.",
2116
365
  },
2117
366
  async () => {
2118
- // Gate the briefing behind authenticated Command runtime. The server-owned
2119
- // /api/agent/briefing contract is canonical; bundled AGENT_BRIEFING.md is
2120
- // only a rollout/offline fallback for authenticated users.
2121
367
  const headers = await getAuthHeaders();
2122
- let stub = null;
2123
- let briefing = null;
2124
- if (Object.keys(headers).length === 0) {
2125
- stub =
2126
- "Llama Ventures team onboarding requires credentials.\n\n" +
2127
- "Team member: run `gcloud auth login` with your @llamaventures.vc " +
2128
- "account, or mint a token at " +
2129
- "https://command.llamaventures.vc/settings/tokens and run " +
2130
- "`llama token set <llc_...>`. Then re-request this prompt.\n\n" +
2131
- "Founder / external visitor: use the `pitch_*` tools — no token required.";
368
+ let text;
369
+ if (!Object.keys(headers).length) {
370
+ text = "Llama team onboarding requires credentials. Run `llama auth login`; external founders use pitch_* tools.";
2132
371
  } else {
2133
372
  try {
2134
373
  const params = new URLSearchParams({ clientVersion: PKG_VERSION });
2135
- const body = await request("GET", `/api/agent/briefing?${params}`);
2136
- briefing = body?.briefing || null;
2137
- } catch (err) {
2138
- const msg = err?.message || "";
2139
- if (msg.includes("Error[UNAUTHORIZED]") || msg.includes("Error[NO_AUTH]")) {
2140
- stub =
2141
- "Llama Ventures team onboarding requires valid credentials. " +
2142
- "Server rejected the credentials we sent. Re-mint at " +
2143
- "https://command.llamaventures.vc/settings/tokens.";
2144
- } else {
2145
- briefing =
2146
- "Warning: server agent briefing unavailable; using bundled fallback.\n\n" +
2147
- readBriefing();
2148
- }
374
+ const response = await request("GET", `/api/agent/briefing?${params}`);
375
+ text = response?.briefing || readBriefing();
376
+ } catch (error) {
377
+ text = `Warning: live briefing unavailable (${error?.message ?? String(error)}).\n\n${readBriefing()}`;
2149
378
  }
2150
379
  }
2151
- return {
2152
- messages: [
2153
- {
2154
- role: "user",
2155
- content: { type: "text", text: stub ?? briefing ?? readBriefing() },
2156
- },
2157
- ],
2158
- };
2159
- }
380
+ return { messages: [{ role: "user", content: { type: "text", text } }] };
381
+ },
2160
382
  );
2161
383
 
2162
- // ============================================================
2163
- // Boot
2164
- // ============================================================
2165
-
2166
- const transport = new StdioServerTransport();
2167
- await server.connect(transport);
384
+ await server.connect(new StdioServerTransport());