Co-Forth
Canonical source:
vocabulary/language/FINCH_FORTH.md. This page is refreshed from the Finch repository when the website builds.
Language version: FINCH-FORTH/1
Co-Forth is the user/model-facing textual form of Finch’s typed stack IR. It is deliberately close
to one-to-one: a literal becomes Constant, a word call becomes Call, if/loops become explicit
IR control-flow blocks, and local syntax becomes LocalSet/LocalGet. The binary/serialized IR
remains the internal representation, but there is no hidden second execution semantics. Source is
whitespace-separated and evaluated left-to-right against the recipient’s typed data stack.
3 4 2 * +s" Hello from Finch" sayBooleans are true and false. The stack manifest is ordered bottom-to-top. Never assume it is
empty; inspect it and include expected_revision when manipulating existing values.
say and every output-* operation are stack-neutral effects: they consume their arguments and
leave no unit placeholder. Compose consecutive output directly—never add drop after them.
Lisp keeps unit only as an internal expression value; at a program boundary it is likewise not
persisted onto the shared stack.
cr is the conventional explicit line break, exactly equivalent to s"\n" say. It does not
change the exact-chunk behavior of say.
\ starts a line comment outside a string and continues through the following newline. Comments
are ignored by the compiler and never grant capability, change a signature, or affect provenance.
'name pushes a typed symbol value. It is data, not a dictionary lookup; ['] word execute
remains the form for obtaining and invoking a typed word reference. some/none and ok/err
construct typed option/result values; use is-some, is-ok, unwrap, result-unwrap, and
result-error for inspection and projection.
if-some is the structured option branch. It consumes an option<T>, makes the unwrapped T
available on the then stack, and consumes the none value on the required else stack. Both
branches must leave the same typed stack row:
5 some if-some 1 +else 0thenif-ok is the equivalent structured result branch. It consumes a result<T,E>, supplies T to
the then branch and E to the required else branch:
5 ok if-ok dropelse dropthenInside a typed word returning exactly one compatible result<R,E>, ? unwraps an ok payload
or returns an err immediately from the word. It is a verified early-return edge, not a hidden
error variable or an exception. The normal path still owns the unwrapped value:
: parse-then-use ( S string -- S result<int,string> ! pure ) json-parse ? drop 42 ok;{ ... } constructs an immutable heterogeneous record. name: is a syntax
label (not a stack value), so every label must have exactly one following value. Project a known
field with "name" record-get; it produces option<T> and can be checked with unwrap or the
typed option branch forms:
{ name: "Ada" age: 37 } "age" record-get unwrap \ leaves 37{ name: "Ada" age: 37 } 38 "age" record-set "age" record-get unwrap \ leaves 38record-set creates a new record; it does not mutate the original value. Its final field name is
a literal string, and the replacement must have the field’s statically known type.
record-get:<name> remains accepted as compatibility syntax, but new programs should use the
ordinary stack spelling "name" record-get.
Closed variants use a typed constructor template. It consumes exactly the selected tag’s payload:
variant<variant{none|some(int)},none>42 variant<variant{none|some(int)},some>The resulting value retains the full closed variant type for exhaustive checking.
Project a tag without trapping via variant-get<tag>. It returns option<payload>; a payload-free
tag uses unit, so variant-get<none> returns option<unit>.
Records may also hold typed quotations. This lets a record carry data and a reusable operation without introducing a separate object runtime; invocation remains explicit and receives its inputs on the ordinary stack:
: increment ( S int -- S int ! pure ) 1 + ;{ run: ['] increment } "run" record-get unwrap 41 swap execute \ leaves 42map-entries returns an insertion-ordered list<record{key:K,value:V}>, which can be traversed
using the ordinary list and loop forms:
map{ "answer" 42 }map map-entries 0 list-get "value" record-get unwrap \ leaves 42list-uncons safely decomposes a homogeneous list without a special multi-return. It returns
none for an empty list or some(record{head:A,tail:list<A>}):
[ 4 8 ] list-uncons if-some "head" record-get unwrap else 0 then \ leaves 4External JSON remains json, rather than being silently coerced into a typed record. Its keys may
contain spaces or other arbitrary text; access them with a string through json-get:
s"{\"first name\":\"Ada\"}" json-parse result-unwraps"first name" json-get unwrap json-as-string unwrap \ leaves "Ada"Co-Forth accepts a pasted JSON object directly when its first key is quoted, so commas and ordinary
JSON string escaping work without translating it into a record or map literal. The value remains
managed json; { name: "Ada" } with an identifier label is still a typed record. Commas are
also optional separators in typed list source, including tight pasted forms:
{"first name":"Ada","age":37} "first name" json-get unwrap json-as-string unwrap[1, 2, 3] 2 list-get \ leaves 3case is a typed integer switch with no fallthrough. Each of ... endof arm and the optional
otherwise arm must leave the same stack row. The selector is removed before an arm begins;
otherwise is required whenever an unmatched case must produce values. This is structured branch
syntax, not a dynamic dictionary lookup:
2 case 1 of 10 endof 2 of 20 endof otherwise 30endcase \ leaves 20Without otherwise, a non-matching selector is simply dropped, so every selected arm must also
leave no values. Use a typed if/if-some/if-ok when the branch condition is not an integer
selector.
Typed signatures must use S as the first item on both sides of --. It is the preserved unknown
lower caller stack, so ordinary words consume only their declared inputs and preserve everything
beneath them:
: square ( S int -- S int ! pure ) dup *;S is a type-level stack row, not a runtime value: ( S -- S ! pure ) is stack-neutral, not a
word that consumes the whole stack. A typed definition may not omit, duplicate, or drop this row.
Type names may be parameterized in a signature, including nested forms such as list<int>,
map<string,int>, option<list<string>>, result<T,E>, task<T>, resource<kind>, and
capability<kind>. A pure closure taking A,B and returning R is fn<A,B,R>; a pure
zero-argument closure returning R is fn<R>. A fixed product type is
record{name:string,age:int}; it is distinct from an open map<string,T> and may be the input or
output of a typed word.
Name inputs directly in the typed signature when the body needs them. Names are in bottom-to-top stack order and lower directly to frame locals; there is no separate locals declaration:
: area ( S width:int height:int -- S int ! pure ) width height *;This lowers to LocalSet height, LocalSet width, then LocalGet width and LocalGet height.
The frame owns those locals and a private operand window above the caller stack boundary. return
keeps only the declared output values from that window, then destroys the frame; temporary values
cannot leak into the caller.
Use ! pure to assert purity or ! infer to accept the transitively inferred capability set in
the current frontend. A false purity
assertion rejects the entire submission and does not modify the dictionary. Every definition’s name
is predeclared as a group before any body compiles, so both ! pure and ! infer definitions may
be self- or mutually-recursive in one submission. A declared-pure signature’s effects are fixed at
empty immediately; an ! infer signature’s effects are not known until its body compiles, and one
! infer definition’s effects can depend on another’s under mutual recursion, so the compiler
reaches a fixpoint over the whole submission’s definitions before compiling the executable body that
follows them. Typed definitions are persistent and immediately callable from Finch Lisp.
Put a public doc comment immediately before a typed definition when it should be discoverable from the shared VM without reparsing source:
\ finch-doc: Double an integer.: double ( S int -- S int ! pure ) 2 * ;Only the text after finch-doc: is retained on the immutable typed function contract. It is never
placed on the operand stack or executed, and inspection may return it alongside the signature.
dup, drop, and swap are polymorphic. Arithmetic does not coerce strings or dynamic values.
Control-flow merge points must have identical stack types, and loops require stable invariants.
Checked arithmetic reports division-by-zero or overflow traps.
Workspace file words use refined paths: s" Cargo.toml" path file-read leaves bytes, while
s" generated/result.bin" path data file-write consumes the bytes and leaves no stack value.
Paths are
workspace-relative and checked against their declared selector at both verification and host
execution. Missing file grants pause for approval rather than being silently widened.
For large text, CSV, or binary files, prefer file-size and bounded file-slice instead of
materializing the whole file. path offset length file-slice reads at most the requested byte
range (currently capped at 8 MiB per call) and returns bytes; EOF simply returns a shorter final
slice. Maintain an explicit byte offset in a local and decode/process each slice before requesting
the next.
path file-hash computes a lowercase SHA-256 digest without putting the file’s bytes on the VM
stack. Use it for exact comparison or as a leaf value in a higher-level tree/Merkle operation.
path max-entries tree-list returns a deterministic bounded record containing
entries:list<record{path:string,kind:string,size:int}> and truncated:bool. It rejects symlinks
and unsupported file kinds. Returned path strings are metadata, not file authority; pass a chosen
string through path before reading it.
path tree-merkle computes a deterministic SHA-256 digest over a directory subtree’s sorted
relative paths and file hashes, without materializing its contents. It is bounded to 100,000
entries and rejects symlinks rather than following them. Use it as an inventory/change-detection
fact; it is not by itself a malware verdict or permission to alter the tree.
host-path is a separate, host-issued root type, never an absolute-path bypass. If the host has
explicitly installed root<host-machine> and the user has approved a matching selector, use
s" var/log/system.log" host-path host-file-read. Workspace file-read and file-write reject
that value at verification time; bind / only for an intentional whole-machine grant.
Application-installed project and task-output roots remain distinct as well:
s" src/main.rs" project-path project-file-read and
s" report.txt" task-output-path s" done" bytes task-output-file-write. A path from one root
cannot be passed to another root’s file word, and binding a root never grants file authority.
For UTF-8 line-oriented files, file-lines-open returns a host-issued stream<string> and
stream-next returns option<string> one bounded line at a time (1 MiB maximum line);
stream-close releases it early. The stream is owned by its ProgramRun and cannot be forged or
reused by another run. file-lines-next / file-lines-close remain compatibility aliases.
CSV-record streams are distinct because
quoted fields may span physical lines. path workbook-open opens the first XLSX/XLS/ODS sheet and
path sheet workbook-sheet-open opens a named sheet as stream<list<string>>; use ordinary
stream-next and stream-close. The host currently caps a decoded sheet at 10 million cells while
passing only one row into the VM at a time; it is not yet a true streaming ZIP/XML decoder.
: first-line ( S -- S option<string> ! infer ) s" data.csv" path file-lines-open stream-next;For a bounded streaming pass, keep the cursor as the loop row and use if-some to consume each
record without retaining it between iterations:
s" data.csv" path file-lines-openbegin: rows true while dup stream-next if-some say \ replace with bounded row processing else break rows thenrepeatstream-closeUse csv-open to obtain stream<list<string>>, stream-next to obtain
option<list<string>>, and stream-close to release it. The stream accepts UTF-8 RFC-style quoted
fields, including doubled quotes and multiline fields, and bounds each complete record to 8 MiB.
It rejects malformed quote boundaries instead of guessing. It has the same ProgramRun ownership and
file.read requirement as a line cursor.
When the model needs column shape and basic statistics rather than individual rows, use
path max-rows csv-summary. It treats the first record as headers, scans at most 100,000 data
records, and returns managed json containing headers, sampled_rows, truncated, and per-column
empty, non_empty, numeric, min, max, and mean fields. It reads one additional record only
to determine truncated and rejects rows wider than the header.
Use path workbook-sheets to discover sheet names. For bounded cell inspection, use
path sheet start-row start-column row-count column-count workbook-range; offsets are zero-based
and the result may contain at most 10,000 cells. For aggregate inspection, use
path sheet max-rows workbook-summary; the first row supplies headers and the result is managed
json with the same per-column facts as csv-summary plus the selected sheet name.
s" data.csv" path csv-opendup csv-next if-some 0 list-get say \ first field of one CSV recordelse s" No records." saythendrop \ branch result; retain the cursorcsv-closeAgent words operate on persistent typed task<agent-result> handles: s" task" agent-spawn,
agent-poll, agent-await, and agent-cancel. A handle can remain on the VM stack across later
turns. Poll is nonblocking and returns a typed snapshot record with status, identity,
task/role/model, depth, and completion fields. Await returns a typed result record containing
status, identity, final message, diagnostics, turn/timing metadata, model, and depth. Programs can
project those fields with record-get instead of parsing JSON or scraping text. The
same structured spawn contract is available without a frontend special case:
{ task: "inspect recent failures" role: "explore" background: "focus on typed effects" provider: "" model: "" context-refs: empty-list<record{kind:string,id:string,sha256:string}> capabilities: empty-list<resource<capability-grant>> max-turns: 4 timeout-ms: 60000 max-output-bytes: 65536} agent-spawn-withRoles are general, explore, research, or code; empty background/provider/model strings mean
no override. Context references are bounded {kind,id,sha256} descriptors and poll/await expose the
effective starting-context-hash. The host registers immutable bounded UTF-8 artifacts and supplies
their computed references; child creation resolves and SHA-256 verifies every artifact before task
allocation. Unknown, rebound, oversized, non-text, or mismatched artifacts fail closed. Budgets are
positive and host-bounded, and unavailable explicit
model selection fails before the scheduler creates a task. capability-list returns typed records
whose grant field is an opaque resource<capability-grant>; select only those resources to pass
in capabilities. An empty list delegates no non-intrinsic authority. Printed UUID strings and JSON
requirement metadata are not authority. The compact task agent-spawn form deliberately inherits
the caller’s whole creation-time ceiling.
For CPU-bound pure closures, ['] zero-argument-word defer-cpu starts a private worker and leaves
task<T>. task-poll replaces it with
record{task:task<T>,value:option<T>}; project task for a later poll/join/cancel and value to
inspect the nonblocking result. task-join replaces a task handle with T, suspending
the current VM run if necessary rather than blocking the terminal. task-cancel consumes a handle
and requests cooperative cancellation at its next worker VM boundary. CPU task words reject
agent-task handles.
yield consumes and publishes one typed value, then yields control to Finch’s event loop as a
saved suspension. A host must explicitly resume that execution at the next word. It does not
expose a continuation value or terminate the program. Use unit yield for a plain cooperative
timeslice; producer/fiber hosts may consume another yielded type:
s" Searching…" sayunit yields" Finished." sayA pure zero-argument yielding quotation becomes fiber<Y,R> with defer. fiber-next returns
result<Y,variant{end(R)}>: ok(Y) is the next item and err(end(R)) carries the terminal return.
fiber-join discards remaining yields and returns R; fiber-cancel makes later use fail
deterministically. Operations consume the handle, so duplicate it when it must remain available:
: numbers ( S -- S int ! infer ) 2 yield 3 yield 5 ;['] numbers deferdup fiber-nextProducer continuations survive VM checkpoints and their advancement is rolled back with a failed
ProgramRun. This version resumes them with unit and permits only pure quotations. Host-backed
lazy data remains stream<T> with stream-next and stream-close.
vm-vocabulary returns the current serialized typed word manifest.
JSON is a managed typed value, not a stringly authority channel. Parse untrusted text with
json-parse (which returns result<json,string>), look up an object field with json-get
(which returns option<json>), then project a scalar through an option-returning converter:
s" {\"answer\":42}" json-parse result-unwraps" answer" json-get unwrap json-as-int unwrapjson-as-string, json-as-int, json-as-float, and json-as-bool return none for a mismatched JSON kind;
json-index returns option<json> for an array index, json-keys returns list<string> for an
object (or an empty list otherwise), json-as-map explicitly normalizes an object to
option<map<string,json>>, and json-stringify converts a managed JSON value back to compact
text. json-as-map preserves arbitrary keys such as "first name"; it does not coerce JSON into
a typed record.
process-run consumes a command string and a list of argument strings; it never invokes a shell.
The generic MCP boundary is server tool arguments-json mcp-call. It returns managed json and
requires authority for that exact server/tool pair. For example:
"github" "issue_get" {"owner":"darwin-finch","repo":"finch","issue_number":42} mcp-callDiscovered tools also appear as mcp.<server>.<tool>. When its input schema is representable, the
word consumes one typed record; otherwise it consumes one managed json. Inspect its exact
signature rather than guessing fields:
{ owner: "darwin-finch" repo: "finch" issue_number: 42 } mcp.github.issue_getFor progressive prose, emit multiple typed chunks with say. Bare "..." pushes one typed
string and never evaluates its contents. s"..." is an equivalent familiar Forth spelling; the
s means string, not say. Both s"text" and the conventional s" text" spell the
identical string text: exactly one ASCII whitespace delimiter immediately after s" is ignored.
Standard Forth ."..." is supported as output shorthand: it lowers exactly to s"..." say,
including the normal typed session.emit side effect. Use s"..." when a string must be passed
to another word; use either say or ."..." when it is user-visible output.
Use escapes (\", \\, \n, \r, \t) inside this short-string form:
"Hello user" says" Hello user" say \ same string: "Hello user"." Hello user" \ output shorthand; same as s" Hello user" sayFor prose or copied text containing quotes/newlines, use a raw triple-quoted literal. Its contents
are verbatim until the next """; it has no escapes and preserves all leading whitespace:
"""The user said "hello".Second line.""" says"""...""" remains a compatible spelling.
The delimiter """ itself cannot appear in a raw literal; split the text into ordinary typed
string operations if it is needed.
Typed maps are immutable shared-language collections. Co-Forth uses map{ / }map around an
even sequence of key/value expressions; the closing delimiter lowers the suffix directly to typed
IR. map-get returns option<V>, map-set returns a replacement map, map-keys returns
list<K>, and map-length returns an integer. Use empty-map<K,V> for an explicitly typed empty
map. Later duplicate keys replace earlier values:
map{ s" answer" 42 s" other" 7 }maps" answer" map-get unwrapTyped lists are likewise immutable. Use [ / ] around one or more homogeneous values;
list-append returns a replacement list rather than changing the original. ['] word remains a
distinct quotation form. An empty list has no inferable element type, so use empty-list<T> to
state one explicitly. list{ / }list remains a compatibility spelling while existing scripts
migrate:
[ 1 2 ] 3 list-append2 list-get \ leaves 3empty-list<string>say is a stack-neutral response effect: it consumes its string and leaves no value on the
stack. Consecutive say calls compose directly; do not add drop after one:
s" Working…" say2 3 + int-to-string sayLisp keeps an internal unit only while compiling an effect-only expression; at a program
boundary it likewise leaves no synthetic value on the shared stack. Both forms emit the same
ordered response chunks.
For concurrent/reactive presentation, output-open asks the host for an opaque
resource<output-handle>. Keep that handle explicitly (usually in a local), then use
output-append, output-replace, output-status, output-progress handle completed total,
output-complete, or output-fail. These produce portable ordered UI events; they do not mutate
the terminal directly and never depend on a global active WorkUnit:
output-append appends the handle body and output-replace replaces that body. output-status
sets separately rendered transient status text; it does not erase the body. output-progress is
separate bounded progress metadata. All of these effects are stack-neutral.
An output handle belongs to the ProgramRun that opened it (including that run’s verified yield/approval resumptions). A later submission cannot reuse it; the host rejects stale, completed, or cross-run handles.
: download-status ( S -- S ! infer ) s" download" output-open dup s" starting" output-status dup 2 5 output-progress output-complete;Typed conditionals use if ... else ... then. Typed loops use begin ... while ... repeat or
begin ... until. Structural words must be properly nested. The condition is bool; the
stack/type shape after consuming it and at every back-edge must equal the loop-header shape. Each
iteration consumes fuel and observes cancellation. do ... loop is reserved for a later version.
Named loop exits are structured, never arbitrary jumps. Spell a named loop as begin: label and
target it with break label or continue label after its while has established an exit:
0 begin: search dup 3 < while 1 + dup 2 = if break search thenrepeatAn exit must name an active loop and leave exactly that loop’s header stack row. This first
version carries no extra break values; expression-valued loop exits and match/case are later
structured forms.
Quotations are typed executable values. A quotation is code; an escaping quotation plus its captured environment is a closure. Runtime quotations are not macros. Macro/immediate expansion occurs in a separate bounded compile-time phase and the expanded program is reverified.
The quotation reference ['] word closes over no values; word must be a persistent typed
definition and its declared stack signature supplies the quotation type. For example,
9 ['] square execute applies square to 9.
An anonymous quotation is [ inputs -- result ! pure | body ] (or ! infer). It lowers to the same hidden
typed function, MakeClosure, and immutable capture vector as a Lisp lambda; it is not a list
literal because its top-level header contains both -- and |. All visible named signature locals
are captured in deterministic lexical order, and local names shadow an enclosing capture. For
example:
: add-offset ( S offset:int value:int -- S int ! pure ) value [ int -- int ! pure | offset + ] execute;3 39 add-offsetThis leaves 42. A quotation declares exactly one result; package multiple results in a record or
list. ! pure rejects a capability-bearing body, while ! infer records the verified transitive
effect row. Captures are immutable values, never aliases to the caller stack or ambient authority.
Use only words advertised by get_vm_state. Treat any other word as unavailable regardless of
examples or prior sessions. Capability-bearing words contribute their structured requirement to the
enclosing definition; a definition cannot claim {} while calling one.