@llamaventures/cli 1.16.0 → 1.17.1

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
@@ -39,6 +39,7 @@ Any time the user mentions a company name or founder name:
39
39
  1. **Run `llama deal search "<name>"` BEFORE web search.** Always. No exceptions.
40
40
  2. If pipeline has it → pull the data, integrate into your reply silently.
41
41
  3. If pipeline doesn't have it → ask once: "New name. Add to pipeline? (Y/n)". On yes, `llama deal create`.
42
+ - Use `--status Interested` when Llama wants to record/track the company before any outreach, intro, response, deck submission, or meeting.
42
43
  - Use `--status Outreached` when we only contacted/logged the company and have no response or effective relationship yet.
43
44
  - Use `--status Sourced` only once there is a response, intro, meeting, or another real relationship signal.
44
45
  - Also set `--source-direction Inbound` if the deal came into the firm; set `--source-direction Outbound` if Llama found/listed/reached out first.
@@ -82,9 +83,9 @@ HTML / thesis / artifact in hand
82
83
  └──────────────┬──────────────────────────────────┘
83
84
 
84
85
  yes ────►│ → Llama Command DEAL page
85
- │ `llama html upload <dealId> --new --title "..." --file <path>`
86
+ │ `llama html publish <deal-id-or-name> --file <path> --title "..."`
86
87
  │ Renders at /deals/<id>/browse/<slug>.
87
- │ Use --doc <slug> + --file to update an existing one.
88
+ │ Use --doc <slug> or --update when updating an existing one.
88
89
 
89
90
  no ────►│ Is it cross-deal / institutional knowledge?
90
91
  │ (sector landscape · market map · framework · firm-level
@@ -138,7 +139,7 @@ The table below details the exact CLI for each destination.
138
139
  |---|---|---|
139
140
  | Deal metadata (status, stage, valuation, founders, notes, etc.) | Pipeline (Postgres) | `llama deal create` / `llama deal update` |
140
141
  | 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. |
142
+ | **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
143
  | 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
144
  | Insights, decisions, framework improvements | Wiki (markdown) | `llama wiki save <slug> --content "..."` (with attribution — see below) |
144
145
  | **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). |
@@ -205,6 +206,7 @@ llama deal show <dealId>
205
206
  llama deal list [--owner ...] [--status ...]
206
207
 
207
208
  # Pipeline — write
209
+ llama deal create "Company" --description "..." --source-direction Outbound --status Interested
208
210
  llama deal create "Company" --description "..." --source-direction Outbound --status Outreached
209
211
  llama deal create "Company" --description "..." --source-direction Inbound --status Sourced
210
212
  llama deal update <dealId> <field> <value>
@@ -213,7 +215,9 @@ llama deal update <dealId> <field> <value>
213
215
  # foundedYear leadInvestor investors (each write logs a deal_events row)
214
216
 
215
217
  # Our Stage vocabulary starts with:
216
- # Outreached → Sourced → First Meeting → Diligence → Partner Meeting → Term Sheet → Invested
218
+ # Interested → Outreached → Sourced → First Meeting → Diligence → Partner Meeting → Term Sheet → Invested
219
+ # `Interested` is pre-contact intent to track. Do not use `manualTags=Interested`
220
+ # for new writes.
217
221
  # `Outreached` is relationship memory only. Do not inflate it to `Sourced`
218
222
  # unless a real relationship signal exists.
219
223
  # `sourceDirection` is separate: Inbound = came into the firm; Outbound =
@@ -229,6 +233,10 @@ llama brief add-callout <dealId> --tone insight|warning|info|success --heading "
229
233
  # Default path when user says "deploy to llama / 部署到 llama command / put this HTML on the deal page".
230
234
  # Each deal can host many slug-scoped artifacts. ALWAYS declare intent: new vs update.
231
235
 
236
+ # Agent-safe default: pass a file path, not inline HTML.
237
+ llama html publish "<deal name or id>" --file ./report.html --title "Consumer-Facing Thesis"
238
+ llama html publish "<deal name or id>" --file ./report.html --doc thesis --update
239
+
232
240
  llama html docs <dealId> # list slugs currently on this deal
233
241
  llama html docs create <dealId> <slug> [--title "..."] # pre-create a slot (optional; upload --new also creates)
234
242
  llama html docs archive <dealId> <slug> # soft-archive a doc
@@ -247,6 +255,11 @@ llama html restore <dealId> <version> [--doc <slug>] # promo
247
255
  llama html reset <dealId> [--doc <slug>] # soft-delete latest (browse reverts to empty)
