diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 22d17ce..2086d92 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -123,11 +123,14 @@ touches_consent=$(printf '%s\n' "$staged" | grep -E '(^|/)(play\.html|scripts/ch
# play.html bakes creator sample results into #a= shares so keyless recipients see what the app makes;
# index.html's buildShareUrl("app") packs the same envelope (samples + lang parity, asserted by the checker).
touches_sharesamples=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\.html|scripts/check-share-samples\.mjs)$' || true)
+# llms-full.txt is generated and says "do not edit by hand", so only a re-run of the generator keeps it
+# true. It once advertised the retired draw node for days after every source had dropped it.
+touches_llmsfull=$(printf '%s\n' "$staged" | grep -E '(^|/)(llms-full\.txt|llms\.txt|README\.md|index\.html|scripts/(gen-llms-full|check-llms-full)\.mjs)$' || true)
# Nothing to check unless an .html file, one of the checkers, or a guarded doc changed.
# Every touches_* variable belongs in this chain — a gate left out of it never runs on a
# commit that stages only its own files, which is exactly the commit it exists to catch.
-[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_examples" ] && exit 0
+[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && exit 0
if ! command -v node >/dev/null 2>&1; then
echo "pre-commit: node not found, skipping HTML JS checks" >&2
exit 0
@@ -502,3 +505,14 @@ fi
if [ -n "$touches_launchfacts" ]; then
node "$root/scripts/check-launch-facts.mjs"
fi
+
+# 42) LLMS-FULL FRESHNESS: llms-full.txt is the single-file reference agents fetch, it is generated,
+# and it says "do not edit by hand" — so nothing keeps it true except somebody re-running the
+# generator. Nobody did, and it advertised the retired draw node after every source had dropped
+# it. Offline: regenerates the sections sourced from THIS repo (llms.txt, README.md, index.html)
+# and diffs them byte for byte, then asserts every node type named in the two libraries'
+# supported-node tables is still registered in index.html. Regenerate with
+# `node scripts/gen-llms-full.mjs`. CI diffs the library sections too, against fresh checkouts.
+if [ -n "$touches_llmsfull" ]; then
+ node "$root/scripts/check-llms-full.mjs"
+fi
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index b659beb..42ef312 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -22,6 +22,12 @@ jobs:
repository: nanoodlecom/nanoodle-js
path: nanoodle-js-sibling
+ - name: Checkout nanoodle-py (llms-full.txt source)
+ uses: actions/checkout@v4
+ with:
+ repository: nanoodlecom/nanoodle-py
+ path: nanoodle-py-sibling
+
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -45,3 +51,13 @@ jobs:
fails=$((fails+1))
fi
exit $fails
+
+ # The pre-commit guard can only diff the sections llms-full.txt takes from THIS
+ # repo; the two library sections need the sibling READMEs. Here they are checked
+ # out fresh, so this is the check that catches a library README moving on without
+ # a regeneration. Reads the siblings from disk — no clone, no network.
+ - name: llms-full.txt matches the library READMEs
+ env:
+ NANOODLE_JS: ${{ github.workspace }}/nanoodle-js-sibling
+ NANOODLE_PY: ${{ github.workspace }}/nanoodle-py-sibling
+ run: node scripts/gen-llms-full.mjs --check
diff --git a/growth/i18n-drafts/zenn-comfyui-ja.md b/growth/i18n-drafts/zenn-comfyui-ja.md
index 3b844d4..6b29bb2 100644
--- a/growth/i18n-drafts/zenn-comfyui-ja.md
+++ b/growth/i18n-drafts/zenn-comfyui-ja.md
@@ -78,7 +78,7 @@ https://nanoodle.com/ja/ を開くとエディタがそのまま起動します
### 2. ノードを置いてつなぐ
-テキスト・画像・動画・音声のモデルノードに加えて、Draw(手描き入力)、
+テキスト・画像・動画・音声のモデルノードに加えて、Join(テキスト結合)、
Resize、Choice、Comment などのユーティリティノードがあります。
たとえば:
diff --git a/guide/graph-format.html b/guide/graph-format.html
index 31423e0..a93dfe4 100644
--- a/guide/graph-format.html
+++ b/guide/graph-format.html
@@ -129,7 +129,7 @@
- Declared inputs — fixed per type (e.g.
inpaint takes
image and mask), plus dynamic families that grow as you wire
- more: img1, img2, … (LLM/Draw vision references),
+ more: img1, img2, … (LLM vision references),
image, image2, … (Edit references), ref1, …
(Text→Video references), clip1, … (Combine), and endframe
(Image→Video) / audio (LLM audio input).
@@ -143,8 +143,11 @@ Ports and wires
Node types
"local" runs without any network call; "NanoGPT" calls the API (and spends);
- "browser-only" nodes do media processing with browser APIs — they work in the app but the
- headless executors reject them up front, before any paid call.
+ "local media" nodes process media on your own machine and never spend. The browser app
+ does that work with canvas, Web Audio and MediaRecorder. The headless executors run the
+ same 6 node types, but some of them need ffmpeg and ffprobe on
+ PATH — see what runs where
+ for the per-node detail.
| type | node | runs | inputs | outputs | key fields |
text | Text | local | — | text | text |
@@ -155,23 +158,22 @@ Node types
join | Join | local | a, b (text) | text | sep |
llm | LLM | NanoGPT | img1… (image), audio | text | model, system, prompt, temperature, maxTokens, format, reasoningEffort, showThinking |
image | Image | NanoGPT | — | image | model, prompt, size, variations, seed, customCivitaiAir, negativePrompt |
- draw | Draw | NanoGPT | img1… (image) | image, text | model, system, prompt, showThinking |
edit | Edit | NanoGPT | image, image2… | image | model, prompt, size, seed |
inpaint | Inpaint | NanoGPT | image, mask | image | model, prompt, size, seed |
- resize | Resize / crop | browser-only | image | image | mode, width, height |
+ resize | Resize / crop | local media | image | image | mode, width, height |
vision | Vision | NanoGPT | image | text | model, q |
tvideo | Text→Video | NanoGPT | ref1… (image) | video | model, prompt, duration, aspect, resolution, modelOpts |
ivideo | Image→Video | NanoGPT | image, endframe | video | model, prompt, duration, aspect, resolution, modelOpts |
vedit | Video edit | NanoGPT | video | video | model, prompt, resolution, modelOpts |
- vframes | Video → frames | browser-only | video | frame1…frameN (image) | frames, gap, dir |
- combine | Combine videos | browser-only | clip1… (video) | video | dedup |
- soundtrack | Soundtrack | browser-only | video, audio | video | loop |
+ vframes | Video → frames | local media | video | frame1…frameN (image) | frames, gap, dir |
+ combine | Combine videos | local media | clip1… (video) | video | dedup |
+ soundtrack | Soundtrack | local media | video, audio | video | loop |
lipsync | Avatar / lipsync | NanoGPT | image, audio | video | model, prompt, resolution, modelOpts |
music | Music | NanoGPT | — | audio | model, prompt, lyrics, instrumental, duration, negative_prompt, seed, extraJson |
remix | Remix audio | NanoGPT | audio | audio | model, prompt, lyrics, duration, extraJson |
tts | Speech | NanoGPT | — | audio | model, prompt, voice, speed, instructions, extraJson |
- trim | Trim audio | browser-only | audio | audio | start, length |
- extractaudio | Extract audio | browser-only | video | audio | start, length |
+ trim | Trim audio | local media | audio | audio | start, length |
+ extractaudio | Extract audio | local media | video | audio | start, length |
transcribe | Transcribe | NanoGPT | audio | text | model, language |
comment | Comment | local (never runs) | — | — | text, color |
@@ -186,6 +188,12 @@ Loader rules
had a single combined audio node).
- Unknown node types don't crash a load — the editor skips them; the headless
libraries keep them, warn, and fail fast at run time.
+ - Retired type:
draw (🎨 Draw) retired on 2026-07-22, because its
+ Gemini omni model left the NanoGPT catalog. It is not in the table above and you
+ cannot add it. A graph saved before that date still loads — the editor skips the
+ node and the headless libraries warn on load, then refuse the run. To repair such a
+ graph, replace the draw node with image (generate) or
+ edit (edit an image you wire in).
- Keep a link only if both endpoint nodes exist after filtering.
- Migration: a link into a
music or tts node's
"text" port is rewritten to "prompt".
diff --git a/guide/run-headless.html b/guide/run-headless.html
index 32feb78..ac307d5 100644
--- a/guide/run-headless.html
+++ b/guide/run-headless.html
@@ -130,13 +130,31 @@ Inputs, outputs, settings
inline as base64 (NanoGPT has no upload endpoint); files over ~4.4 MB (~3.5 MB
for transcription) are refused locally with a clear error before any paid call.
- What runs where
+ What runs where
Pure-logic nodes (text, uploads, choice, join, comment) run locally; generation nodes
- (llm, image, draw, edit, inpaint, vision, video, lipsync, music, remix, tts, transcribe)
- call NanoGPT. Browser-only media-processing nodes (resize, vframes, combine, soundtrack,
- trim, extractaudio) are not supported headlessly: workflows containing them load
- with a warning and fail fast at run(), before any network call. The full
- table is on the format page.
+ (llm, image, edit, inpaint, vision, video, lipsync, music, remix, tts, transcribe)
+ call NanoGPT. The 6 media-processing nodes (resize, vframes, combine, soundtrack, trim,
+ extractaudio) also run headlessly. They do their work on your machine and never spend.
+ Some of them need ffmpeg and ffprobe on PATH:
+
+ | node | JavaScript | Python |
+ resize | Pure JS for a PNG source; ffmpeg for any other format | ffmpeg |
+ trim | Pure JS for a PCM WAV source; ffmpeg for any other format | ffmpeg |
+ combine | Pure JS (lossless mp4 remux) when every clip is mp4 with matching stream parameters; ffmpeg otherwise | ffmpeg |
+ vframes | ffmpeg | ffmpeg |
+ soundtrack | ffmpeg | ffmpeg |
+ extractaudio | ffmpeg | ffmpeg |
+
+ ffmpeg is a soft dependency, not a package dependency. Install it with
+ apt install ffmpeg or brew install ffmpeg. If a node needs it
+ and it is absent, that node stops the run with a clear error that names it. The error
+ arrives when the node's turn comes, so paid nodes earlier in the graph have already
+ spent — run a media-only graph once to prove your setup before you wire a paid node in
+ front of it.
+ Only an unknown node type is refused up front: the libraries warn at load and
+ throw at run() before any network call. That is how a graph saved before
+ 2026-07-22 with the retired draw node behaves. The full node table is on the
+ format page.
No account: pay per run in Nano (x402)
Skip the API key entirely. With --pay (or a library
diff --git a/llms-full.txt b/llms-full.txt
index be9863d..79d3eae 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -23,7 +23,8 @@ self-contained `.html` file.
### Privacy architecture
-nanoodle is three static HTML pages. There is no backend, and that is the
+nanoodle is a folder of static HTML pages — three core pages plus a
+changelog, guides and landing pages. There is no backend, and that is the
design, not a limitation:
- **No analytics, no tracking, no third-party scripts.** Nothing phones home.
@@ -97,6 +98,16 @@ Replace the code with your own (or delete the parameter and use bare
[patchling](https://github.com/255BITS/patchling), share via `#a=`
link, or export a self-contained `.html`.
- **`legal.html`** — terms, privacy, FAQ (`/legal`).
+- **`changelog.html`** — release notes (`/changelog`), generated from
+ `updates.json` by `scripts/gen-changelog.mjs`, plus an Atom feed
+ (`feed.xml`).
+- **`guide/`** — static docs (`/guide/`): the `noodle-graph.json` format,
+ running workflows headlessly, self-hosting, and how share links work.
+ Zero scripts, zero network.
+- The rest is landing pages: comparisons (`nanoodle-vs-comfyui.html`,
+ `nanoodle-vs-n8n.html`, `comfyui-alternative.html`,
+ `comfyui-alternative-no-gpu.html`) and localized home pages (`es/`,
+ `fr/`, `de/`, `pt/`, `ja/`).
### Run workflows from code
@@ -116,6 +127,27 @@ const result = await wf.run({ Text: "a cozy ramen shop on a rainy night" });
await result.get("Image").save("ramen.png");
```
+### Ecosystem
+
+Everything lives under the [nanoodlecom](https://github.com/nanoodlecom) GitHub org:
+
+| Repo | What it is |
+| --- | --- |
+| [nanoodle](https://github.com/nanoodlecom/nanoodle) | The playground — editor, app builder, the whole site (this repo) |
+| [nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) | Zero-dependency JS executor — use when running saved graphs from Node or the CLI |
+| [nanoodle-py](https://github.com/nanoodlecom/nanoodle-py) | Zero-dependency Python executor — use when your scripts speak Python |
+| [nanoodle-mcp](https://github.com/nanoodlecom/nanoodle-mcp) | MCP server exposing saved graphs as tools — use when an AI agent should run your workflows |
+| [run-noodle-action](https://github.com/nanoodlecom/run-noodle-action) | GitHub Action — use when a graph should run in CI |
+| [nanoodle-skill](https://github.com/nanoodlecom/nanoodle-skill) | One agent skill that teaches an agent to build any graph — use when the agent should author workflows itself |
+| [noodle-skills](https://github.com/nanoodlecom/noodle-skills) | Prebuilt one-task agent skills — use when you want a ready-made skill, no graph editing |
+| [awesome-noodles](https://github.com/nanoodlecom/awesome-noodles) | Gallery of ready-to-open graphs — use when you want examples to start from |
+| [built-with-nanoodle](https://github.com/nanoodlecom/built-with-nanoodle) | Showcase of apps people shipped — use when you want to see (or show) what nanoodle makes |
+| [noodle-embed](https://github.com/nanoodlecom/noodle-embed) | `` web component — use when a page should render a workflow read-only |
+| [mp4cat](https://github.com/nanoodlecom/mp4cat) | Lossless mp4 concat, browser or Node — use when you just need to join clips, no ffmpeg |
+
+Naming note: the package is `nanoodle` on **both** registries while the repos are
+`nanoodle-js` / `nanoodle-py` — so it's `npm install nanoodle` and `pip install nanoodle`.
+
### Development
No build step. Edit the HTML files, refresh the browser.
@@ -126,6 +158,24 @@ bundler, the OAuth flow, run-engine compatibility, pricing, i18n coverage
and more. They spend no API credits — everything runs against recorded
fixtures.
+`scripts/check-js-parity.mjs` dual-runs the same graphs through play.html’s
+`RUNTIME_JS` and the sibling [`nanoodle-js`](https://github.com/nanoodlecom/nanoodle-js)
+package and asserts identical NanoGPT request bodies — the safety net for
+eventually replacing the inlined processor with the package. Skips if
+`nanoodle-js` isn’t checked out next to this repo (or set `NANOODLE_JS`).
+
+play.html also embeds a generated bundle of that package (the `njs-engine`
+script block, `scripts/gen-js-engine.mjs`, freshness-checked pre-commit)
+and routes network nodes through it by default — `?engine=play` (or
+`localStorage.njs_engine = "0"`) opts back into the built-in runners;
+`scripts/check-njs-delegation.mjs` asserts the delegated path produces
+byte-identical requests. Exported apps carry the bundle too, so the same
+flag works there. The editor honors the same flag: index.html lazy-loads
+the same bundle as `vendor/njs-engine.js` (emitted by the same generator),
+and known library gaps are vetoed back to the built-in runners per run;
+`scripts/check-njs-editor-delegation.mjs` pins that path to the built-in
+runners byte-for-byte.
+
`updates.json` is the in-app changelog behind the 📣 button. It's opt-in
per commit: add an `Update: one polished line` to a commit message and the
`post-commit` hook folds it in. Commits without one stay silent. Edit the
@@ -137,7 +187,7 @@ JSON by hand anytime; `scripts/check-updates.mjs` keeps it valid.
## Node types in the editor
-The editor registers 27 node types. Titles and one-line descriptions below are taken verbatim from the node registry in index.html (the same text the in-editor quick-add menu shows); the `key` is the type identifier used in noodle-graph.json and in the executor libraries' supported-node tables.
+The editor registers 26 node types. Titles and one-line descriptions below are taken verbatim from the node registry in index.html (the same text the in-editor quick-add menu shows); the `key` is the type identifier used in noodle-graph.json and in the executor libraries' supported-node tables.
### Inputs
@@ -155,7 +205,6 @@ The editor registers 27 node types. Titles and one-line descriptions below are t
### Image
- `image` — 🖼 Image: Text → image
-- `draw` — 🎨 Draw: Prompt (+ images) → image — Gemini omni: reasons, then draws or edits
- `edit` — ✂️ Edit: Image(s) + text → image
- `inpaint` — 🩹 Inpaint: Brush a region of an image → repaint just that area
- `resize` — 📐 Resize / crop: Image → image — scale, crop, fit an aspect
@@ -223,6 +272,8 @@ npx nanoodle --help # or run the CLI without installing
import { Workflow } from "nanoodle";
const wf = await Workflow.load("noodle-graph.json"); // key from NANOGPT_API_KEY
+// …or load any nanoodle share link — the URL is the package:
+// const wf = await Workflow.load("https://nanoodle.com/#g=…");
const result = await wf.run({ Text: "a cozy ramen shop on a rainy night" });
await result.get("Image").save("ramen.png"); // media: MediaRef (url + bytes()/save())
console.log(result.costUsd, result.remainingBalance);
@@ -232,39 +283,66 @@ With the app’s starter graph (text → LLM prompt-writer → image), that’s
### Quickstart (CLI)
-Inspect first — offline; shows inputs, outputs, and settings:
+No graph yet? Scaffold the starter (text → LLM prompt-writer → image) and
+inspect it — both offline, no key needed:
```bash
-npx nanoodle inspect graph.json
+npx nanoodle init # writes ./noodle-graph.json
+npx nanoodle inspect noodle-graph.json # shows inputs, outputs, settings
```
Then run (calls NanoGPT and spends from your balance):
```bash
export NANOGPT_API_KEY=... # or --key K, or --env-file .env
-npx nanoodle run graph.json --input Text="a cozy ramen shop" --out ./out
-npx nanoodle run graph.json --input n2.system=@style.txt --set n3.size=1k --json
+npx nanoodle run noodle-graph.json --input Text="a cozy ramen shop on a rainy night"
+npx nanoodle run graph.json --input n2.system=@style.txt --set n3.size=1k --out ./renders
```
-- `--env-file path` — load `NANOGPT_API_KEY` from a `.env`-style file (`--key` wins if both are set)
+Media outputs are saved under `--out` (default `./noodle-out`, created only
+when there is media to save); a JSON run summary always goes to stdout,
+progress lines to stderr; exit code `0` on success, `1` on failure.
+
- `--input k=@path` — read a file (media as media; `.txt` / `.md` / `.json` as text)
-- `--out dir` — save media outputs to disk
-- `--json` — machine-readable result
+- `--set k=v` — override a setting (`n3.model=flux-pro`)
+- `--out dir` — where media outputs land (default `./noodle-out`)
+- `--timeout ms` — overall run timeout
+- `--json` — quiet mode: skip the stderr progress lines (the JSON summary is printed either way)
+- `--env-file path` — load `NANOGPT_API_KEY` from a `.env`-style file (`--key` wins if both are set)
+
+#### The URL is the package
+
+Every nanoodle share link is a runnable artifact — paste one straight from a
+README, a chat, or a tweet, wherever a `graph.json` path is accepted:
+
+```bash
+npx nanoodle inspect "https://nanoodle.com/#g=..." # what does it need? (offline)
+npx nanoodle run "https://nanoodle.com/play.html#a=..." --input Text=hi # run it
+```
+
+Workflow links (`#g=`/`#j=`) and app links (`#a=`, graph only — the app shell
+stays in the browser) both work, as do da.gd/TinyURL short links (resolved by
+reading redirect headers; no credentials are ever sent). Direct links decode
+fully offline. Quote the URL — `#` starts a comment in most shells.
+
+Links mangled in transit (a character flipped or dropped by a chat app or a
+copy/paste) are recovered best-effort: the graph's nodes and wires are salvaged
+and a warning is printed. Only damage inside the graph itself makes a link
+unrecoverable.
### Supported nodes
| runs | node types |
|---|---|
| local | text, upload (image/audio/video), choice, join, comment |
-| NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-| **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
+| local media† | resize, vframes, combine, soundtrack, trim, extractaudio |
+| NanoGPT | llm (incl. vision + audio input), image, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-Workflows with unsupported node types load with a warning and fail fast at
-`run()` with `UnsupportedNodeError` — before any network call.
+† **local media** prefers a pure-JS path that matches the browser (lossless mp4 remux, PCM-WAV trim, PNG resize). **ffmpeg** on `PATH` is the fallback for everything else (soft dependency — not an npm package); clear error if it’s required and missing.
-\* **inpaint:** the browser app composites the mask onto black at the source
-pixel size; this library passes your mask through verbatim. Supply a
-black/white mask matching the source dimensions.
+\* **inpaint:** the mask is composited onto black at the source image’s pixel
+size before send (same as the browser app) — white = repaint, black = keep.
+Brush-style white-on-transparent masks and plain B/W masks both work.
## nanoodle-py — run workflows from Python
@@ -278,6 +356,7 @@ your own key.
Zero runtime dependencies (stdlib only). Library + CLI in one install.
Looking for JavaScript / Node? → **[nanoodle-js](https://github.com/nanoodlecom/nanoodle-js)**
+Running graphs in GitHub CI → [run-noodle-action](https://github.com/nanoodlecom/run-noodle-action) · saved graphs as AI-agent tools → [nanoodle-mcp](https://github.com/nanoodlecom/nanoodle-mcp) · Agent Skills → [nanoodle-skill](https://github.com/nanoodlecom/nanoodle-skill) / [noodle-skills](https://github.com/nanoodlecom/noodle-skills)
### At a glance
@@ -299,6 +378,9 @@ pip install nanoodle
export NANOGPT_API_KEY=... # nano-gpt.com API key (or OAuth access token)
```
+Requires **0.2.0+** for share-link loading, the local media nodes
+(resize/vframes/combine/soundtrack/trim/extractaudio), and x402 `--pay`.
+
### Quickstart (library)
```python
@@ -306,12 +388,34 @@ from nanoodle import Workflow
wf = Workflow.load("noodle-graph.json")
result = wf.run({"Text": "a cozy ramen shop on a rainy night"})
-result["Image"].save("ramen.png") # media: MediaRef (url + bytes()/save())
+img = result["Image"] # media: MediaRef (url + bytes()/save())
+img.save("ramen." + img.suggested_extension()) # extension matches the actual MIME (often jpg)
print(result.cost_usd, result.remaining_balance)
```
With the app’s starter graph (text → LLM prompt-writer → image), that’s the whole program.
+#### The URL is the package
+
+Every nanoodle share link is a runnable artifact. Anywhere a `graph.json` path
+is accepted — `Workflow.load` or the CLI — a share link works just as well:
+
+```python
+wf = Workflow.load("https://nanoodle.com/#g=...") # workflow link
+wf = Workflow.load("https://nanoodle.com/play.html#a=...") # app link (graph only)
+```
+
+Workflow links (`#g=`/`#j=`) and app links (`#a=`, graph only — the app shell
+stays in the browser) both decode, as do `da.gd`/TinyURL short links (resolved
+by reading redirect headers; no credentials are ever sent). Direct fragment
+links decode **fully offline** — zero network I/O, stdlib only. Paste one
+straight from a README, a chat, or a tweet.
+
+Links mangled in transit (a character flipped or dropped by a chat app or a
+copy/paste) are recovered best-effort: the graph's nodes and wires are salvaged
+and a warning is surfaced on `wf.warnings`. Only damage inside the graph itself
+makes a link unrecoverable.
+
#### Discover a workflow’s interface
```python
@@ -324,6 +428,35 @@ Input keys are flexible (case-insensitive): the node’s custom name, `nodeId.fi
(`"n2.system"`), or the input’s label when unique. A workflow with exactly one
required input also accepts a bare value: `wf.run("hello")`.
+An input node the author marked **optional** in the editor (the checkbox, saved as
+`fields.optional`) is skippable: `spec.optional` is `True`, and a run that omits it
+proceeds with an empty value that consumers drop — an optional style reference costs
+you nothing when you leave it out.
+
+##### Breaking in 0.5.0: named nodes now name their input key
+
+A node with a custom name that surfaces exactly one input uses that name as the input key,
+even when the input is optional. This matches nanoodle-js 0.8.0, so one set of keys now
+works in both languages. It **renames advertised keys** on published workflows. Run
+`nanoodle-py inspect ` to see the current keys, or use `nodeId.field`
+(`"n2.system"`), which never changes.
+
+| workflow | 0.4.0 key | 0.5.0 key | old key still resolves? |
+|---|---|---|---|
+| pr-describe | `System prompt` | `Drafter` | no — now ambiguous across 3 nodes |
+| pr-describe | `System prompt 2` | `Auditor` | no |
+| pr-describe | `System prompt 3` | `Final PR body` | no |
+| jingle | `System prompt` | `Lyric writer` | no — now ambiguous across 2 nodes |
+| jingle | `System prompt 2` | `Style writer` | no |
+| visual-judge | `System prompt` | `Verdict` | yes — the label is unique |
+| narrated-poem | `System prompt` | `Poet` | yes |
+| video-teaser | `System prompt` | `Shot writer` | yes |
+
+Where the old label still names exactly one input it keeps resolving, so those calls need
+no change. Where two or more nodes shared it, the old key now raises `ambiguous` (or
+`unknown input` for the numbered forms) instead of silently picking one — the call fails
+loudly and costs nothing.
+
#### Media inputs
```python
@@ -349,12 +482,54 @@ result = wf.run(
)
```
+`timeout=` bounds the whole run. When it fires, `run()` returns straight away
+and the in-flight nodes stop polling within about a second, so the process is
+free to exit. Without `timeout=`, each node still waits out its own limit
+(video 600 s, audio 300 s).
+
+The deadline bounds the run, and nothing else. Media a node already produced
+stays fetchable after it: `result["Image"].save("out.png")` works once the
+deadline has passed, and works for a lane that finished while another lane
+timed out.
+
`run()` raises `RunError` when an output (sink) node fails — `error.result`
still has partial results, per-node statuses, and cost so far. Failures in
lanes no output depends on only appear in `result.errors`. Unknown/unsupported
node types, missing required inputs, bad keys, and a missing API key all fail
**before** anything is spent.
+#### Prompt length caps
+
+Many image and video models reject a prompt over a fixed character count (HTTP 400,
+`prompt_too_long`) before anything is charged. In a graph that prompt is usually written
+by an upstream LLM, so there is nothing you can shorten. nanoodle trims the prompt to the
+model's cap at a sentence boundary and tells you it did:
+
+```python
+result = wf.run({"Text": "..."}, on_progress=print)
+# {'node_id': 'n3', 'name': 'Image', 'from': 1440, 'to': 780, 'cap': 800, 'type': 'prompt-trimmed'}
+result.prompt_trims # the same records, for a caller that passed no on_progress
+```
+
+Every trim is also reported as a `RuntimeWarning`. Reporting can never fail your run: if
+you run with warnings as errors (`PYTHONWARNINGS=error`), the same sentence goes to stderr
+instead, and the run continues.
+
+A cap counts **UTF-16 code units**, which is what the model route counts and what
+nanoodle-js reports — not Python code points. One emoji is 1 code point and 2 code units,
+so `"🎉" * 450` is 450 to `len()` and 900 to the API. `from` and `to` in the trim record are
+code units for the same reason, and `nanoodle.prompt_caps.utf16_len` measures them.
+
+A cap this library does not know yet is learned from the live 400 and applied on the next
+run of the same `Workflow`. That applies to image, video and audio nodes. An `llm` or
+`vision` node is never fitted (its real limit is tokens), so its rejection is relayed
+exactly as the API worded it, with no promise that a retry would behave differently.
+
+Your own prompts are never rewritten, summarised or added to — the library only ever cuts
+an over-length prompt at the end, and always says so. `PROMPT_CAPS`, `prompt_cap`,
+`fit_prompt_text`, `is_prompt_too_long` and `prompt_cap_from_error` are public, for
+callers that do their own orchestration.
+
### CLI
Installed as `nanoodle-py` (and `python -m nanoodle` always works):
@@ -364,22 +539,39 @@ nanoodle-py inspect graph.json
nanoodle-py run graph.json --input Text="a cozy ramen shop" --set n3.size=1k --out ./out
nanoodle-py run graph.json --input n2.system=@style.txt --json
nanoodle-py run graph.json --env-file .env --input Text="hello" # NANOGPT_API_KEY from a .env file
+nanoodle-py inspect "https://nanoodle.com/#g=..." # a share link works too (quote it — # is a shell comment)
```
- `--out DIR` — save media outputs to files
- `--json` — machine-readable result
- `--env-file PATH` — load `.env`-style `KEY=VALUE` lines (existing env vars win)
+With `--json`, a **failed** run still prints the same JSON on stdout — per-node
+`status` and `error`, the outputs that did complete, the cost already spent, any
+prompt trims (`promptTrims`) and any Nano deposit the run asked for (`payments`,
+empty unless you ran `--pay`) — and exits 1. Without `--json` a failed run prints
+`error: …` on stderr and exits 1, as before.
+
+That includes a failure caught **before** the first node runs (a missing required input,
+an unknown key, an unreadable graph). Nothing executed, so `nodes` is `{}` and `costUsd`
+is `0.0`, and the reason is in `errors[0].message`:
+
+```json
+{"outputs": {"Answer": null}, "costUsd": 0.0, "costExact": true, "remainingBalance": null,
+ "nodes": {}, "errors": [{"node_id": null, "name": null,
+ "message": "missing required input: Answer"}],
+ "promptTrims": [], "payments": []}
+```
+
### Supported nodes
| runs | node types |
|---|---|
| local | text, upload (image/audio/video), choice, join, comment |
-| NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-| **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
+| local media† | resize, vframes, combine, soundtrack, trim, extractaudio |
+| NanoGPT | llm (incl. vision + audio input), image, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-Workflows with unsupported node types load with a warning and fail fast at
-`run()` with `UnsupportedNodeError` — before any network call.
+† **local media** needs **ffmpeg** on `PATH` (soft dependency — not a PyPI package). Same behaviour as the browser app; clear error if ffmpeg is missing.
\* **inpaint:** the browser app composites the mask onto black at the source
pixel size; this library passes your mask through verbatim. Supply a
diff --git a/scripts/check-llms-full.mjs b/scripts/check-llms-full.mjs
new file mode 100644
index 0000000..6187e25
--- /dev/null
+++ b/scripts/check-llms-full.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+// Guard: llms-full.txt must not drift away from the sources it is generated from.
+//
+// Why this exists: llms-full.txt is the single-file reference agents fetch. It
+// carries a "do not edit by hand" header, so nobody edits it — which means the
+// only thing that keeps it true is somebody remembering to re-run
+// scripts/gen-llms-full.mjs. Nobody did, and the file kept advertising the
+// retired `draw` node after index.html and both library READMEs had dropped it.
+// An agent that reads a stale entry writes a graph that cannot run.
+//
+// This guard is OFFLINE and deterministic. It checks two things:
+//
+// 1. Prefix match. Every section built from a file in THIS repo (llms.txt,
+// README.md, index.html) is regenerated and compared byte for byte. Those
+// sections are always the leading parts of the output, so the comparison is
+// a plain startsWith.
+// 2. Node keys. Every node type named in the two libraries' supported-node
+// tables must still be registered in index.html's NODE_TYPES. That reaches
+// into the sections this script cannot regenerate offline, and it is the
+// exact check the `draw` rows would have failed.
+//
+// The library sections' full text needs the sibling repos, so the byte-exact
+// check for those runs in CI (.github/workflows/checks.yml) via
+// `gen-llms-full.mjs --check` with NANOODLE_JS / NANOODLE_PY pointed at fresh
+// checkouts.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { localParts, joinParts, firstDiff, OUT_PATH } from "./gen-llms-full.mjs";
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const fails = [];
+
+const actual = fs.readFileSync(OUT_PATH, "utf8");
+
+// --- 1. the locally-sourced prefix -----------------------------------------
+
+const expected = joinParts(localParts()).replace(/\n$/, "");
+if (!actual.startsWith(expected + "\n\n")) {
+ fails.push(
+ "llms-full.txt no longer matches README.md / llms.txt / index.html.\n" +
+ " Regenerate it: node scripts/gen-llms-full.mjs\n" +
+ firstDiff(actual, expected + "\n"));
+}
+
+// --- 2. node keys in the libraries' supported-node tables -------------------
+
+function registeredTypes() {
+ const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
+ const start = html.indexOf("const NODE_TYPES = {");
+ if (start < 0) throw new Error("NODE_TYPES not found in index.html");
+ const block = html.slice(start, html.indexOf("\n};", start));
+ const keys = [...block.matchAll(/^ {2}([a-z]+): \{/gm)].map((m) => m[1]);
+ if (!keys.length) throw new Error("parsed 0 NODE_TYPES keys from index.html");
+ return new Set(keys);
+}
+
+// "upload (image/audio/video)" -> "upload"; "inpaint*" -> "inpaint"
+const bareKey = (cell) => cell.replace(/\([^)]*\)/g, "").replace(/[*†\\`]/g, "").trim();
+
+const known = registeredTypes();
+// aliases the tables use that are not literal NODE_TYPES keys
+const ALIASES = new Set(["upload"]); // stands for upload / aupload / vupload
+
+const rows = [...actual.matchAll(/^\| *(local|local media†|NanoGPT) *\| *(.+?) *\|$/gm)];
+// 3 rows per library table, 2 libraries
+if (rows.length !== 6) {
+ fails.push(
+ `expected 6 supported-node rows in llms-full.txt (3 per library), found ${rows.length}.\n` +
+ " The libraries' README table shape changed, so this guard went blind.\n" +
+ " Update the row regex in scripts/check-llms-full.mjs.");
+} else {
+ for (const [, runs, cell] of rows) {
+ for (const raw of cell.split(",")) {
+ const key = bareKey(raw);
+ if (!key || ALIASES.has(key)) continue;
+ if (!known.has(key)) {
+ fails.push(
+ `llms-full.txt lists node type "${key}" (row "${runs}"), but index.html no longer registers it.\n` +
+ " Either the type was retired and the library README still advertises it,\n" +
+ " or llms-full.txt is stale — run: node scripts/gen-llms-full.mjs");
+ }
+ }
+ }
+}
+
+if (fails.length) {
+ console.error("✗ check-llms-full");
+ for (const f of fails) console.error(" - " + f.split("\n").join("\n "));
+ process.exit(1);
+}
+console.log(`✓ check-llms-full — locally-sourced sections match; ${rows.length} supported-node rows name only registered types`);
diff --git a/scripts/gen-llms-full.mjs b/scripts/gen-llms-full.mjs
index ddbfa59..52a4e65 100644
--- a/scripts/gen-llms-full.mjs
+++ b/scripts/gen-llms-full.mjs
@@ -13,32 +13,78 @@
// - curated sections of the nanoodle-js and nanoodle-py READMEs
// (intro, At a glance, Install, quickstarts, Supported nodes)
//
-// The two library repos are shallow-cloned into .tmp-libs/ (gitignored) on
-// first run and reused after that; delete .tmp-libs/ to force a re-fetch.
+// The two library repos are shallow-cloned into .tmp-libs/ (gitignored). Every
+// run re-fetches that clone and hard-resets it to the remote default branch: a
+// reused cache is how a regeneration silently reproduces last month's library
+// README. Set NANOODLE_JS / NANOODLE_PY to a local checkout to read that
+// instead of cloning (CI does this, so its check needs no network).
//
-// Run: node scripts/gen-llms-full.mjs (writes llms-full.txt at repo root)
+// Run: node scripts/gen-llms-full.mjs writes llms-full.txt at the repo root
+// node scripts/gen-llms-full.mjs --check regenerates and diffs, writes nothing
+// node scripts/gen-llms-full.mjs --offline reuses the cache as-is, no fetch
+//
+// scripts/check-llms-full.mjs is the offline guard the pre-commit hook runs: it
+// verifies the sections that come from files in THIS repo. The library sections
+// need the sibling repos, so CI covers those.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const TMP = path.join(root, ".tmp-libs");
+export const OUT_PATH = path.join(root, "llms-full.txt");
-const LIBS = [
- { repo: "nanoodle-js", lang: "JavaScript / Node.js", install: "npm install nanoodle",
+export const LIBS = [
+ { repo: "nanoodle-js", lang: "JavaScript / Node.js", install: "npm install nanoodle", env: "NANOODLE_JS",
sections: ["At a glance", "Install", "Quickstart (library)", "Quickstart (CLI)", "Supported nodes"] },
- { repo: "nanoodle-py", lang: "Python", install: "pip install nanoodle",
+ { repo: "nanoodle-py", lang: "Python", install: "pip install nanoodle", env: "NANOODLE_PY",
sections: ["At a glance", "Install", "Quickstart (library)", "CLI", "Supported nodes"] },
];
-function clone(repo) {
+// Where to read a library README from. A local checkout wins (CI points these at
+// freshly checked-out siblings); otherwise the .tmp-libs clone, always refreshed.
+export function libSource(lib, { offline = false } = {}) {
+ const local = process.env[lib.env];
+ if (local) {
+ if (!fs.existsSync(path.join(local, "README.md"))) {
+ throw new Error(`${lib.env}=${local} has no README.md`);
+ }
+ return local;
+ }
+ return clone(lib.repo, { offline });
+}
+
+const git = (args) => execFileSync("git", args, { stdio: ["ignore", "ignore", "inherit"] });
+
+function clone(repo, { offline = false } = {}) {
const dest = path.join(TMP, repo);
- if (fs.existsSync(path.join(dest, "README.md"))) return dest;
+ const url = `https://github.com/nanoodlecom/${repo}.git`;
+ if (fs.existsSync(path.join(dest, ".git"))) {
+ if (offline) {
+ console.warn(`⚠ --offline: reusing ${path.relative(root, dest)} without a fetch — it may be stale`);
+ return dest;
+ }
+ // Refresh in place. Never trust the cache: a stale README here is invisible
+ // in the output, and that is exactly how llms-full.txt went stale before.
+ try {
+ git(["-C", dest, "fetch", "--depth", "1", "origin", "HEAD"]);
+ git(["-C", dest, "reset", "--hard", "FETCH_HEAD"]);
+ git(["-C", dest, "clean", "-fdx"]);
+ return dest;
+ } catch (e) {
+ throw new Error(
+ `could not refresh the cached clone at ${path.relative(root, dest)} (${e.message.trim()}). ` +
+ `Delete .tmp-libs/ and retry, set ${repo === "nanoodle-js" ? "NANOODLE_JS" : "NANOODLE_PY"} ` +
+ `to a local checkout, or pass --offline to accept the cache as-is.`);
+ }
+ }
+ if (offline) {
+ throw new Error(`--offline: no clone at ${path.relative(root, dest)} and nothing to reuse`);
+ }
fs.mkdirSync(TMP, { recursive: true });
- execFileSync("git", ["clone", "--depth", "1", `https://github.com/nanoodlecom/${repo}.git`, dest],
- { stdio: ["ignore", "ignore", "inherit"] });
+ git(["clone", "--depth", "1", url, dest]);
return dest;
}
@@ -120,41 +166,90 @@ function nodeCatalog() {
// --- assemble ---
-const mainReadme = fs.readFileSync(path.join(root, "README.md"), "utf8");
-const llmsTxt = fs.readFileSync(path.join(root, "llms.txt"), "utf8");
-const blockquote = llmsTxt.split("\n").find((l) => l.startsWith("> "));
-if (!blockquote) throw new Error("no blockquote line in llms.txt");
-
-const parts = [];
-parts.push("# nanoodle — full reference");
-parts.push(blockquote);
-parts.push(
- "This file is generated by `scripts/gen-llms-full.mjs` in " +
- "[nanoodlecom/nanoodle](https://github.com/nanoodlecom/nanoodle) — do not edit by hand. " +
- "Every section is copied or extracted verbatim from the repository's README.md and " +
- "index.html, and from the public READMEs of " +
- "[nanoodlecom/nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) and " +
- "[nanoodlecom/nanoodle-py](https://github.com/nanoodlecom/nanoodle-py). " +
- "The short index lives at [llms.txt](https://nanoodle.com/llms.txt)."
-);
-
-parts.push("## nanoodle (the site)");
-parts.push(embed(mainReadme.replace(/^# nanoodle\s*\n/, ""), "https://github.com/nanoodlecom/nanoodle"));
-
-parts.push("## Node types in the editor");
-parts.push(nodeCatalog());
-
-for (const lib of LIBS) {
- const dir = clone(lib.repo);
+/**
+ * The sections built only from files in THIS repo (llms.txt, README.md,
+ * index.html). They are always the leading parts of the output, which is what
+ * lets the offline guard compare them without cloning anything.
+ */
+export function localParts() {
+ const mainReadme = fs.readFileSync(path.join(root, "README.md"), "utf8");
+ const llmsTxt = fs.readFileSync(path.join(root, "llms.txt"), "utf8");
+ const blockquote = llmsTxt.split("\n").find((l) => l.startsWith("> "));
+ if (!blockquote) throw new Error("no blockquote line in llms.txt");
+
+ const parts = [];
+ parts.push("# nanoodle — full reference");
+ parts.push(blockquote);
+ parts.push(
+ "This file is generated by `scripts/gen-llms-full.mjs` in " +
+ "[nanoodlecom/nanoodle](https://github.com/nanoodlecom/nanoodle) — do not edit by hand. " +
+ "Every section is copied or extracted verbatim from the repository's README.md and " +
+ "index.html, and from the public READMEs of " +
+ "[nanoodlecom/nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) and " +
+ "[nanoodlecom/nanoodle-py](https://github.com/nanoodlecom/nanoodle-py). " +
+ "The short index lives at [llms.txt](https://nanoodle.com/llms.txt)."
+ );
+
+ parts.push("## nanoodle (the site)");
+ parts.push(embed(mainReadme.replace(/^# nanoodle\s*\n/, ""), "https://github.com/nanoodlecom/nanoodle"));
+
+ parts.push("## Node types in the editor");
+ parts.push(nodeCatalog());
+ return parts;
+}
+
+/** The sections copied out of one library README. Needs that repo on disk. */
+export function libParts(lib, dir) {
const md = fs.readFileSync(path.join(dir, "README.md"), "utf8");
const repoUrl = `https://github.com/nanoodlecom/${lib.repo}`;
+ const parts = [];
parts.push(`## ${lib.repo} — run workflows from ${lib.lang}`);
parts.push(`Repository: ${repoUrl} · package: \`nanoodle\` (\`${lib.install}\`). ` +
`Curated from the repository README; see it for the full docs (specs, agent skills, error model, cost fields).`);
parts.push(embed(intro(md), repoUrl));
for (const s of lib.sections) parts.push(embed(section(md, s), repoUrl));
+ return parts;
}
-const out = parts.join("\n\n") + "\n";
-fs.writeFileSync(path.join(root, "llms-full.txt"), out);
-console.log(`wrote llms-full.txt (${out.length} bytes, ${out.split("\n").length} lines)`);
+export const joinParts = (parts) => parts.join("\n\n") + "\n";
+
+export function build({ offline = false } = {}) {
+ const parts = localParts();
+ for (const lib of LIBS) parts.push(...libParts(lib, libSource(lib, { offline })));
+ return joinParts(parts);
+}
+
+function main(argv) {
+ const offline = argv.includes("--offline");
+ const out = build({ offline });
+ if (argv.includes("--check")) {
+ const have = fs.existsSync(OUT_PATH) ? fs.readFileSync(OUT_PATH, "utf8") : "";
+ if (have === out) {
+ console.log("✓ llms-full.txt matches the current sources");
+ return 0;
+ }
+ console.error("✗ llms-full.txt is stale — run: node scripts/gen-llms-full.mjs");
+ console.error(firstDiff(have, out));
+ return 1;
+ }
+ fs.writeFileSync(OUT_PATH, out);
+ console.log(`wrote llms-full.txt (${out.length} bytes, ${out.split("\n").length} lines)`);
+ return 0;
+}
+
+/** Name the first line that differs, so the failure says WHAT drifted. */
+export function firstDiff(have, want) {
+ const a = have.split("\n"), b = want.split("\n");
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ if (a[i] !== b[i]) {
+ return ` first difference at line ${i + 1}\n` +
+ ` on disk: ${a[i] === undefined ? "" : JSON.stringify(a[i].slice(0, 160))}\n` +
+ ` generated: ${b[i] === undefined ? "" : JSON.stringify(b[i].slice(0, 160))}`;
+ }
+ }
+ return " files are identical";
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ process.exit(main(process.argv.slice(2)));
+}