@llamaventures/cli 1.16.0 → 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/AGENT_BRIEFING.md CHANGED
@@ -82,9 +82,9 @@ HTML / thesis / artifact in hand
82
82
  └──────────────┬──────────────────────────────────┘
83
83
 
84
84
  yes ────►│ → Llama Command DEAL page
85
- │ `llama html upload <dealId> --new --title "..." --file <path>`
85
+ │ `llama html publish <deal-id-or-name> --file <path> --title "..."`
86
86
  │ Renders at /deals/<id>/browse/<slug>.
87
- │ Use --doc <slug> + --file to update an existing one.
87
+ │ Use --doc <slug> or --update when updating an existing one.
88
88
 
89
89
  no ────►│ Is it cross-deal / institutional knowledge?
90
90
  │ (sector landscape · market map · framework · firm-level
@@ -138,7 +138,7 @@ The table below details the exact CLI for each destination.
138
138
  |---|---|---|
139
139
  | Deal metadata (status, stage, valuation, founders, notes, etc.) | Pipeline (Postgres) | `llama deal create` / `llama deal update` |
140
140
  | Brief blocks (text / link / embed / callout) | Pipeline | `llama brief add-text` / `add-link` / `add-callout` |
141
- | **HTML artifact, internal — IC report, dashboard, market map, 2×2, any hand-authored page** | **Llama Command native** (Postgres + sandboxed iframe at `/deals/<id>/browse/<slug>`) | Default path when the user says "deploy to llama", "deploy to llama command", "部署到 llama command", "put this HTML on the deal page", "在 deal 里看这个". **You MUST declare intent — "new artifact" vs "update existing":**<br><br>**New artifact:** `llama html upload <dealId> --new --title "<artifact name>" --file <path>` (CLI slugifies the title; pass `--doc <slug>` to override).<br>**Update existing:** `llama html upload <dealId> --doc <slug> --file <path>` (slug must already exist — run `llama html docs <dealId>` first to see what's there).<br><br>The bare form `llama html upload <id> --file <path>` REFUSES if `main` already has content. Do NOT default to Netlify for internal pages. |
141
+ | **HTML artifact, internal — IC report, dashboard, market map, 2×2, any hand-authored page** | **Llama Command native** (Postgres + sandboxed iframe at `/deals/<id>/browse/<slug>`) | Default path when the user says "deploy to llama", "deploy to llama command", "部署到 llama command", "put this HTML on the deal page", "在 deal 里看这个". **Preferred agent-safe path:** `llama html publish <deal-id-or-name> --file <path> --title "<artifact name>" [--doc <slug>]`. It resolves deal names, avoids silent overwrite, auto-detects sibling asset folders, uploads, then verifies by reading the version back.<br><br>Low-level explicit path remains available: new artifact `llama html upload <dealId> --new --title "<artifact name>" --file <path>`; update existing `llama html upload <dealId> --doc <slug> --file <path>`.<br><br>Never paste large HTML into chat or MCP tool arguments. Use file paths. Do NOT default to Netlify for internal pages. |
142
142
  | HTML artifact, external — founder-facing share link | Netlify | Only when the user explicitly says "share link", "give it to the founder", "publish publicly". Use the `netlify-access-guard` workflow (server-side password + edge 401 verification). |
143
143
  | Insights, decisions, framework improvements | Wiki (markdown) | `llama wiki save <slug> --content "..."` (with attribution — see below) |
144
144
  | **HTML wiki entry — standalone HTML page hosted at `/wiki/<slug>`** (sector landscape, market map, dashboard, hand-styled thesis page) | **Wiki (HTML)** | `llama wiki save <slug> --title "..." --file <path.html> --sources "..."`. Auto-detects content_type=html from extension. Public page is full-viewport sandboxed iframe takeover (no wiki chrome). Sources/status/title still required; appears in `wiki search` + backlinks. Use when the user says "deploy this HTML to wiki", "wiki 词条", "make this page a wiki entry". HTML must be self-contained (inline CSS/JS, image data URIs or external URLs) — asset bundles aren't supported on wiki yet. **Native comments + working in-page (#) anchor links are injected automatically** — readers discuss inline and the table of contents scrolls; you don't wire anything up (pages that already embed the comment widget are left as-is). |
@@ -229,6 +229,10 @@ llama brief add-callout <dealId> --tone insight|warning|info|success --heading "
229
229
  # Default path when user says "deploy to llama / 部署到 llama command / put this HTML on the deal page".
230
230
  # Each deal can host many slug-scoped artifacts. ALWAYS declare intent: new vs update.
231
231
 
232
+ # Agent-safe default: pass a file path, not inline HTML.
233
+ llama html publish "<deal name or id>" --file ./report.html --title "Consumer-Facing Thesis"
234
+ llama html publish "<deal name or id>" --file ./report.html --doc thesis --update
235
+
232
236
  llama html docs <dealId> # list slugs currently on this deal
233
237
  llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot (optional; upload --new also creates)
234
238
  llama html docs archive <dealId> <slug> # soft-archive a doc
@@ -247,6 +251,11 @@ llama html restore <dealId> <version> [--doc <slug>] # promo
247
251
  llama html reset <dealId> [--doc <slug>] # soft-delete latest (browse reverts to empty)
248
252
 
249
253
  # Safety contract (since 1.5.0):
254
+ # - Coding agents should use `llama html publish ... --file <path>` for memos/reports.
255
+ # Do not move large HTML through chat text or MCP `html_upload` string args.
256
+ # - Successful uploads return `sha256` and `client_upload_id`; verification
257
+ # reads the server copy back and compares version/bytes/sha256 when available.
258
+ # If a network retry is needed for the same attempt, reuse `--upload-id`.
250
259
  # - Bare `llama html upload <id> --file X` REFUSES if 'main' already has content.
251
260
  # The error names the existing artifact and suggests --doc main / --new --title "...".
252
261
  # - --slug is silently accepted as an alias for --doc (agent-confusion mitigation).
package/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ this project adheres to [Semantic Versioning](https://semver.org).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.17.0] - 2026-06-23
10
+
11
+ ### Added
12
+ - **`llama html publish <deal-id-or-name> --file <path>`** — agent-safe
13
+ HTML artifact publishing for deal pages. It resolves deal names, preflights
14
+ file size/HTML shape, chooses a non-overwriting slug by default, auto-detects
15
+ sibling `*_files` asset folders, uploads, and performs read-after-write
16
+ verification before returning the viewer URL.
17
+ - **`html_upload_file` MCP tool** — file-path based HTML upload for MCP-native
18
+ agents, including preflight checks, optional asset-folder upload, and
19
+ read-after-write verification.
20
+
21
+ ### Changed
22
+ - HTML publishing now sends a per-attempt `client_upload_id` and verifies the
23
+ server copy by `sha256` when Command returns it. Retrying the same failed
24
+ attempt with `--upload-id` avoids duplicate versions. JSON and multipart
25
+ uploads also send the same id as `X-Llama-Upload-Id` so server logs and
26
+ retry/debug paths can correlate the attempt.
27
+ - MCP `html_upload` now refuses inline HTML bodies over 50KB and instructs
28
+ agents to use `html_upload_file` or `llama html publish --file`. This avoids
29
+ moving large memos through model/tool-call context, the main reliability
30
+ failure mode for long IC memos.
31
+ - MCP `html_upload_bundle` is now treated as a small inline fallback and refuses
32
+ tool-call payloads over 50KB. Agents should use `html_upload_file` with
33
+ `assetsDir` or `llama html publish --file --assets` for saved-page exports.
34
+ - `AGENT_BRIEFING.md` now teaches coding agents to use file-path based HTML
35
+ publishing as the default Llama Command route.
36
+ - Top-level CLI help now routes deal-specific HTML artifacts to `llama html
37
+ publish --file` instead of the lower-level upload command.
38
+ - Local-agent telemetry now redacts content payload fields such as `html`,
39
+ `body`, `content`, `markdown`, `message`, and `text`, keeping only
40
+ chars/bytes/sha256. Incident-sized memo uploads remain observable without
41
+ storing memo text in the telemetry stream.
42
+
9
43
  ## [1.16.0] - 2026-06-22
10
44
 
11
45
  ### Added
@@ -423,7 +457,21 @@ assistant-drafted (🤖) and enforces the AI trust ceiling.
423
457
 
424
458
  ---
425
459
 
426
- [Unreleased]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.1...HEAD
460
+ [Unreleased]: https://github.com/Llama-Ventures/llama-cli/compare/v1.17.0...HEAD
461
+ [1.17.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.16.0...v1.17.0
462
+ [1.16.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.15.1...v1.16.0
463
+ [1.15.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.15.0...v1.15.1
464
+ [1.15.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.14.1...v1.15.0
465
+ [1.14.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.14.0...v1.14.1
466
+ [1.14.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.13.0...v1.14.0
467
+ [1.13.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.12.0...v1.13.0
468
+ [1.12.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.11.0...v1.12.0
469
+ [1.11.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.10.0...v1.11.0
470
+ [1.10.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.7.0...v1.10.0
471
+ [1.7.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.5.0...v1.7.0
472
+ [1.5.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.4.4...v1.5.0
473
+ [1.4.4]: https://github.com/Llama-Ventures/llama-cli/compare/v1.4.0...v1.4.4
474
+ [1.4.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.1...v1.4.0
427
475
  [1.3.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.0...v1.3.1
428
476
  [1.3.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.2.4...v1.3.0
429
477
  [1.2.4]: https://github.com/Llama-Ventures/llama-cli/compare/v1.2.3...v1.2.4
package/bin/llama-mcp.mjs CHANGED
@@ -8,11 +8,13 @@
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
15
  import {
15
16
  getAuthHeaders,
17
+ getBaseUrl,
16
18
  getLastAgentEvent,
17
19
  readBriefing,
18
20
  request,
@@ -32,13 +34,26 @@ import {
32
34
 
33
35
  setClientRuntime({ client: "mcp" });
34
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
+
35
50
  // Wrap a request() call into the MCP CallToolResult shape. Catches errors
36
51
  // (NO_AUTH / 401 / 5xx / network) and surfaces them as `isError: true`
37
52
  // content so the calling agent sees a clean error string instead of the
38
53
  // MCP transport closing.
39
- async function callApi(method, path, body) {
54
+ async function callApi(method, path, body, opts = {}) {
40
55
  try {
41
- const result = await request(method, path, body);
56
+ const result = await request(method, path, body, opts);
42
57
  const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
43
58
  return { content: [{ type: "text", text }] };
44
59
  } catch (err) {
@@ -56,6 +71,10 @@ function textResult(text, isError = false) {
56
71
  };
57
72
  }
58
73
 
74
+ function jsonResult(value, isError = false) {
75
+ return textResult(JSON.stringify(value, null, 2), isError);
76
+ }
77
+
59
78
  function splitSources(value) {
60
79
  if (Array.isArray(value)) return value.filter(Boolean);
61
80
  if (!value || value === true) return undefined;
@@ -1379,17 +1398,230 @@ server.registerTool(
1379
1398
  // All html_* tools take an optional documentSlug param. Default 'main'.
1380
1399
  // Each deal can hold multiple named documents (different HTMLs); use
1381
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
+
1382
1406
  function htmlUrl(dealId, slug) {
1383
1407
  return `/api/deals/${encodeURIComponent(dealId)}/documents/${encodeURIComponent(slug ?? "main")}/html`;
1384
1408
  }
1385
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
+
1386
1618
  server.registerTool(
1387
1619
  "html_show",
1388
1620
  {
1389
1621
  description:
1390
1622
  "Read the current hand-authored HTML 'deal page' for a deal. " +
1391
1623
  "Returns {empty: true} if no one has uploaded HTML yet, or " +
1392
- "{empty: false, version, html, bytes, uploaded_by, source, " +
1624
+ "{empty: false, version, html, bytes, sha256, uploaded_by, source, " +
1393
1625
  "created_at}. The HTML can be 5-500KB — be deliberate about " +
1394
1626
  "including the body in your reply. Use html_versions if you " +
1395
1627
  "just want the version list without the body. Each deal can " +
@@ -1424,7 +1656,9 @@ server.registerTool(
1424
1656
  "Creates a NEW version row — the previous version is retained " +
1425
1657
  "and restorable. Triggers SSE push so any open viewer auto- " +
1426
1658
  "refreshes. Constraints: HTML body MUST start with " +
1427
- "<!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 " +
1428
1662
  "call html_show first if anything exists — replace only the " +
1429
1663
  "relevant section, don't lose unrelated content. Source defaults " +
1430
1664
  "to 'agent' for MCP-originated uploads. Pass documentSlug to " +
@@ -1440,13 +1674,76 @@ server.registerTool(
1440
1674
  .enum(["web", "cli", "agent"])
1441
1675
  .optional()
1442
1676
  .describe("default: agent"),
1677
+ clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same small inline upload"),
1443
1678
  },
1444
1679
  },
1445
- async ({ dealId, html, documentSlug, source }) =>
1446
- 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), {
1447
1698
  html,
1448
1699
  source: source ?? "agent",
1449
- })
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
+ },
1450
1747
  );
1451
1748
 
1452
1749
  server.registerTool(
@@ -1454,7 +1751,7 @@ server.registerTool(
1454
1751
  {
1455
1752
  description:
1456
1753
  "List version history for a deal's /browse page HTML. Returns " +
1457
- "an array of {version, bytes, uploaded_by, source, created_at, " +
1754
+ "an array of {version, bytes, sha256, uploaded_by, source, created_at, " +
1458
1755
  "deleted_at} — newest first, including soft-deleted versions. " +
1459
1756
  "Use to find a target version for html_restore.",
1460
1757
  inputSchema: {
@@ -1547,11 +1844,12 @@ server.registerTool(
1547
1844
  "html_upload_bundle",
1548
1845
  {
1549
1846
  description:
1550
- "Upload HTML + binary assets as one atomic version. Use this " +
1551
- "INSTEAD of html_upload when the HTML references local images / " +
1552
- "fonts / CSS files via relative src=/href= attributes (typical " +
1553
- "of 'Save Page As Complete' exports). The server stores HTML + " +
1554
- "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 " +
1555
1853
  "table), rewrites the HTML refs to version-pinned URLs at " +
1556
1854
  "/api/deals/<id>/asset/<path>?v=N, and triggers SSE push. " +
1557
1855
  "Constraints: HTML <= 5 MB; each asset <= 50 MB; total bundle " +
@@ -1587,12 +1885,32 @@ server.registerTool(
1587
1885
  .enum(["web", "cli", "agent"])
1588
1886
  .optional()
1589
1887
  .describe("default: agent"),
1888
+ clientUploadId: z.string().optional().describe("optional retry id; reuse the same value if retrying the same bundle upload"),
1590
1889
  },
1591
1890
  },
1592
- 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
+ }
1593
1910
  const form = new FormData();
1594
1911
  form.append("html", html);
1595
1912
  form.append("source", source ?? "agent");
1913
+ form.append("client_upload_id", uploadId);
1596
1914
  for (const a of assets) {
1597
1915
  const bytes = Buffer.from(a.base64, "base64");
1598
1916
  form.append(
@@ -1604,7 +1922,7 @@ server.registerTool(
1604
1922
  const headers = await getAuthHeaders();
1605
1923
  const res = await fetch(`${getBaseUrl()}${htmlUrl(dealId, documentSlug)}`, {
1606
1924
  method: "PUT",
1607
- 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
1608
1926
  body: form,
1609
1927
  });
1610
1928
  const body = await res.json().catch(() => ({}));