248
256
 
249
257
  # Safety contract (since 1.5.0):
258
+ # - Coding agents should use `llama html publish ... --file <path>` for memos/reports.
259
+ # Do not move large HTML through chat text or MCP `html_upload` string args.
260
+ # - Successful uploads return `sha256` and `client_upload_id`; verification
261
+ # reads the server copy back and compares version/bytes/sha256 when available.
262
+ # If a network retry is needed for the same attempt, reuse `--upload-id`.
250
263
  # - Bare `llama html upload <id> --file X` REFUSES if 'main' already has content.
251
264
  # The error names the existing artifact and suggests --doc main / --new --title "...".
252
265
  # - --slug is silently accepted as an alias for --doc (agent-confusion mitigation).
package/CHANGELOG.md CHANGED
@@ -6,6 +6,48 @@ this project adheres to [Semantic Versioning](https://semver.org).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.17.1] - 2026-06-29
10
+
11
+ ### Changed
12
+ - Documented `Interested` as the canonical pre-outreach Our Stage across CLI
13
+ help, MCP tool schemas, README examples, and the bundled agent briefing.
14
+ Agents should use `status=Interested` for "record/track before contact" and
15
+ avoid writing new `manualTags=Interested` records.
16
+
17
+ ## [1.17.0] - 2026-06-23
18
+
19
+ ### Added
20
+ - **`llama html publish <deal-id-or-name> --file <path>`** — agent-safe
21
+ HTML artifact publishing for deal pages. It resolves deal names, preflights
22
+ file size/HTML shape, chooses a non-overwriting slug by default, auto-detects
23
+ sibling `*_files` asset folders, uploads, and performs read-after-write
24
+ verification before returning the viewer URL.
25
+ - **`html_upload_file` MCP tool** — file-path based HTML upload for MCP-native
26
+ agents, including preflight checks, optional asset-folder upload, and
27
+ read-after-write verification.
28
+
29
+ ### Changed
30
+ - HTML publishing now sends a per-attempt `client_upload_id` and verifies the
31
+ server copy by `sha256` when Command returns it. Retrying the same failed
32
+ attempt with `--upload-id` avoids duplicate versions. JSON and multipart
33
+ uploads also send the same id as `X-Llama-Upload-Id` so server logs and
34
+ retry/debug paths can correlate the attempt.
35
+ - MCP `html_upload` now refuses inline HTML bodies over 50KB and instructs
36
+ agents to use `html_upload_file` or `llama html publish --file`. This avoids
37
+ moving large memos through model/tool-call context, the main reliability
38
+ failure mode for long IC memos.
39
+ - MCP `html_upload_bundle` is now treated as a small inline fallback and refuses
40
+ tool-call payloads over 50KB. Agents should use `html_upload_file` with
41
+ `assetsDir` or `llama html publish --file --assets` for saved-page exports.
42
+ - `AGENT_BRIEFING.md` now teaches coding agents to use file-path based HTML
43
+ publishing as the default Llama Command route.
44
+ - Top-level CLI help now routes deal-specific HTML artifacts to `llama html
45
+ publish --file` instead of the lower-level upload command.
46
+ - Local-agent telemetry now redacts content payload fields such as `html`,
47
+ `body`, `content`, `markdown`, `message`, and `text`, keeping only
48
+ chars/bytes/sha256. Incident-sized memo uploads remain observable without
49
+ storing memo text in the telemetry stream.
50
+
9
51
  ## [1.16.0] - 2026-06-22
10
52
 
11
53
  ### Added
@@ -423,7 +465,22 @@ assistant-drafted (🤖) and enforces the AI trust ceiling.
423
465
 
424
466
  ---
425
467
 
426
- [Unreleased]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.1...HEAD
468
+ [Unreleased]: https://github.com/Llama-Ventures/llama-cli/compare/v1.17.1...HEAD
469
+ [1.17.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.17.0...v1.17.1
470
+ [1.17.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.16.0...v1.17.0
471
+ [1.16.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.15.1...v1.16.0
472
+ [1.15.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.15.0...v1.15.1
473
+ [1.15.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.14.1...v1.15.0
474
+ [1.14.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.14.0...v1.14.1
475
+ [1.14.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.13.0...v1.14.0
476
+ [1.13.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.12.0...v1.13.0
477
+ [1.12.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.11.0...v1.12.0
478
+ [1.11.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.10.0...v1.11.0
479
+ [1.10.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.7.0...v1.10.0
480
+ [1.7.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.5.0...v1.7.0
481
+ [1.5.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.4.4...v1.5.0
482
+ [1.4.4]: https://github.com/Llama-Ventures/llama-cli/compare/v1.4.0...v1.4.4
483
+ [1.4.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.1...v1.4.0
427
484
  [1.3.1]: https://github.com/Llama-Ventures/llama-cli/compare/v1.3.0...v1.3.1
428
485
  [1.3.0]: https://github.com/Llama-Ventures/llama-cli/compare/v1.2.4...v1.3.0
429
486
  [1.2.4]: https://github.com/Llama-Ventures/llama-cli/compare/v1.2.3...v1.2.4
package/README.md CHANGED
@@ -180,12 +180,14 @@ llama token show
180
180
 
181
181
  # Pipeline — read
182
182
  llama deal search "acme ai"
183
+ llama deal list --owner alex --status Interested
183
184
  llama deal list --owner alex --status Outreached
184
185
  llama deal list --source-direction Outbound --status Outreached
185
186
  llama deal list --owner alex --status Diligence
186
187
  llama deal show <dealId>
187
188
 
188
189
  # Pipeline — write
190
+ llama deal create "Acme AI" --description "..." --source Gavin --source-direction Outbound --status Interested
189
191
  llama deal create "Acme AI" --description "..." --source Gavin --source-direction Outbound --status Outreached
190
192
  llama deal create "Acme AI" --description "..." --source Gavin --source-direction Inbound --status Sourced
191
193
  llama deal update <dealId> status Diligence
@@ -197,6 +199,7 @@ llama deal delete <dealId> # soft (audit-logged)
197
199
  llama deal restore <dealId>
198
200
 
199
201
  # Status semantics
202
+ # Interested = record/track before outreach, intro, response, deck submission, or meeting.
200
203
  # Outreached = contact/logged, but no response or effective relationship yet.
201
204
  # Sourced = response, intro, meeting, or another real relationship signal exists.
202
205
  # sourceDirection is separate:
package/README.zh-CN.md CHANGED
@@ -201,12 +201,14 @@ llama token show
201
201
 
202
202
  # Pipeline——读
203
203
  llama deal search "acme ai"
204
+ llama deal list --owner alex --status Interested
204
205
  llama deal list --owner alex --status Outreached
205
206
  llama deal list --source-direction Outbound --status Outreached
206
207
  llama deal list --owner alex --status Diligence
207
208
  llama deal show <dealId>
208
209
 
209
210
  # Pipeline——写
211
+ llama deal create "Acme AI" --description "..." --source Gavin --source-direction Outbound --status Interested
210
212
  llama deal create "Acme AI" --description "..." --source Gavin --source-direction Outbound --status Outreached
211
213
  llama deal create "Acme AI" --description "..." --source Gavin --source-direction Inbound --status Sourced
212
214
  llama deal update <dealId> status Diligence
@@ -218,6 +220,7 @@ llama deal delete <dealId> # 软删除(审计日志记录)
218
220
  llama deal restore <dealId>
219
221
 
220
222
  # Status 语义
223
+ # Interested = 先记录/关注,还没有 outreach、intro、回复、deck submission 或 meeting。
221
224
  # Outreached = 只是联系/记录了,还没有回复或有效关系。
222
225
  # Sourced = 已有回复、intro、会议,或其它真实关系信号。
223
226
  # sourceDirection 是单独维度:
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;
@@ -350,7 +369,7 @@ server.registerTool(
350
369
  .string()
351
370
  .optional()
352
371
  .describe(
353
- "exact match on 'Our Stage' (Outreached, Sourced, First Meeting, Diligence, Partner Meeting, Term Sheet, Invested, Passed, Stalled, Future, Unknown). Outreached means contact was logged but no effective relationship/response exists yet."
372
+ "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."
354
373
  ),
355
374
  theirStage: z.string().optional().describe("exact match on 'Their Stage'"),
356
375
  stage: z
@@ -415,7 +434,7 @@ server.registerTool(
415
434
  .string()
416
435
  .optional()
417
436
  .describe(
418
- "Our Stage workflow position. Use Outreached when we only contacted/logged them and have no response/effective relationship; use Sourced only once there is a response, intro, meeting, or other real relationship signal."
437
+ "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."
419
438
  ),
420
439
  source: z.string().optional().describe("free-form sourced-by; recommend nominating a user"),
421
440
  sourceDirection: z
@@ -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(() => ({}));