Repository navigation
v2.0.7
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 inittakes its defaults when stdin is not a terminal. CI, a pipe, an agent driving
the install or</dev/nullused 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-uiand--printcompose 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 recallinstalls the runtime into~/.mojulo/recall/(its ownpackage.jsonand an
entry.mjsshim; it survives package upgrades the way~/.mojulo/models/does) and fetches the model;
--removetakes it away.mojulo install chatbotinstalls recall first, because the bot-builder preview
RAG must behave like the deployed bot.MOJULO_PACKS=recallis the manual override, as for the other groups. semantic_searchworks without it. The index is now a lexical search (SQLite FTS5, trigram
tokenizer, already insidebetter-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
carrymode: 'vector' | 'lexical'; a lexicalscoreis 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 inrouting-eval.integration.test.jsnow needs the group).- Text-only rows.
meta_embeddings.embeddingis 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. sharpis an optionalDependency in its own right (it used to arrive through the embedder) and
stays part of the creative group;sharp-lazy.jsalready 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:tarballruns the cold
install without the group and asserts a lexicalsemantic_searchresult, and--with-recallinstalls
the group into the temp dir first. Measured by that gate on macOS arm64: tarball 30 MB, unpacked 116 MB,
node_modules472 MB (2.0.6: ~775 MB), about 590 MB total against 885 MB before. adm-zipis 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:tarballparsesnpm pack --jsonpast 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: trueon afieldsentry or acutsentry 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/intersectwithout
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_referenceand the scad mint, each only when the recipe wants exact. The
export_modelledger reads an exact cut asexactwithedge_rounding: 0(no grid, no rounding).
Routing copy no longer says a sharp edge needs OpenSCAD. -
Fixed:
export_model union: truenever found Manifold under the dashboard server.manifold-3d
is ESM-only, and Next's server bundle turned the literalimport()into arequire()Node refuses
(ERR_PACKAGE_PATH_NOT_EXPORTED), so the union reported the package missing undernext devand 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 thescadkind's loader carries). -
update_sketchon ascadrow answers with a readout. The edit paysplanScad'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 apatch) lists the parts by
NAME that are new, moved, or named by a/parts/<name>op, plus new warnings and OpenSCAD log lines;
summaryandfullas 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 baremojulo_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.scadbeside 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.sourceis 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 asmanifold-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 —unitsdefaults 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 ascadrow returns
the source verbatim (one exact term, nothing frozen). Card:get_solid_vocab({ id: 'scad' }); routing
cardscad-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. Ascadspec may carryfields(the workbench's
field entries, each with anid); the program reaches one asmojulo_field("<id>"), a module mojulo
prepends holding that field baked as apolyhedron(). 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 heavydisplace) is refused at mint with the edge counts, because OpenSCAD's kernel
would drop it from a boolean silently; and any OpenSCADERRORline fails a scad render even when a
file was written. -
bind_mesh_rendertakes a.stlor.3mfatglb_path. The file is read into the standard
face list (3MFbasematerialscolour 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 viameshRef. The result carriesconverted_from. A GLB path behaves exactly as before. -
The scad still is legible. The CSS-3D scene and gallery PNG of a
scadrow fold coplanar
triangles into one clipped panel per flat region (holes via anevenoddclip); the World and every
export keep OpenSCAD's triangles. -
Fixed:
export_model format:'scad'dropped every stadium. A rounded rect whose side equals2r
(a pill button, a USB-C port, a camera plateau) transpiled tooffset(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 ahull()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,gridandtoonfrom the spec. The card documented all three as
top-level spec keys butmint_solid kind:'workbench'/'code'dropped them; only anupdate_sketch
patch could store a hinge. Absent, byte-identical.
Mover ease: the magnetic latch
movers[].easeon 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 theemit-channels.charhashes for the
mover fixtures are re-pinned in the same change.
Library performance
- Changed:
GET /api/sketchesships summaries, not manifests. The Library's Scenes shelf was a
62 MB JSON body on a workshop of ~3,000 sketches, because everycontrollablerow stores its baked
facesinline (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 carriesref, title, kind, renderMode, bucket, bucketOverride, createdAt, folderRef, the
badge facts the shelves actually read (facts: { seed, giBake, giAdapter, game, audio }),
hasBoundRenderandassociations. The recipe itself rides along only where the client renders from it:
rows whose render mode isdiagram(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 isSketchRepository.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.kindand
sketches.bucket_derived(distinct from thebucketoverride 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()andnewestByBucket()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/homeand/api/home/floor. The derivation is versioned: a signature of the kind lists
classifyBucketreads is stored inapp_settings, and a mismatch (a kind added to a list, or a manual
revision bump) re-backfills the columns.rowToSketchstill derivesbucketfrom 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]/worldserved 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'sIf-None-Matchand got a 304. The key now folds inWORLD_CACHE_VERSION
and the package version (getServerVersion, moved tolib/server-version.jsand re-exported from the
MCP server module). Browser-held world pages revalidate once after this ships.?nocache=1stays
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,
versionincluded, crashed at tool registration whensharpcould
not load (Could not load the "sharp" module: an--omit=optionalinstall leaves out
@img/sharp-<platform>, and a platform with no prebuilt libvips has the same shape). Thirteen
modules on the registration path importedsharpstatically — the world registry, the sketch
rasterizer, the polygonizer and sprite-sheet tools, six image-outcome modules, the two motion
encoders — and@huggingface/transformers(which importssharpitself) rode in through the
embedder. They now loadsharpon first use through one helper (lib/sharp-lazy.js), and the
embedder loads transformers on first use. A missingsharpis an in-band error on the raster
call that needed it, naming the fix (npm install sharpin 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 refusessharpand assertscall versionexits 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-uibound to the machine hostname on Linux. The bin setHOSTNAMEonly when it
was absent; Linux containers and many shells exportHOSTNAME=<machine>, so the standalone
server printedLocal: http://<container>:3001and127.0.0.1refused. It now binds127.0.0.1
unconditionally;MOJULO_UI_HOSTis the explicit override (loopback-only remains the default). - Fixed: the bins wrote exports into the package directory.
resolveMojuloPathsseeded
SQLITE_PATH/ARTIFACTS_DIR/STORAGE_ROOTunder~/.mojulo/databut not
MOJULO_OUTCOMES_DIRorMOJULO_EXPORTS_DIR, and both fall back tocwd/data/…. The stdio bin
chdirs to the package root and the dashboard to.next/standalone, soexport_modellanded in
node_modules/mojulo/data/outcomes/<ref>/and the dashboard's/outcomesroute read a different
folder. The resolver now seeds both ($MOJULO_DATA_DIR/outcomes,$MOJULO_DATA_DIR/exports);
repo-devnext devkeeps its cwd fallback. Exports written by earlier versions stay where they
were (under the installed package'sdata/outcomes/anddata/exports/); nothing is moved.
export_modelnow also returns ahomenote 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. Writesworld.htmlinto the
sketch's outcome folder besiderecipe.jsonandREADME.md: the same self-contained page
/worldserves (emitThreeWorld({ inline: true }), three.js embedded asdata:modules, walk
mode and HUD included), resolved through the sameresolveWorldScenethe GLB leg uses. It opens
fromfile://with no server and no network; a soundtrack is the one channel afile://open may
block, asexport_gamealready documents. Result carriesbytes,path,dir,download_url
and anotesaying so;vertices/trianglesare 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
zerohttp(s)://and zero/vendor/threereferences and the inline importmap. - Fixed: the flat tools/list payload had crossed its pin. The
htmlclause landed on an
export_modeldescription already carrying the batch's3mf/usda/usdz/scadgrowth,
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 (thelitproperty already carries it) — and the payload
measures 263,355. Pin unchanged. - Fixed:
compose_worldflaggedcontextas "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 aslandmark; the adapter is run on the one child to learn that), and the note names
only the child keys that did not (context.timefor 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:
blockson the fractal-city stats: the count of street-grid parcels the quadrant
recursion filled (eachfillBlockleaf 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.mdLinux 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.mdplatform sentence matches.AGENTS.mdgains a
"CLI-only / remote sandbox" host section and names the Grok chat / Grok Build collision;
grok-build.mdpoints chat-sandbox riders at it;docs/install-capabilities.mdsays what
--omit=optionalnow leaves running.