Skip to content

v2.0.7

Choose a tag to compare

@github-actions github-actions released this 21 Sep 20:50
· 1376 commits to main since this release

The hosted-host release. A Claude cloud sandbox drove 2.0.6 end to end on 2026-09-21 (a 47-part phone
at true scale, exported as glTF) and reported the two things that blocked a clean first run: the
embedding runtime's install step needs a host the sandbox's proxy refuses, and init waits on a
keyboard that is not there. Both are gone. Nothing changes for a desktop install except that it is a
third smaller.

Init on a host with no keyboard

  • npx mojulo init takes its defaults when stdin is not a terminal. CI, a pipe, an agent driving
    the install or </dev/null used to hang at the first prompt and then die with exit 13 and a Node
    "unsettled top-level await" warning when stdin closed. Now a non-TTY stdin is announced once and
    behaves like --yes (--no-ui and --print compose as before); a terminal that closes mid-prompt
    exits with one plain line instead of the internals warning.

Recall

  • The embedding model is an opt-in install group. @huggingface/transformers (and with it
    onnxruntime-node, onnxruntime-web, the ~130 MB lazy model) leaves the package's dependencies.
    mojulo install recall installs the runtime into ~/.mojulo/recall/ (its own package.json and an
    entry.mjs shim; it survives package upgrades the way ~/.mojulo/models/ does) and fetches the model;
    --remove takes it away. mojulo install chatbot installs recall first, because the bot-builder preview
    RAG must behave like the deployed bot. MOJULO_PACKS=recall is the manual override, as for the other groups.
  • semantic_search works without it. The index is now a lexical search (SQLite FTS5, trigram
    tokenizer, already inside better-sqlite3) over the same rows whenever the recall group is absent;
    with the group present it is the vector search it was. One path or the other, never a blend. Results
    carry mode: 'vector' | 'lexical'; a lexical score is the share of query terms the row contains, so
    the weak-match hint keeps its threshold. The index self-populates on the first search of a fresh
    install (text only, no model), so a cold CLI process answers on its first call. Query in English; the
    host translates the operator's ask before the first tool call. Measured: the routing fixture lands its
    entry tool in the top 3 for 32 of 34 phrasings through the lexical path (pinned at 85 % in
    embeddings-lexical.test.js; the vector gate in routing-eval.integration.test.js now needs the group).
  • Text-only rows. meta_embeddings.embedding is nullable (existing DBs are rebuilt in place, rows
    preserved). A row whose vector could not be produced is still indexed by text; installing recall later
    backfills the vectors on the next boot.
  • sharp is an optionalDependency in its own right (it used to arrive through the embedder) and
    stays part of the creative group; sharp-lazy.js already tolerates its absence.
  • Boot and status. The bins no longer preload a model that is not installed; the settings
    embeddings-status route reports the group state and the install line; smoke:tarball runs the cold
    install without the group and asserts a lexical semantic_search result, and --with-recall installs
    the group into the temp dir first. Measured by that gate on macOS arm64: tarball 30 MB, unpacked 116 MB,
    node_modules 472 MB (2.0.6: ~775 MB), about 590 MB total against 885 MB before.
  • adm-zip is a devDependency. The docker deployer test imported it while it arrived through
    onnxruntime-node; it is declared now that the runtime is gone.

Release tooling

  • npm run smoke:tarball parses npm pack --json past prepack's own stdout. Under the current npm
    the Next build banner lands in front of the JSON and the smoke died at the first step with
    "Unexpected token '▲'"; it now parses from the last array-opening line. The gate then passed with
    both WASM kernels in the tree, and a scad row and an exact cut minted, exported and rendered
    (/world, /scene, STL, GLB, PNG) through the fresh install's CLI and dashboard.

