@llamaventures/cli 1.15.1 → 1.17.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
@@ -8,10 +8,19 @@
8
8
  // CLI: gcloud (preferred) → $LLAMA_TOKEN → ~/.llama/token.
9
9
 
10
10
  import { createRequire } from "module";
11
+ import { randomUUID } from "crypto";
11
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
14
  import { z } from "zod";
14
- import { getAuthHeaders, readBriefing, request, requestSse } from "../lib/client.mjs";
15
+ import {
16
+ getAuthHeaders,
17
+ getBaseUrl,
18
+ getLastAgentEvent,
19
+ readBriefing,
20
+ request,
21
+ requestSse,
22
+ setClientRuntime,
23
+ } from "../lib/client.mjs";
15
24
 
16
25
  const requireFromHere = createRequire(import.meta.url);
17
26
  const { version: PKG_VERSION } = requireFromHere("../package.json");
@@ -23,13 +32,28 @@ import {
23
32
  uploadExternalFile,
24
33
  } from "../lib/external.mjs";
25
34
 
35
+ setClientRuntime({ client: "mcp" });
36
+
37
+ function newHtmlUploadId() {
38
+ return `mcp-${randomUUID()}`;
39
+ }
40
+
41
+ function normalizeUploadId(value) {
42
+ if (typeof value !== "string" || !value.trim()) return null;
43
+ const id = value.trim();
44
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) {
45
+ throw new Error("clientUploadId must be 1-128 chars: letters, numbers, dot, underscore, colon, or hyphen");
46
+ }
47
+ return id;
48
+ }
49
+
26
50
  // Wrap a request() call into the MCP CallToolResult shape. Catches errors
27
51
  // (NO_AUTH / 401 / 5xx / network) and surfaces them as `isError: true`
28
52
  // content so the calling agent sees a clean error string instead of the
29
53
  // MCP transport closing.
