GP-Crystal Design Overview
Drafted 2026-08-16 by the operator with jkridner; a working document — decisions marked [D#] are open and owned by the maintainer unless resolved below.
Vision
GP-Crystal (gpcr) is a blocks-style language whose source is Crystal (parsed by Crystal's own parser, rendered as graphical blocks through the Fossil pipeline). Four north stars:
- Authorable by self-hosted models — the language and its edit interface must be simple enough that a local 7–30B model can write and modify programs reliably (ties to ticket cf1f55134a: grammar-constrained generation as the weak-model interface).
- Speakable — a human looking at the rendered blocks can edit by voice (Stone iPhone app: speech-to-text, live re-render).
- Self-hosting — the gpcr editor should eventually be authored in gpcr (GP's own IDE-in-GP lineage).
- Scales down — the same programs and UI vocabulary should target tiny embedded GUIs (lvgl-crystal; badge-class devices).
0. Peers: formerly the blocker, now a declaration
The multiproject infrastructure (unified peer manifest + session ro-binds, landed 2026-08-12) means gp-crystal dev sessions can mount reference repos read-only at /peers/<name>. The blocker is gone; what remains is committing .peers/peers.json to this repo declaring the study set (§4).
1. Edit Operations ↔ Spoken Grammar (the core bet)
The bet: one small operation vocabulary, three authors. Every edit to a gpcr program is a JSON operation. The SAME operations are emitted by (a) a human speaking a rigid, learnable grammar, (b) a local LLM under grammar-constrained decoding (it cannot produce an invalid edit), and (c) UI interactions (§3). One apply engine; every author's edits are identical in kind, logged, replayable, and forum-postable — the edit log is itself a first-class artifact in Fossil.
1-to-1 mapping sketch (illustrative, not final):
| Spoken | JSON op |
|---|---|
| "add a repeat ten around blink's set-led" | {"op":"wrap","target":"blink/set-led","block":"repeat","slots":{"times":"10"}} |
| "set times of the repeat in blink to twenty" | {"op":"set","target":"blink/repeat[1].times","value":"20"} |
| "delete the second wait in blink" | {"op":"delete","target":"blink/wait[2]"} |
| "make a new command flash with parameter count" | {"op":"def","kind":"command","name":"flash","params":["count"]} |
Addressing [D1.1 — RESOLVED by maintainer, 2026-08-16]: every rendered block carries a visible index number; voice targets it flatly — "wrap block 1 with repeat 10". No hierarchical paths in the grammar. Refinement to make this safe: numbers are DISPLAY indices, re-assigned on each re-render, and every op carries the render generation it was spoken against — the apply engine rejects an op whose generation is stale (you edited against numbers you can no longer see) instead of hitting a renumbered block. Since SSE re-renders immediately after every applied op, the numbers on screen are always current, which keeps flat numbering from becoming unwieldy and gives concurrent-editor safety for free. Addressability stays a rendering requirement: the SVG must print the same numbers the grammar accepts.
Two address dialects, one op schema (maintainer question, resolved 2026-08-16): the op target field accepts either reference form, resolved to the same internal node identity. Voice dialect: {"index":7,"gen":42} — display index + render generation; ergonomically right for humans who re-look at the screen between utterances. Model/MCP dialect: {"path":"blink/repeat[1].times","expect":"repeat 10"} — structural path PLUS an expected-content assertion, validated before apply; wrong kind/ordinal or stale content rejects loudly instead of silently editing the wrong block. Rationale from ollama-codex tool-loop history: indices carry no self-checking redundancy (a hallucinated index is a VALID op aimed at the wrong block) and go stale mid-batch under renumbering, whereas path+expect is block-level optimistic locking — the artifact-grounding principle that fixed local-model source editing. Grammar-constrained decoding applies to the JSON dialect too: the op schema itself is the constraint.
Grammar discipline [D1.2]: the spoken grammar is CLOSED (fixed verbs: add/set/delete/wrap/move/rename/def; fixed prepositions) — no NL "understanding." STT output is parsed by a deterministic grammar, mis-parses are rejected with a rendered hint, never guessed. This same closed grammar becomes the GBNF/constrained-decoding spec for local models.
Round-trip requirement: ops apply to the AST (Crystal parser), and the AST serializes back to canonical Crystal source deterministically — crystal tool format as the normalizer. Undo = inverse ops. Every applied op batch can be a Fossil commit or a working-buffer state.
Pipeline: Stone (STT) → text → grammar parse → JSON op → POST (management API, ticket 21dfd164d6) → apply → re-render → SSE push (ticket 0b016fe2b3 gives Stone authenticated cross-origin SSE) → live block view updates. Confirmation model [D1.3 — RESOLVED by maintainer, 2026-08-16]: apply-and-show with cheap undo (inverse-op stack, session-scoped; "undo"/"undo three"). Plus a cheap commit op with an edit note — "commit note fixed the blink timing" serializes the buffer and makes a real Fossil commit (note = message; the op batch embedded in the commit so history is walkable at the edit-semantic level, not just line diffs). Revision-history walking becomes editor ops ("show history", "go back two commits", "what changed in commit 3") over fossil timeline/diff, rendered through the same block pipeline. Undo across a commit boundary = a new revert-commit, never history rewriting — matching Fossil's immutable-history culture; the editor needs no amend/rebase semantics.
2. Rich Data: Records and Tables
GP/MicroBlocks stop at scalars and lists. gpcr adds Table: an ordered collection of records with a declared column schema — spreadsheet semantics, spoken like everything else ("make a table robots with columns name text, x number, y number"; "add row name scout x three y five"; "set x of row scout to seven").
- Crystal mapping [D2.1 — clarified]: a spoken schema compiles to a real Crystal struct plus a typed array of it. "make a table robots with columns name text, x number, y number" emits:
struct Robot; property name : String; property x : Int32; property y : Int32; def initialize(@name, @x, @y); end; endandrobots = [] of Robot. "add row name scout x three y five" emitsrobots << Robot.new("scout", 3, 5); "set x of row scout to seven" becomes a property assignment. (A mutablestructwith properties, not Crystal's immutablerecordmacro, because spreadsheet-style cell setting needs mutation.) The rejected alternative was an untyped runtime grid (rows as string-keyed maps) — typed structs get compiler checking, honest readable Crystal, and speed. Type inference from spoken literals where possible; Crystal's compiler remains the truth about types. - Verb set [D2.2 — RESOLVED (tiered) by maintainer, 2026-08-16]: VOICE edit verbs stay minimal and direct: add-row, set-cell, delete-row only — the others are not confidently intuitive as spoken forms yet. Richer table editing (schema surgery: rename/retype columns, bulk operations, complex restructuring) is MCP- and GUI-dialect work in the early phases; voice may grow verbs later only once intuitive spoken forms are proven. Clarifying distinction: filter-where / sort-by / sum-of etc. are LANGUAGE BLOCKS (runtime constructs placed via the ordinary Thread-1 grammar by any author), not table edit verbs — they enter the block vocabulary without needing voice-specific verbs. Joins deferred entirely.
- Rendering: table DATA renders as a markdown table (native to the Fossil pipeline); table OPERATIONS render as blocks. Large tables paginate.
- Persistence [D2.3 — RESOLVED by maintainer, 2026-08-16]: only small native tables are in scope, living in source as literals (versioned with the program, rendered as markdown tables). CSV read/write exists as runtime I/O blocks — filling and dumping tables at execution time — but CSV contents are NOT visualized or versioned by the editor; a CSV-file editor is explicitly deferred.
- Driver cases:
crystal-robots(board/robot state as records — the reason this exists) andbootstrap-qcow2(package/source manifests are records; its dependency ledger could literally be a gpcr table — the self-bootstrap goal expressed in the language).
3. UI Elements
Constraint (settled): rendering stays on the Fossil pipeline — markdown + Pikchr→SVG + Fossil chrome/skin. No new web stack.
- Interaction model: Pikchr emits static SVG; a single external JS asset (CSP: no inline scripts — hard-learned rule) maps SVG element ids → JSON ops POSTs, and an SSE channel pushes re-renders. UI = the same op vocabulary as §1, emitted by clicks. The semantic-ui demo is the study reference for SVG-as-UI.
- SSE reuse [D3.1 — RESOLVED by maintainer, 2026-08-16]: extract ollama-codex's LiveEvents as a shared Crystal module (HMAC channel auth, per-session channels, CSP story, poll fallback) in a peer repo as a pinned internal dependency — declared per the ledger discipline.
- Self-hosting path: GP's IDE is written in GP — the long-game here is the gpcr editor authored in gpcr. First UI vocabulary: palette (available blocks), canvas (the program), inspector (slots/values) — each a gpcr program emitting markdown/pikchr.
- Embedded target [D3.2 — RESOLVED direction by maintainer, 2026-08-16]: Pikchr-the-text is the UI intermediate representation — chosen for its defining virtue: line-by-line human (and voice-op, and weak-model) editability, versus unreadable raw SVG. Web renderer = Fossil's built-in Pikchr→SVG. Embedded renderer = a Pikchr-subset → lvgl-crystal mapping (box→object/button, text→label, line/arrow→line, ellipse→arc); the SUBSET DEFINITION is itself the dual-renderability constraint — UI blocks may only emit subset constructs. Self-hosting-coherent: gpcr's renderer already emits pikchr for program blocks, so an editor authored in gp-crystal shares one medium end to end. Open sub-items: (a) layout resolution — Pikchr relative placement must be position-resolved for lvgl absolute placement (fine for small static badge-class screens; reflow out of scope); (b) interactivity annotation — Pikchr only draws, so a marking convention (e.g.
text "Start" # op={...}) must designate live elements, read identically by the web JS layer and lvgl event handlers. First D3.2 artifact: a one-page Pikchr-subset spec.
4. Peer study map (the .peers/peers.json declaration)
| Peer | Study for | Thread |
|---|---|---|
crystal |
parser/AST surface, stdlib idioms the generated code must match | 1,2 |
microblocks-smallvm |
block vocabulary precedent, VM constraints, what GP/MicroBlocks got right/omitted | 1,2 |
semantic-ui |
SVG-based UI interaction demo | 3 |
lvgl-crystal |
embedded UI element set → the abstract UI vocabulary | 3 |
crystal-robots |
records/table driver use-case | 2 |
bootstrap-qcow2 |
manifest/ledger use-case; also provides this container | 2 |
stone |
iPhone STT client — the spoken-grammar front-end; informs the STT→op pipeline | 1 |
5. Suggested sequencing
- Commit the peer manifest (this repo, trunk-ops).
- Thread 1 first — the op vocabulary + addressing scheme is load-bearing for everything (UI emits ops; tables are edited by ops; models are constrained by the op grammar). Prototype: ops apply engine + grammar parser + round-trip on the existing blink.cr.
- Tables (thread 2) as the second vocabulary increment, driven by a real crystal-robots slice.
- UI (thread 3) after ops exist to emit; SSE extraction can proceed in parallel in ollama-codex.
Open decisions register: D1.1 RESOLVED (indexed blocks + render-generation guard), D1.2 grammar closure details, D1.3 RESOLVED (apply-and-show + commit-with-note + history ops), D2.1 typing/inference, D2.2 RESOLVED (voice: add-row/set-cell/delete-row only; rich editing via MCP/GUI early), D2.3 RESOLVED (small in-source tables; CSV = runtime I/O blocks; CSV editor deferred), D3.1 RESOLVED (shared module), D3.2 RESOLVED-direction (Pikchr subset as UI IR; open: layout resolution + interactivity annotation, first artifact = subset spec).
Vocabulary note: event rules are "on-X-do-Y" (maintainer, 2026-08-16)
Future event-rule blocks (the IFTTT / Node-RED-style direction) use on, not "when": when is a Crystal keyword (case/when) whose language meaning carries no timing implication — reusing it for events would collide with both the compiler and the reader's Crystal intuition. do is also a Crystal keyword, but its meaning there (introducing the target block) is EXACTLY the meaning intended here, so it reads consistently in both worlds. Rule shape: on <event> do <block>. Existing hat-block naming (e.g. when_flag_clicked in examples) should migrate to on_flag_clicked as the rules vocabulary lands.