Routing

  • Two routing cards learn the words the fixture uses. The world card's When line gains "a little
    town I can wander around" and the motion-waypoints card gains "can the player get from the door to
    the goal": the two phrasings the lexical path missed on the routing fixture were both absent from
    the card vocabulary rather than mis-ranked.
  • The illustration pack names the graphic novel. Its recognizer gains the anchor 'a graphic novel I
    click through page by page', so "present the graphic novel like a slideshow I click through" routes
    to the sketch packs again instead of sitting a few thousandths behind the game and motion packs (it
    had missed on the committed descriptions since before the OpenSCAD work; the eval is max-over-anchors,
    so a pack without a matching quote loses to any pack with a near one).

OpenSCAD front door

  • exact: true on a fields entry or a cuts entry composes it with Manifold (the boolean kernel
    under OpenSCAD 2025, already an optional creative dependency) instead of the surface-net grid: a bore
    has a sharp lip and a 12-unit disc measures 12 in /world, the .glb, the engine packs and the print,
    with no OpenSCAD in the loop. Reach: the nine field shapes, add / subtract / intersect without
    blend, transform, repeat; anything else (blend, stroke, displace, shell, round, expr, harmonic
    lathe, warps) is refused at mint by name. segments (8–256, default 48) facets curved primitives.
    Opt-in: every existing row renders byte for byte. Absent the package, the mint refuses with the install
    line. The kernel loads at the async seams (mint, world resolve, the scene route) before the synchronous
    lowering: the mint, the edit (update_sketch), the world resolve, the scene route, the SVG control
    scaffold, capture_reference and the scad mint, each only when the recipe wants exact. The
    export_model ledger reads an exact cut as exact with edge_rounding: 0 (no grid, no rounding).
    Routing copy no longer says a sharp edge needs OpenSCAD.

  • Fixed: export_model union: true never found Manifold under the dashboard server. manifold-3d
    is ESM-only, and Next's server bundle turned the literal import() into a require() Node refuses
    (ERR_PACKAGE_PATH_NOT_EXPORTED), so the union reported the package missing under next dev and the
    standalone server while the CLI and tests had it. The loader now falls back to the runtime's own
    import when the bundler's require is refused (the same fix the scad kind's loader carries).

  • update_sketch on a scad row answers with a readout. The edit pays planScad's gates as at
    mint (the fence, the parts contract, the embedded fields' audit, OpenSCAD's own errors), re-stamps
    the ledger, and hands back the readout: changed (the default for a patch) lists the parts by
    NAME that are new, moved, or named by a /parts/<name> op, plus new warnings and OpenSCAD log lines;
    summary and full as on the workbench. Before this an edited scad row re-resolved but returned no
    stats.

  • OpenSCAD's default colours never reach the World. Its OFF writer paints uncoloured geometry in
    the preview scheme's yellow and the faces an uncoloured cutter leaves in its green; both now land as
    the card promised — the neutral grey, and in a one-colour part the cut faces wear that colour (a
    coloured body carved by a bare mojulo_field() stays the body's colour). The card also says how
    two overlapping parts weld for the print: export_model { union: true }, which already does.

  • The docs say what the doors do now. The README's examples include a part written in OpenSCAD,
    the tour and the formats tables list .scad beside the print formats, and the substrate drawer,
    tech-requirements and the OpenSCAD worker doc no longer say a sampled field cannot give a sharp edge
    or that the round trip ends at OpenSCAD. The workbench card shows the shaped-cutter idiom (a nested
    sub-solid, subtracted) a D-bore needs.

  • mint_solid kind:'scad': an OpenSCAD program IS the recipe. spec.source is stored verbatim and
    meshed on every read by OpenSCAD itself, running in-process as WebAssembly (openscad-wasm-prebuilt,
    a new optional creative dep, same posture as manifold-3d; OpenSCAD 2025.01.19, Manifold backend,
    version pinned in the ledger). The mesh is the ordinary face list, so the object rides the workbench
    studio (/world, /scene, facing, grid, movers), every export leg (.glb, engines, USD, STL,
    3MF at true size — units defaults to mm) and the gates unchanged. color() is the tint; parts
    ({ name: 'module();' }) render each part as its own group so a hinge can swing it; include /
    use / import() / surface() are refused at mint (the recipe carries its own geometry). Absent the
    package, the mint refuses with the install line. export_model format:'scad' on a scad row returns
    the source verbatim (one exact term, nothing frozen). Card: get_solid_vocab({ id: 'scad' }); routing
    card scad-object. Existing workbench rows are untouched. Why: measured on the iPhone Duo block-in,
    the same object in OpenSCAD reads at about half the tokens of the workbench manifest, needs no
    14k-token card, renders in 0.2 s, and its booleans are exact.

  • mojulo_field("<id>") inside a scad source. A scad spec may carry fields (the workbench's
    field entries, each with an id); the program reaches one as mojulo_field("<id>"), a module mojulo
    prepends holding that field baked as a polyhedron(). Blends, strokes, noise, expressions and warps
    stay mojulo's; the exact CSG around them is OpenSCAD's. A field whose surface net is not a
    2-manifold (a heavy displace) is refused at mint with the edge counts, because OpenSCAD's kernel
    would drop it from a boolean silently; and any OpenSCAD ERROR line fails a scad render even when a
    file was written.

  • bind_mesh_render takes a .stl or .3mf at glb_path. The file is read into the standard
    face list (3MF basematerials colour and build transforms kept; an STL binds grey) and written as
    the GLB the bind stores, so an OpenSCAD or slicer-side part comes home as a bound mesh and places in
    any world via meshRef. The result carries converted_from. A GLB path behaves exactly as before.

  • The scad still is legible. The CSS-3D scene and gallery PNG of a scad row fold coplanar
    triangles into one clipped panel per flat region (holes via an evenodd clip); the World and every
    export keep OpenSCAD's triangles.

  • Fixed: export_model format:'scad' dropped every stadium. A rounded rect whose side equals 2r
    (a pill button, a USB-C port, a camera plateau) transpiled to offset(r) square([w, 0]), a zero-area
    polygon OpenSCAD discards silently while the coverage ledger still counted the term exact; the Duo
    lost seven of twenty-three parts. Such a profile now emits as a hull() of circles (a disc as one
    circle). Non-degenerate profiles emit byte-for-byte as before. The OpenSCAD gate on the Duo agrees
    on every axis again.

  • Workbench mint stores movers, grid and toon from the spec. The card documented all three as
    top-level spec keys but mint_solid kind:'workbench' / 'code' dropped them; only an update_sketch
    patch could store a hinge. Absent, byte-identical.

Mover ease: the magnetic latch

  • movers[].ease on a states toggle (turn or slide). Absent → the smoothstep every existing recipe rides,
    byte-identical. 'snap' (or { snap, gap }) eases the part out to a hover just short of the detent, then
    accelerates the last stretch in — a lid clicking shut, a foldable closing on its magnets. An array is read
    by the state arrived at (['snap', null]: click into state 0, plain into state 1). Documented on the
    workbench card. The mover channel block's emitted bytes change, so the emit-channels.char hashes for the
    mover fixtures are re-pinned in the same change.

Library performance

  • Changed: GET /api/sketches ships summaries, not manifests. The Library's Scenes shelf was a
    62 MB JSON body on a workshop of ~3,000 sketches, because every controllable row stores its baked
    faces inline (up to 3.7 MB each) and the list route sent all of it for the browser to parse and hold.
    A list row now carries ref, title, kind, renderMode, bucket, bucketOverride, createdAt, folderRef, the
    badge facts the shelves actually read (facts: { seed, giBake, giAdapter, game, audio }),
    hasBoundRender and associations. The recipe itself rides along only where the client renders from it:
    rows whose render mode is diagram (CreationMap draws the manifest) and the voice shelf (the register
    card reads the recipe). Everything else fetches /api/sketches/<ref> when it needs the full recipe: the
    board room's outliner and "save as new" now do. SketchRepository.list() keeps returning full sketches
    for the arcade, the MCP tools and scripts; the projection is SketchRepository.listSummary(). The home
    floor folds light rows and hydrates only the faces it draws. Client helpers in
    lib/graph/sketch/sketch-summary.js (renderModeOf, kindOf, factsOf) read both shapes, so the
    detail page, which still has the manifest, is untouched. Measured on that workshop, route handler
    called directly: the Scenes shelf went from 1.3 s / 62.45 MB to 0.17 s / 0.11 MB, Models from
    0.93 s / 25.3 MB to 0.16 s / 0.4 MB, the recent list from 0.27 s / 1.46 MB to 0.04 s / 0.07 MB.
  • Changed: the sketch bucket and kind are persisted columns. sketches.kind and
    sketches.bucket_derived (distinct from the bucket override column; effective bucket is
    COALESCE(bucket, bucket_derived)) are backfilled once on upgrade and kept in sync on every create and
    update. list({ bucket }), bucketCounts() and newestByBucket() now filter and tally in SQL instead
    of parsing every manifest in JS to classify it, which was a ~0.6 s floor under every shelf, the counts
    chips, /api/home and /api/home/floor. The derivation is versioned: a signature of the kind lists
    classifyBucket reads is stored in app_settings, and a mismatch (a kind added to a list, or a manual
    revision bump) re-backfills the columns. rowToSketch still derives bucket from the parsed manifest,
    so the value a caller sees is unchanged; a characterization test asserts the SQL path equals the old JS
    filter. Measured: the counts chips 0.6 s → 0.03 s, the home floor 0.7 s → 0.04 s, bucketCounts()
    575 ms → 28 ms; the one-time backfill of ~3,000 rows took 1.2 s on first use.
  • Fixed: /api/sketches/[ref]/world served stale HTML from the browser cache after an upgrade. Its
    ETag was a hash of (ref, flags, manifest) with no code-version salt, so an emitter change on upgrade
    still matched the browser's If-None-Match and got a 304. The key now folds in WORLD_CACHE_VERSION
    and the package version (getServerVersion, moved to lib/server-version.js and re-exported from the
    MCP server module). Browser-held world pages revalidate once after this ships. ?nocache=1 stays
    no-store.

Grok headless affordances

The remote-agent host class: a chat agent driving npx mojulo from an ephemeral Linux sandbox with
no MCP binding, no browser, and files as the only handoff to the human. A Grok session did exactly
that against 2.0.6 on 2026-09-21 (x64, Node 24.15, npm install mojulo --omit=optional); the
kernel held (its city GLB re-mints byte-identical on macOS, texture PNG aside) and five things
around it broke or misled. Each is fixed below.

  • Fixed: every CLI command, version included, crashed at tool registration when sharp could
    not load
    (Could not load the "sharp" module: an --omit=optional install leaves out
    @img/sharp-<platform>, and a platform with no prebuilt libvips has the same shape). Thirteen
    modules on the registration path imported sharp statically — the world registry, the sketch
    rasterizer, the polygonizer and sprite-sheet tools, six image-outcome modules, the two motion
    encoders — and @huggingface/transformers (which imports sharp itself) rode in through the
    embedder. They now load sharp on first use through one helper (lib/sharp-lazy.js), and the
    embedder loads transformers on first use. A missing sharp is an in-band error on the raster
    call that needed it, naming the fix (npm install sharp in the package directory); the
    kernel, the CLI, and every non-raster tool are up without it. Pinned by a subprocess test that
    spawns the bin with a resolver hook that refuses sharp and asserts call version exits 0.
    Three static importers stay (final-page.js, cover-compositor.js, motion-comic-resolve.js):
    they are not on the registration path and load only when their tool runs.
  • Fixed: mojulo-ui bound to the machine hostname on Linux. The bin set HOSTNAME only when it
    was absent; Linux containers and many shells export HOSTNAME=<machine>, so the standalone
    server printed Local: http://<container>:3001 and 127.0.0.1 refused. It now binds 127.0.0.1
    unconditionally; MOJULO_UI_HOST is the explicit override (loopback-only remains the default).
  • Fixed: the bins wrote exports into the package directory. resolveMojuloPaths seeded
    SQLITE_PATH / ARTIFACTS_DIR / STORAGE_ROOT under ~/.mojulo/data but not
    MOJULO_OUTCOMES_DIR or MOJULO_EXPORTS_DIR, and both fall back to cwd/data/…. The stdio bin
    chdirs to the package root and the dashboard to .next/standalone, so export_model landed in
    node_modules/mojulo/data/outcomes/<ref>/ and the dashboard's /outcomes route read a different
    folder. The resolver now seeds both ($MOJULO_DATA_DIR/outcomes, $MOJULO_DATA_DIR/exports);
    repo-dev next dev keeps its cwd fallback. Exports written by earlier versions stay where they
    were
    (under the installed package's data/outcomes/ and data/exports/); nothing is moved.
    export_model now also returns a home note when its path resolved under the package directory
    rather than the data dir, so a caller can see where the file landed.
  • Added: export_model format: 'html', the remote eyes gate. Writes world.html into the
    sketch's outcome folder beside recipe.json and README.md: the same self-contained page
    /world serves (emitThreeWorld({ inline: true }), three.js embedded as data: modules, walk
    mode and HUD included), resolved through the same resolveWorldScene the GLB leg uses. It opens
    from file:// with no server and no network; a soundtrack is the one channel a file:// open may
    block, as export_game already documents. Result carries bytes, path, dir, download_url
    and a note saying so; vertices / triangles are not reported (it is a page, not a mesh).
    Deterministic: the same row emits the same bytes (the seed-91 city is about 9 MB). Test pins
    zero http(s):// and zero /vendor/three references and the inline importmap.
  • Fixed: the flat tools/list payload had crossed its pin. The html clause landed on an
    export_model description already carrying the batch's 3mf / usda / usdz / scad growth,
    and the merged tree measured 263,619 bytes against the 263,500 pin (the branch-local number
    recorded when the clause was written did not survive the merge). The description is re-cut to
    routing grade instead — 2,179 → 1,915 chars, every routing fact kept, the fidelity rhetoric and
    the re-lightable-PBR aside dropped (the lit property already carries it) — and the payload
    measures 263,355. Pin unchanged.
  • Fixed: compose_world flagged context as "not reflected" on every themed city mint. The
    city adapter lowers the slot keys (context, asset, material, style) onto the top level of
    the recipe, so the flat-key check never found them. A lowered slot now counts as reflected when
    every child key landed, under its own name or the name the adapter gives it (asset.monument
    is stored as landmark; the adapter is run on the one child to learn that), and the note names
    only the child keys that did not (context.time for an invalid 'noon'), adding that the rest
    of the slot folded onto the recipe's top level. A slot with no child landed is still named whole.
  • Added: blocks on the fractal-city stats: the count of street-grid parcels the quadrant
    recursion filled (each fillBlock leaf past the 1.3-unit floor), the unit an operator means by
    "block". The seed-91 fixture city has 47 blocks for its 44 buildings; the default city 8 for 3.
    The old stats had rows for what stands on a block, none for the block. A count only; no dice,
    no bytes change.
  • Docs: docs/tech-requirements.md Linux bullet records the 2026-09-21 evidence and what is
    still unverified there; Node 24 is recorded as a verified runtime; the PNG-encoder caveat sits
    beside the byte-identical GLB claim. README.md platform sentence matches. AGENTS.md gains a
    "CLI-only / remote sandbox" host section and names the Grok chat / Grok Build collision;
    grok-build.md points chat-sandbox riders at it; docs/install-capabilities.md says what
    --omit=optional now leaves running.