30
- async function callApi(method, path, body) {
54
+ async function callApi(method, path, body, opts = {}) {
31
55
  try {
32
- const result = await request(method, path, body);
56
+ const result = await request(method, path, body, opts);
33
57
  const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
34
58
  return { content: [{ type: "text", text }] };
35
59
  } catch (err) {
@@ -47,6 +71,10 @@ function textResult(text, isError = false) {
47
71
  };
48
72
  }
49
73
 
74
+ function jsonResult(value, isError = false) {
75
+ return textResult(JSON.stringify(value, null, 2), isError);
76
+ }
77
+
50
78
  function splitSources(value) {
51
79
  if (Array.isArray(value)) return value.filter(Boolean);
52
80
  if (!value || value === true) return undefined;
@@ -56,6 +84,20 @@ function splitSources(value) {
56
84
  .filter(Boolean);
57
85
  }
58
86
 
87
+ function expectedIds(value) {
88
+ const expected = { dealIds: [], wikiSlugs: [], raw: [] };
89
+ if (!value) return expected;
90
+ const items = Array.isArray(value) ? value : String(value).split(",");
91
+ for (const item of items.map((s) => String(s).trim()).filter(Boolean)) {
92
+ const [kind, ...rest] = item.split(":");
93
+ const id = rest.join(":").trim();
94
+ if (kind === "deal" && id) expected.dealIds.push(id);
95
+ else if ((kind === "wiki" || kind === "slug") && id) expected.wikiSlugs.push(id);
96
+ else expected.raw.push(item);
97
+ }
98
+ return expected;
99
+ }
100
+
59
101
  function buildEnrichmentAgentMessage(args = {}) {
60
102
  if (args.message) return String(args.message);
61
103
  const sources = splitSources(args.sources) ?? [
@@ -209,11 +251,47 @@ server.registerTool(
209
251
  },
210
252
  async ({ limit } = {}) => {
211
253
  const params = new URLSearchParams();
254
+ params.set("clientVersion", PKG_VERSION);
212
255
  if (limit) params.set("limit", String(limit));
213
256
  return callApi("GET", `/api/agent/manifest${params.toString() ? `?${params}` : ""}`);
214
257
  }
215
258
  );
216
259
 
260
+ server.registerTool(
261
+ "record_eval_feedback",
262
+ {
263
+ description:
264
+ "Mark the latest llama CLI/MCP result as good/bad or add a real query " +
265
+ "to the Golden Query Eval candidate pool. Use when the user says a " +
266
+ "Llama Command search/result was right, wrong, missing a source, or should be regression-tested.",
267
+ inputSchema: {
268
+ action: z.enum(["good", "bad", "add"]).describe("feedback action"),
269
+ eventId: z.number().optional().describe("agent_client_events id; defaults to latest local event"),
270
+ query: z.string().optional().describe("required for manual add when no source event is available"),
271
+ surface: z.string().optional().describe("deal, wiki, activity, people, or manual"),
272
+ expect: z
273
+ .union([z.string(), z.array(z.string())])
274
+ .optional()
275
+ .describe("expected ids like wiki:llamaos-weekly-2026-06-17 or deal:<uuid>"),
276
+ reason: z.string().optional().describe("why this was good/bad or should be tracked"),
277
+ privacyLevel: z.string().optional().describe("default internal"),
278
+ },
279
+ },
280
+ async ({ action, eventId, query, surface, expect, reason, privacyLevel }) => {
281
+ const last = getLastAgentEvent();
282
+ const body = {
283
+ action,
284
+ eventId: eventId ?? last?.lastEventId,
285
+ query,
286
+ surface: surface ?? last?.lastSurface,
287
+ expected: expectedIds(expect),
288
+ reason,
289
+ privacyLevel: privacyLevel ?? "internal",
290
+ };
291
+ return callApi("POST", "/api/agent/eval-feedback", body);
292
+ }
293
+ );
294
+
217
295
  server.registerTool(
218
296
  "skills_search",
219
297
  {
@@ -1320,17 +1398,230 @@ server.registerTool(
1320
1398
  // All html_* tools take an optional documentSlug param. Default 'main'.
1321
1399
  // Each deal can hold multiple named documents (different HTMLs); use
1322
1400
  // html_docs_list to discover slugs.
1401
+ const INLINE_HTML_UPLOAD_LIMIT = 50 * 1024;
1402
+ const MAX_HTML_BYTES = 5 * 1024 * 1024;
1403
+ const MAX_ASSET_BYTES = 50 * 1024 * 1024;
1404
+ const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
1405
+
1323
1406
  function htmlUrl(dealId, slug) {
1324
1407
  return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug ?? "main")}/html`;
1325
1408
  }
1326
1409
 
1410
+ function looksLikeHtml(html) {
1411
+ const head = String(html || "").trim().slice(0, 256).toLowerCase();
1412
+ return head.startsWith("<!doctype html") || head.startsWith("<html");
1413
+ }
1414
+
1415
+ function mimeForAsset(path) {
1416
+ const ext = (String(path).split(".").pop() || "").toLowerCase();
1417
+ return (
1418
+ {
1419
+ jpg: "image/jpeg",
1420
+ jpeg: "image/jpeg",
1421
+ png: "image/png",
1422
+ gif: "image/gif",
1423
+ webp: "image/webp",
1424
+ svg: "image/svg+xml",
1425
+ ico: "image/x-icon",
1426
+ avif: "image/avif",
1427
+ css: "text/css",
1428
+ js: "text/javascript",
1429
+ json: "application/json",
1430
+ woff: "font/woff",
1431
+ woff2: "font/woff2",
1432
+ ttf: "font/ttf",
1433
+ otf: "font/otf",
1434
+ mp4: "video/mp4",
1435
+ webm: "video/webm",
1436
+ pdf: "application/pdf",
1437
+ }[ext] || "application/octet-stream"
1438
+ );
1439
+ }
1440
+
1441
+ async function detectSiblingAssetsDir(filePath) {
1442
+ const { existsSync, statSync } = await import("node:fs");
1443
+ const { dirname, basename, extname, join } = await import("node:path");
1444
+ const dir = dirname(filePath);
1445
+ const stem = basename(filePath, extname(filePath));
1446
+ const candidates = [
1447
+ `${stem}_files`,
1448
+ `${stem} files`,
1449
+ `${basename(filePath)}_files`,
1450
+ ];
1451
+ for (const name of candidates) {
1452
+ const p = join(dir, name);
1453
+ if (existsSync(p) && statSync(p).isDirectory()) return p;
1454
+ }
1455
+ return null;
1456
+ }
1457
+
1458
+ async function collectAssets(assetsRoot) {
1459
+ const { readFileSync, readdirSync, statSync } = await import("node:fs");
1460
+ const { join, relative, sep, basename } = await import("node:path");
1461
+ const rootStat = statSync(assetsRoot);
1462
+ if (!rootStat.isDirectory()) {
1463
+ throw new Error(`assetsDir must point to a directory: ${assetsRoot}`);
1464
+ }
1465
+ const collected = [];
1466
+ const walk = (dir) => {
1467
+ for (const name of readdirSync(dir)) {
1468
+ const absPath = join(dir, name);
1469
+ const st = statSync(absPath);
1470
+ if (st.isDirectory()) {
1471
+ walk(absPath);
1472
+ } else if (st.isFile()) {
1473
+ const relPath = relative(assetsRoot, absPath).split(sep).join("/");
1474
+ collected.push({ absPath, relPath, bytes: st.size });
1475
+ }
1476
+ }
1477
+ };
1478
+ walk(assetsRoot);
1479
+ if (collected.length === 0) {
1480
+ throw new Error(`assetsDir is empty: ${assetsRoot}`);
1481
+ }
1482
+ const rootName = basename(assetsRoot);
1483
+ const looksLikeSavePageDir = /[_ ]files$/i.test(rootName);
1484
+ const finalPaths = looksLikeSavePageDir
1485
+ ? collected.map((c) => ({ ...c, relPath: `${rootName}/${c.relPath}` }))
1486
+ : collected;
1487
+ let totalBytes = 0;
1488
+ for (const item of finalPaths) {
1489
+ if (item.relPath.split("/").some((seg) => seg === "..")) {
1490
+ throw new Error(`asset path "${item.relPath}" contains "..", refused`);
1491
+ }
1492
+ if (item.bytes > MAX_ASSET_BYTES) {
1493
+ throw new Error(
1494
+ `asset "${item.relPath}" is ${item.bytes} bytes; cap is ${MAX_ASSET_BYTES}`,
1495
+ );
1496
+ }
1497
+ totalBytes += item.bytes;
1498
+ if (totalBytes > MAX_BUNDLE_BYTES) {
1499
+ throw new Error(`total asset bytes exceeds ${MAX_BUNDLE_BYTES}`);
1500
+ }
1501
+ }
1502
+ return {
1503
+ assets: finalPaths.map((item) => ({
1504
+ ...item,
1505
+ data: readFileSync(item.absPath),
1506
+ contentType: mimeForAsset(item.relPath),
1507
+ })),
1508
+ totalBytes,
1509
+ };
1510
+ }
1511
+
1512
+ async function uploadHtmlFromFile({
1513
+ dealId,
1514
+ filePath,
1515
+ documentSlug,
1516
+ source = "agent",
1517
+ assetsDir,
1518
+ autoDetectAssets = true,
1519
+ verify = true,
1520
+ clientUploadId,
1521
+ }) {
1522
+ const { readFileSync, statSync } = await import("node:fs");
1523
+ const st = statSync(filePath);
1524
+ if (!st.isFile()) throw new Error(`filePath must point to a file: ${filePath}`);
1525
+ const html = readFileSync(filePath, "utf8");
1526
+ if (!html.trim()) throw new Error("HTML body is empty.");
1527
+ const htmlBytes = Buffer.byteLength(html, "utf8");
1528
+ if (htmlBytes > MAX_HTML_BYTES) {
1529
+ throw new Error(
1530
+ `HTML body is ${(htmlBytes / 1024 / 1024).toFixed(2)} MB; cap is 5 MB.`,
1531
+ );
1532
+ }
1533
+ if (!looksLikeHtml(html)) {
1534
+ throw new Error("HTML must start with <!doctype html> or <html.");
1535
+ }
1536
+
1537
+ let effectiveAssetsDir = assetsDir || null;
1538
+ if (!effectiveAssetsDir && autoDetectAssets !== false) {
1539
+ effectiveAssetsDir = await detectSiblingAssetsDir(filePath);
1540
+ }
1541
+ const uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
1542
+
1543
+ let body;
1544
+ if (!effectiveAssetsDir) {
1545
+ body = await request("PUT", htmlUrl(dealId, documentSlug), {
1546
+ html,
1547
+ source,
1548
+ client_upload_id: uploadId,
1549
+ }, {
1550
+ headers: { "X-Llama-Upload-Id": uploadId },
1551
+ });
1552
+ } else {
1553
+ const { assets, totalBytes } = await collectAssets(effectiveAssetsDir);
1554
+ const form = new FormData();
1555
+ form.append("html", html);
1556
+ form.append("source", source);
1557
+ form.append("client_upload_id", uploadId);
1558
+ for (const asset of assets) {
1559
+ form.append(
1560
+ `asset:${asset.relPath}`,
1561
+ new Blob([asset.data], { type: asset.contentType }),
1562
+ asset.relPath,
1563
+ );
1564
+ }
1565
+ const headers = await getAuthHeaders();
1566
+ const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
1567
+ method: "PUT",
1568
+ headers: { ...headers, "X-Llama-Upload-Id": uploadId },
1569
+ body: form,
1570
+ });
1571
+ body = await res.json().catch(() => ({}));
1572
+ if (!res.ok) {
1573
+ throw new Error(
1574
+ `HTTP ${res.status}: ${body?.error || JSON.stringify(body).slice(0, 300)}`,
1575
+ );
1576
+ }
1577
+ body = { ...body, asset_bytes: body.asset_bytes ?? totalBytes };
1578
+ }
1579
+
1580
+ let verified = { ok: false, skipped: true };
1581
+ if (verify !== false) {
1582
+ const latest = await request("GET", htmlUrl(dealId, documentSlug));
1583
+ if (latest?.empty) throw new Error("verification failed: document came back empty after upload");
1584
+ if (body?.version != null && Number(latest.version) !== Number(body.version)) {
1585
+ throw new Error(`verification failed: expected version ${body.version}, got ${latest.version}`);
1586
+ }
1587
+ if (body?.bytes != null && latest.bytes != null && Number(latest.bytes) !== Number(body.bytes)) {
1588
+ throw new Error(`verification failed: expected ${body.bytes} bytes, got ${latest.bytes}`);
1589
+ }
1590
+ if (body?.sha256 && latest.sha256 && String(latest.sha256) !== String(body.sha256)) {
1591
+ throw new Error(`verification failed: expected sha256 ${body.sha256}, got ${latest.sha256}`);
1592
+ }
1593
+ verified = {
1594
+ ok: true,
1595
+ version: latest.version,
1596
+ bytes: latest.bytes,
1597
+ sha256: latest.sha256,
1598
+ created_at: latest.created_at,
1599
+ };
1600
+ }
1601
+
1602
+ return {
1603
+ ok: true,
1604
+ document_slug: documentSlug || "main",
1605
+ version: body?.version,
1606
+ bytes: body?.bytes ?? verified.bytes ?? htmlBytes,
1607
+ sha256: body?.sha256 ?? verified.sha256,
1608
+ client_upload_id: body?.client_upload_id ?? uploadId,
1609
+ idempotent_replay: body?.idempotent_replay,
1610
+ asset_count: body?.asset_count,
1611
+ asset_bytes: body?.asset_bytes,
1612
+ assets_dir: effectiveAssetsDir,
1613
+ verified,
1614
+ viewer: `${getBaseUrl()}/deals/${encodeURIComponent(dealId)}/browse/${encodeURIComponent(documentSlug || "main")}`,
1615
+ };
1616
+ }
1617
+
1327
1618
  server.registerTool(
1328
1619
  "html_show",
1329
1620
  {
1330
1621
  description:
1331
1622
  "Read the current hand-authored HTML 'deal page' for a deal. " +
1332
1623
  "Returns {empty: true} if no one has uploaded HTML yet, or " +
1333
- "{empty: false, version, html, bytes, uploaded_by, source, " +
1624
+ "{empty: false, version, html, bytes, sha256, uploaded_by, source, " +
1334
1625
  "created_at}. The HTML can be 5-500KB — be deliberate about " +
1335
1626
  "including the body in your reply. Use html_versions if you " +
1336
1627
  "just want the version list without the body. Each deal can " +
@@ -1365,7 +1656,9 @@ server.registerTool(
1365
1656
  "Creates a NEW version row — the previous version is retained " +
1366
1657
  "and restorable. Triggers SSE push so any open viewer auto- " +
1367
1658
  "refreshes. Constraints: HTML body MUST start with " +
1368
- "<!doctype html> or <html (case-insensitive); max 5 MB. ALWAYS " +
1659
+ "<!doctype html> or <html (case-insensitive); max 5 MB. Reliability guard: " +
1660
+ "this inline-string tool refuses bodies over 50KB; use html_upload_file " +
1661
+ "or `llama html publish --file` for memos/reports. ALWAYS " +
1369
1662
  "call html_show first if anything exists — replace only the " +
1370
1663
  "relevant section, don't lose unrelated content. Source defaults " +
1371
1664
  "to 'agent' for MCP-originated uploads. Pass documentSlug to " +
@@ -1381,13 +1674,76 @@ server.registerTool(
1381
1674
  .enum(["web", "cli", "agent"])
1382
1675
  .optional()
1383
1676
  .describe("default: agent"),
1677
+ clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same small inline upload"),
1384
1678
  },
1385
1679
  },
1386
- async ({ dealId, html, documentSlug, source }) =>
1387
- callApi("PUT", htmlUrl(dealId, documentSlug), {
1680
+ async ({ dealId, html, documentSlug, source, clientUploadId }) => {
1681
+ const bytes = Buffer.byteLength(String(html || ""), "utf8");
1682
+ if (bytes > INLINE_HTML_UPLOAD_LIMIT) {
1683
+ return textResult(
1684
+ `Error: html_upload received ${(bytes / 1024).toFixed(1)} KB of inline HTML. ` +
1685
+ `For reliability, do not pass large HTML through MCP tool arguments. ` +
1686
+ `Use html_upload_file({ dealId, filePath, documentSlug }) or run ` +
1687
+ `\`llama html publish <deal-id-or-name> --file <path> --doc <slug>\` instead.`,
1688
+ true,
1689
+ );
1690
+ }
1691
+ let uploadId;
1692
+ try {
1693
+ uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
1694
+ } catch (err) {
1695
+ return textResult(`Error: ${err?.message ?? String(err)}`, true);
1696
+ }
1697
+ return callApi("PUT", htmlUrl(dealId, documentSlug), {
1388
1698
  html,
1389
1699
  source: source ?? "agent",
1390
- })
1700
+ client_upload_id: uploadId,
1701
+ }, {
1702
+ headers: { "X-Llama-Upload-Id": uploadId },
1703
+ });
1704
+ }
1705
+ );
1706
+
1707
+ server.registerTool(
1708
+ "html_upload_file",
1709
+ {
1710
+ description:
1711
+ "Agent-safe HTML upload from a LOCAL FILE PATH. Use this instead " +
1712
+ "of html_upload for any substantial memo/report; it avoids moving " +
1713
+ "large HTML through the model/tool-call context. Reads filePath on " +
1714
+ "the machine running this MCP server, preflights size/HTML shape, " +
1715
+ "optionally auto-detects a sibling *_files asset folder, uploads, " +
1716
+ "then reads the document back to verify version/bytes/sha256. For a higher " +
1717
+ "level CLI flow that can resolve deal names and choose create/update, " +
1718
+ "run `llama html publish <deal-id-or-name> --file <path>`.",
1719
+ inputSchema: {
1720
+ dealId: z.string().describe("deal uuid"),
1721
+ filePath: z.string().describe("absolute or relative local filesystem path to the HTML file"),
1722
+ documentSlug: z.string().optional().describe("default: 'main'"),
1723
+ source: z.enum(["web", "cli", "agent"]).optional().describe("default: agent"),
1724
+ assetsDir: z.string().optional().describe("optional local directory of relative assets"),
1725
+ autoDetectAssets: z.boolean().optional().describe("default true; detects sibling *_files folders"),
1726
+ verify: z.boolean().optional().describe("default true; read-after-write verification"),
1727
+ clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same failed upload"),
1728
+ },
1729
+ },
1730
+ async ({ dealId, filePath, documentSlug, source, assetsDir, autoDetectAssets, verify, clientUploadId }) => {
1731
+ try {
1732
+ const result = await uploadHtmlFromFile({
1733
+ dealId,
1734
+ filePath,
1735
+ documentSlug,
1736
+ source: source ?? "agent",
1737
+ assetsDir,
1738
+ autoDetectAssets,
1739
+ verify,
1740
+ clientUploadId,
1741
+ });
1742
+ return jsonResult(result);
1743
+ } catch (err) {
1744
+ return textResult(`Error: ${err?.message ?? String(err)}`, true);
1745
+ }
1746
+ },
1391
1747
  );
