@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 +17 -4
- package/CHANGELOG.md +58 -1
- package/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/bin/llama-mcp.mjs +335 -17
- package/bin/llama.mjs +470 -9
- package/lib/client.mjs +26 -9
- package/package.json +3 -2
- package/scripts/verify-agent-routing.mjs +260 -2
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
|
|
86
|
+
│ `llama html publish <deal-id-or-name> --file <path> --title "..."`
|
|
86
87
|
│ Renders at /deals/<id>/browse/<slug>.
|
|
87
|
-
│ Use --doc <slug>
|
|
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 里看这个". **
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
1551
|
-
"
|
|
1552
|
-
"
|
|
1553
|
-
"
|
|
1554
|
-
"
|
|
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(() => ({}));
|