title: "Graph editor" description: "A local Rete.js node editor whose graphs compile to readable Ohm scripts." nodes: 20 progress: 85% generated: ohm spec editor#

Graph editor#

A local Rete.js node editor whose graphs compile to readable Ohm scripts.

Contents#

☑ Overview#

☑ Scope#

note

packages/graph (component catalogue, profiles, compiler) and apps/ui/src/views/GraphView.tsx.

☑ The graph compiles to a script, it does not execute itself#

decision

Running a graph writes projects/<id>/scripts/<name>.graph.ohm and runs that file. So there is exactly one execution engine, the generated code can be read, edited and rerun by hand, and a graph is a way of writing Ohm rather than a parallel runtime.

  • spec: 02-ohm-language

☑ Local only#

requirement

Rete and its plugins are bundled; nothing is fetched from a CDN, and the editor works offline like the rest of the app.

☑ Model#

☑ GraphDoc#

data-model

{ name, description?, profile?,
  nodes: { id, type, x, y, values: Record<string, unknown> }[],
  connections: { from, output, to, input }[] }

profile only filters the palette; it has no effect on compilation.

☑ Component catalogue#

data-model

Components declare { type, label, category, doc, inputs, outputs, controls, emit } where emit receives helpers for the output variable name, a connected input expression, "connection else same-named control literal", and a Lua literal.

Twelve categories: input, browser, ai, reasoning (Ψ), context (Γ), memory (ℳ), aggregate (Σ), agent (Λ), math, project, output, script.

78 components, in three groups.

Hand-written, because each needs an emit that cannot be derived: Text, Number, Question, Variable, OpenTab, PageText, QueryAll, CloseTab, Generate, Prompt, MemRead, MemWrite, MathExpr, Curve, AddLink, SaveDoc, Print, Display, and a raw OhmCode escape hatch.

Browser automation, for driving a page rather than reasoning about one: Navigate, OpenMany, Click, Type, Press, WaitFor, Scroll, Eval, Inspect, Markdown, AskPage, SavePdf. Also hand-written: each maps to a specific tab.* host call with its own shape.

Every operator, generated from the registry. Inputs and controls are read off each operator's own signature — what precedes the semicolon becomes sockets, what follows becomes controls, with the control's kind taken from its default. The palette carried sixteen of the forty-nine by hand once, and the other thirty-three were missing because adding an operator meant remembering to add it here too. Generation is what stops that recurring; a test asserts the palette covers the registry, and another compiles every generated node and checks the Ohm it produces.

Sockets are untyped by design — one socket kind — so any output can feed any input.

  • spec: 02-ohm-language — the operator families

☑ Profiles#

data-model

Five built-in palettes — All components, Deep research, Prompting, Browser automation, Math & curves — plus user-defined ones saved in settings. Built-ins are read-only; custom profiles are a list of component types, deduped.

☑ Compilation#

☑ Algorithm#

flow

  1. Build incoming edges, dropping connections whose endpoints are missing.
  2. Kahn topological sort, with the initial queue sorted by y then x — the only determinism knob, so a tidy layout produces stable output.
  3. Variable names are n<1-based node index><output name>.
  4. Emit a header comment, one local declaring every output of every node, then per node a -- #<index> <label> comment followed by its emitted lines.
  5. Collect errors instead of throwing: unknown component, unconnected required input, and "The graph has a cycle; nodes in the cycle were skipped".

compileGraph is total — it always returns { code, errors } — and running refuses while errors is non-empty.

  • node: compile-not-run

☑ Generated code#

data-model

-- Generated from graph "research" by the Ohm graph editor. Edits here are overwritten.
local n1text, n2out, n3out, n4out, n5out
-- #1 Project question
n1text = (ctx.project and project.info().question) or "What is Ohm?"
-- #2 \Search
n2out = $\Search_{\Gamma}(n1text; k=3)$
-- #3 \Retrieve
n3out = $\Retrieve_{\Gamma}(n1text; n2out, k=5)$
-- #4 \CoT
n4out = $\CoT_{\Psi}(n1text; n3out)$
-- #5 \Cite
n5out = $\Cite_{\Gamma}(n4out; n3out)$

Comment numbers follow document order while statements follow topological order, so the numbers may appear out of sequence — that is expected.

☑ What the tests pin#

test

The research pipeline compiles to Ohm that the Ohm compiler accepts, in dependency order; control values fill unconnected inputs; cycles, unknown types and missing required inputs are reported rather than thrown; every component compiles standalone; and every profile references components that exist.

  • node: compiler

☑ The editor#

☑ Interaction#

component

Edit and viewer modes (the viewer drops the connection plugin and blocks node movement), a palette grouped by category and filtered by the profile and a text filter, Delete to remove selected nodes and their connections, Fit to zoom to everything, and "Compile to Ohm" which saves and then shows the code and any errors with a Copy button. A profile editor ticks components per category and saves a named palette.

☑ Where it opens from#

flow

Two routes to the same editor: the Projects panel's Graphs section, and clicking a projects/<id>/graphs/<name>.graph.json file in the Scripts tree.

The second route is the one people take, because the file is sitting there in the tree. Without it the file opened in Monaco as raw JSON, which looks like the editor is missing. The same applies to prompts, curves, experiments and specs: a path under a project's item folder with that kind's suffix opens that kind's editor.

A file under <name>.versions/ is a saved snapshot, not the document, and opens as text on purpose.

☑ Theme#

component

Nodes are dressed through the classic preset's styles hook rather than by replacing the node component, so the socket and control plumbing stays the library's. Colours are the app's own CSS variables, which means the canvas follows light and dark with no second theme, and each node carries a stripe in its category's colour — the same colour the palette shows beside that group.

☑ Review findings#

☐ Lua’s local limit is reachable#

risk

The compiler declares one local per output of every node, including nodes skipped as part of a cycle. Lua allows 200 locals per function, so a graph beyond roughly 200 outputs fails at runtime with a confusing message rather than at compile time. Either batch the declarations into a table or check the count and report it.

  • node: compiler

☐ Variable names are concatenated without a separator#

risk low

n + index + output name means node 1's output ext and node 11's output t would both be n1ext. No current component makes this reachable, but nothing prevents it either. Add a separator.

☐ Boolean controls round-trip through strings#

risk low

A bool control is stored as the string "true"/"false"; any other truthy string coerces to true on load. Store booleans as booleans.

graph LR
  compiler["Algorithm"] -.-> compile_not_run["The graph compiles to a script, it does not execute itself"]
  what_the_tests_pin["What the tests pin"] -.-> compiler["Algorithm"]
  lua_s_local_limit_is_reachable["Lua’s local limit is reachable"] -.-> compiler["Algorithm"]