1392
1748
 
1393
1749
  server.registerTool(
@@ -1395,7 +1751,7 @@ server.registerTool(
1395
1751
  {
1396
1752
  description:
1397
1753
  "List version history for a deal's /browse page HTML. Returns " +
1398
- "an array of {version, bytes, uploaded_by, source, created_at, " +
1754
+ "an array of {version, bytes, sha256, uploaded_by, source, created_at, " +
1399
1755
  "deleted_at} — newest first, including soft-deleted versions. " +
1400
1756
  "Use to find a target version for html_restore.",
1401
1757
  inputSchema: {
@@ -1488,11 +1844,12 @@ server.registerTool(
1488
1844
  "html_upload_bundle",
1489
1845
  {
1490
1846
  description:
1491
- "Upload HTML + binary assets as one atomic version. Use this " +
1492
- "INSTEAD of html_upload when the HTML references local images / " +
1493
- "fonts / CSS files via relative src=/href= attributes (typical " +
1494
- "of 'Save Page As Complete' exports). The server stores HTML + " +
1495
- "each asset as one transactional bundle (deal_browse_assets " +
1847
+ "Legacy small inline upload for HTML + binary assets as one atomic version. " +
1848
+ "For substantial memos/reports or 'Save Page As Complete' exports, use " +
1849
+ "html_upload_file with filePath + assetsDir instead so large HTML/assets " +
1850
+ "do not move through the model/tool-call context. This inline bundle " +
1851
+ "tool refuses payloads over 50KB. The server stores HTML + each asset as " +
1852
+ "one transactional bundle (deal_browse_assets " +
1496
1853
  "table), rewrites the HTML refs to version-pinned URLs at " +
1497
1854
  "/api/deals/<id>/asset/<path>?v=N, and triggers SSE push. " +
1498
1855
  "Constraints: HTML <= 5 MB; each asset <= 50 MB; total bundle " +
@@ -1528,12 +1885,32 @@ server.registerTool(
1528
1885
  .enum(["web", "cli", "agent"])
1529
1886
  .optional()
1530
1887
  .describe("default: agent"),
1888
+ clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same bundle upload"),
1531
1889
  },
1532
1890
  },
1533
- async ({ dealId, html, assets, documentSlug, source }) => {
1891
+ async ({ dealId, html, assets, documentSlug, source, clientUploadId }) => {
1892
+ const inlineBytes =
1893
+ Buffer.byteLength(String(html || ""), "utf8") +
1894
+ assets.reduce((sum, a) => sum + Buffer.byteLength(String(a.base64 || ""), "utf8"), 0);
1895
+ if (inlineBytes > INLINE_HTML_UPLOAD_LIMIT) {
1896
+ return textResult(
1897
+ `Error: html_upload_bundle received ${(inlineBytes / 1024).toFixed(1)} KB of inline tool-call payload. ` +
1898
+ `For reliability, do not pass large HTML/assets through MCP arguments. ` +
1899
+ `Use html_upload_file({ dealId, filePath, documentSlug, assetsDir }) or run ` +
1900
+ `\`llama html publish <deal-id-or-name> --file <path> --assets <dir>\` instead.`,
1901
+ true,
1902
+ );
1903
+ }
1904
+ let uploadId;
1905
+ try {
1906
+ uploadId = normalizeUploadId(clientUploadId) || newHtmlUploadId();
1907
+ } catch (err) {
1908
+ return textResult(`Error: ${err?.message ?? String(err)}`, true);
1909
+ }
1534
1910
  const form = new FormData();
1535
1911
  form.append("html", html);
1536
1912
  form.append("source", source ?? "agent");
1913
+ form.append("client_upload_id", uploadId);
1537
1914
  for (const a of assets) {
1538
1915
  const bytes = Buffer.from(a.base64, "base64");
1539
1916
  form.append(
@@ -1545,7 +1922,7 @@ server.registerTool(
1545
1922
  const headers = await getAuthHeaders();
1546
1923
  const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
1547
1924
  method: "PUT",
1548
- headers, // let fetch set multipart Content-Type with boundary
1925
+ headers: { ...headers, "X-Llama-Upload-Id": uploadId }, // let fetch set multipart Content-Type with boundary
1549
1926
  body: form,
1550
1927
  });
1551
1928
  const body = await res.json().catch(() => ({}));
@@ -1595,11 +1972,12 @@ server.registerPrompt(
1595
1972
  "skills_search, and skills_read.",
1596
1973
  },
1597
1974
  async () => {
1598
- // Gate the briefing behind a /api/me check so unauthenticated MCP
1599
- // clients can't harvest internal workflow / command surface just by
1600
- // requesting the prompt. Mirrors the CLI gate in bin/llama.mjs.
1975
+ // Gate the briefing behind authenticated Command runtime. The server-owned
1976
+ // /api/agent/briefing contract is canonical; bundled AGENT_BRIEFING.md is
1977
+ // only a rollout/offline fallback for authenticated users.
1601
1978
  const headers = await getAuthHeaders();
1602
1979
  let stub = null;
1980
+ let briefing = null;
1603
1981
  if (Object.keys(headers).length === 0) {
1604
1982
  stub =
1605
1983
  "Llama Ventures team onboarding requires credentials.\n\n" +
@@ -1610,19 +1988,28 @@ server.registerPrompt(
1610
1988
  "Founder / external visitor: use the `pitch_*` tools — no token required.";
1611
1989
  } else {
1612
1990
  try {
1613
- await request("GET", "/api/me");
1991
+ const params = new URLSearchParams({ clientVersion: PKG_VERSION });
1992
+ const body = await request("GET", `/api/agent/briefing?${params}`);
1993
+ briefing = body?.briefing || null;
1614
1994
  } catch (err) {
1615
- stub =
1616
- "Llama Ventures team onboarding requires valid credentials. " +
1617
- "Server rejected the credentials we sent. Re-mint at " +
1618
- "https://command.llamaventures.vc/settings/tokens.";
1995
+ const msg = err?.message || "";
1996
+ if (msg.includes("Error[UNAUTHORIZED]") || msg.includes("Error[NO_AUTH]")) {
1997
+ stub =
1998
+ "Llama Ventures team onboarding requires valid credentials. " +
1999
+ "Server rejected the credentials we sent. Re-mint at " +
2000
+ "https://command.llamaventures.vc/settings/tokens.";
2001
+ } else {
2002
+ briefing =
2003
+ "Warning: server agent briefing unavailable; using bundled fallback.\n\n" +
2004
+ readBriefing();
2005
+ }
1619
2006
  }
1620
2007
  }
1621
2008
  return {
1622
2009
  messages: [
1623
2010
  {
1624
2011
  role: "user",
1625
- content: { type: "text", text: stub ?? readBriefing() },
2012
+ content: { type: "text", text: stub ?? briefing ?? readBriefing() },
1626
2013
  },
1627
2014
  ],
1628
2015
  };