Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
"name": "ilo",
"source": "./",
"description": "Write, run, debug, and explain programs in ilo — a token-optimised programming language for AI agents",
"version": "26.5.0",
"version": "0.13.0",
"version": "26.8.0",
"author": {
"name": "Daniel Morris"
},
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ against Phase 6.

### Fixed

- **Paren-form calls accept trailing operands (ILO-544).** A paren group committed to being the whole argument list, so `fmt2(x)2` AND `fmt2(x) 2` both died with ILO-P001 while `fmt2(x, 2)` and `fmt2 (x) 2` worked — and models emit the glued shape constantly (pipeline-report failed 5/5 on it in the ILO-364 N=5 benchmark, ~1000 wasted repair tokens per failure; the single biggest contributor to ilo's 40% task-failure rate vs Python's 0%). At expression head, a completed adjacent-paren call now keeps collecting trailing operands with the same greedy loop the spaced postfix form uses, so all four spellings mean the same call. A field/index chain (`f(x).0`) closes the list. Mechanism: a `paren_call_atom` flag set by the atom parser, consumed with `mem::take` at expression head only — operand positions route elsewhere and are untouched, so nested calls and complete calls keep their exact prior parses. Cross-engine regression tests in `tests/regression_paren_form_trailing_args.rs`.

- **Shadow-test declarations survive script mode.** When script mode (main) was merged into this branch, `is_decl_start` knew `alias` but not `test`, so `test add { ok add 2 3 5 }` fell into script-statement collection and died as an undefined call — `ilo check` on any file with shadow tests exited 1 and both `cli_check_*_shadow_test` unit tests failed on the branch tip. `test` now routes to `parse_decl` beside `alias`.

- **ai.txt bootstrap index no longer clobbered by builds (ILO-538 completion).** The index shipped without disabling build.rs's SPEC.md→ai.txt regeneration, so any `cargo build` overwrote the 2K index with the ~180KB monolith and CI's sync check failed on the index's own lineage. Regeneration and `compact_spec()` are removed; `cargo:rerun-if-changed=ai.txt` keeps the `include_str!` embed current; the CI diff step now guards against accidental clobber. The index's SPACING line also taught the pre-ILO-544 workaround as advice — updated to the new contract.

- **AOT byte-identity corpus recaptured post-backend-rework.** The `.ilo`→`.@` example rename left `aot_byte_identical` reporting all 136 baselines "missing", which masked that the typed-HIR/backend rework had (intentionally) changed every object byte. The test now resolves `.@` with `.ilo` fallback, and the corpus is recaptured. A 3-example capture on the unmodified branch tip produced byte-identical hashes to this branch's, attributing the drift entirely to the backend rework and none to the parser changes here.

- **CLI arguments are type-checked against the entry function's parameters (ILO-517).** A shell string that didn't match its declared parameter type used to be bound as-is, because `parse_cli_arg`'s ladder falls through to `Text` for anything non-numeric. So `tri n:n>n` invoked as `ilo tri.@ main` bound `Value::Text("main")` to a `n` parameter: the tree-walker and VM printed `NaN`, the Cranelift JIT echoed `main`, and every engine exited **0** — a silent wrong answer with a success exit code, which is worse than a crash for any caller that checks `$?`. The CLI was therefore less safe than the language it fronts, where `num "main"` returns `R n t` and the type checker forces the failure to be handled. A new CLI-boundary guard (`check_cli_arg_types`, sibling of the existing `check_cli_arity`) now rejects unambiguously mismatched values with `ILO-R600`, naming the parameter and showing the offending literal, and exits 1. Wired into all four dispatch sites (VM, interpreter, JIT, default) so the error contract can't drift per engine the way ILO-177 did. The guard is deliberately permissive — `_` accepts anything, `O T` still accepts `nil`, and structural / user-defined types are waved through — so the ILO-182 single-fn pass-through keeps working (`ilo greet.@ world` against `s:t` is legitimate usage, not a typo'd function name). Also closes the same hole for `b` parameters. Found while running the ILO-364 closed-loop benchmark, where it cost a model retries on an otherwise-correct program.

- **`get-stream` yields lines on newline, not buffer-fill (ILO-489).** The client-side streaming builtins (`get-stream`, `get-stream-h`, `pst-stream`, `pst-stream-h`, ILO-448) wrapped minreq's `ResponseLazy` in `BufReader::lines()`, which blocks on a full ~8 KiB read-buffer fill before surfacing any line. So a slow SSE upstream that flushes one short event then idles had its lines batched until the buffer filled or the connection closed - functionally correct but latency was buffer-bound, not event-bound. The line splitter now consumes `ResponseLazy`'s byte iterator incrementally and emits each line the instant its `\n` arrives (trailing `\r` stripped for CRLF / chunked encoding), so each event surfaces promptly. EOF still yields a trailing newline-less partial line; mid-stream errors still surface as `ILO-R009 http-stream read error: ...`. Unblocks ILO-482's previously flaky end-to-end streaming test.
Expand Down
12 changes: 12 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2044,6 +2044,18 @@ f(g(x), h(y)) -- nested paren-calls also work

**Trailing commas are accepted:** `f(a, b,)` is valid (Rust/JS convention).

**Trailing operands extend the call (ILO-544).** At expression head, a
paren group need not be the whole argument list — operands after the `)`
are collected exactly as the spaced postfix form would collect them, so
all four spellings mean the same 2-arg call:

```
fmt2(x)2 fmt2(x) 2 fmt2(x, 2) fmt2 (x) 2
```

A field/index chain closes the list: `f(x).0` is a value, and an operand
after it is an error as before.

**Postfix stays canonical.** `ilo fmt` does not rewrite paren-form to postfix; both styles are accepted everywhere.

### Call Arguments
Expand Down
2 changes: 1 addition & 1 deletion ai.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ INTRO: ilo is a token-optimised, prefix-notation language for AI agents. Every t

QUICK START: Function: `f x:n>n;+x 1`. Entry point: `main>_;prnt "hello"`. Types: n=number t=text b=bool _=any L n=list R n t=result. Prefix ops: `+a b` `*a b` `-a b` `/a b` `>a b` `=a b` `!b`. No infix needed. Semicolons separate statements. Last expression returns. Comments: `--`.

SPACING: Every token needs whitespace. `90"A"` fails — write `90 "A"`. `func(x)2` fails — write `func (x) 2` or `func x 2`.
SPACING: `90"A"` and `func(x)2` both parse (ILO-537/544): a paren group extends with trailing operands, so `func(x)2` `func(x) 2` `func x 2`. Prefer spaces for readability; they cost the same.

MODULES: Load only what the task needs. Each module is 1-4K tokens.
ilo skill get ilo-language syntax, types, operators, guards, match, ternary, pipes, records
Expand Down
166 changes: 16 additions & 150 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,154 +1,20 @@
// build.rs — regenerates the compact spec at `ai.txt` from SPEC.md at compile time.
// `ilo help ai` / `ilo -ai` embeds the same file directly via `include_str!("../ai.txt")`,
// so `ai.txt` is the single source of truth for the compact spec — git-tracked, stable raw
// URL on GitHub, and embedded in the binary unchanged.
// build.rs — ai.txt is a hand-maintained bootstrap index (ILO-538), no longer
// regenerated from SPEC.md.
//
// CI runs `cargo build` then `git diff --exit-code ai.txt`. If SPEC.md was edited without
// regenerating, the diff is non-empty and CI fails.
// History: this script used to compact SPEC.md into ai.txt on every build,
// which kept the two in lockstep but let ai.txt balloon to ~48K tokens as the
// spec grew — the agentlanguages.dev review's "the spec has grown against the
// thesis". ILO-538 replaced ai.txt with a <3K-token index (language kernel +
// `ilo skill get <module>` load instructions); the modular skill files under
// skills/ilo/ are the content artefacts, each under a CI-enforced token cap.
//
// The regeneration had to be REMOVED, not just skipped: any local build
// otherwise overwrote the index with the SPEC-derived monolith (and CI's
// `git diff --exit-code ai.txt` then failed on the very commit that shipped
// the index). SPEC.md remains the human/reference document; `ilo -ai` embeds
// ai.txt verbatim via include_str!.

fn main() {
println!("cargo:rerun-if-changed=SPEC.md");
let spec = std::fs::read_to_string("SPEC.md").expect("SPEC.md not found");
let compact = compact_spec(&spec);

// Only write when the content changed, so unchanged builds don't dirty the working tree.
let tracked_path = std::path::Path::new("ai.txt");
let needs_write = match std::fs::read_to_string(tracked_path) {
Ok(existing) => existing != compact,
Err(_) => true,
};
if needs_write {
std::fs::write(tracked_path, &compact).expect("failed to write ai.txt");
}

// Phase 2 (PR #419): SKILL.md is now a thin bootstrap pointer. The rich
// spec content lives in the modular skill files (`skills/ilo/ilo-*.md`),
// which are bundled via `include_str!` in src/main.rs and served by
// `ilo skill list/get/path/show`. SKILL.md no longer needs the compact
// spec injected on every build, so the marker-based mirror is gone.
}

/// Compress the spec into one line per `## Section`.
/// - Table headers + separator rows are dropped; data rows become `key=value` tokens.
/// - Bullet points are joined with `;`.
/// - `### Subsection` becomes an inline `[Subsection]` label.
/// - Code fence markers, blank lines, and `---` dividers are stripped.
/// - Everything within a section is joined with ` ` and emitted as `SECTION: content`.
fn compact_spec(src: &str) -> String {
// Split into (heading, content_lines) sections.
// The preamble (before the first `## heading`) is labelled INTRO so every section
// in the compact output has a uniform `LABEL: content` shape.
let mut sections: Vec<(String, Vec<String>)> = vec![("INTRO".into(), vec![])];

for line in src.lines() {
let trimmed = line.trim();
if let Some(h) = trimmed.strip_prefix("## ") {
sections.push((h.to_uppercase(), vec![]));
} else {
sections
.last_mut()
.expect("sections always non-empty")
.1
.push(trimmed.to_string());
}
}

let mut out = String::new();

for (heading, raw_lines) in sections {
let tokens = compress_section(&raw_lines);
if tokens.is_empty() {
continue;
}
out.push_str(&heading);
out.push_str(": ");
out.push_str(&tokens);
out.push('\n');
}

out
}

/// Compress a section's lines into a single string.
fn compress_section(lines: &[String]) -> String {
#[derive(PartialEq)]
enum TableState {
NotInTable,
InHeader, // first data row seen, separator not yet seen
InData, // past the separator row — real data rows
}

let mut items: Vec<String> = Vec::new();
let mut table_state = TableState::NotInTable;

for line in lines {
let t = line.as_str();

// Blank lines, horizontal rules, code-fence markers, and the document H1 title
// are noise. The H1 is the file's title in SPEC.md ("# ilo Language Spec") and
// is redundant in the compact output, where the description paragraph already
// self-identifies the language.
if t.is_empty() || t == "---" || t.starts_with("```") || t.starts_with("# ") {
continue;
}

if let Some(sub) = t.strip_prefix("### ") {
// Subsection heading inline.
table_state = TableState::NotInTable;
items.push(format!("[{sub}]"));
continue;
}

if t.starts_with('|') {
let is_sep = t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '));
if is_sep {
// Separator row: marks end of header, start of data.
table_state = TableState::InData;
continue;
}
match table_state {
TableState::NotInTable => {
// First row of a new table = the header row — skip it.
table_state = TableState::InHeader;
}
TableState::InHeader => {
// Still before the separator (unusual: two header rows?) — skip.
}
TableState::InData => {
// Real data row: extract cells.
// Handle escaped pipes `\|` inside cells by substituting a
// placeholder before splitting, then restoring after.
const PIPE_PLACEHOLDER: &str = "\u{0001}";
let escaped = t.replace("\\|", PIPE_PLACEHOLDER);
let cells: Vec<String> = escaped
.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.replace(PIPE_PLACEHOLDER, "|"))
.collect();
items.push(collapse_ws(&cells.join("=")));
}
}
continue;
}

// Non-table line — reset table state.
table_state = TableState::NotInTable;

if let Some(bullet) = t.strip_prefix("- ") {
items.push(collapse_ws(bullet));
} else {
items.push(collapse_ws(t));
}
}

items.join(" ")
}

/// Collapse runs of internal whitespace to a single space. Code-fenced blocks in SPEC.md
/// use alignment padding (e.g. `mmap -- empty map`) so dashes line up
/// for human readers; that alignment wastes tokens in the compact spec without conveying
/// information to the LLM consumer.
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
// Rebuild when the index changes so the include_str! embed stays current.
println!("cargo:rerun-if-changed=ai.txt");
}
28 changes: 5 additions & 23 deletions skills/ilo/ilo-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,11 @@ Every skill subcommand accepts `--json` (short alias `-j`, ILO-442). `ilo skill
## Running

```
ilo file.ilo auto-pick main
ilo file.ilo func a b call named fn
ilo 'f x:n>n;+x 1' 5 inline source
ilo --jit file.ilo --bench main JIT + bench
ilo file.ilo --bench main --json bench output as NDJSON
ilo file.ilo --bench main --json --silent suppress program stdout
ilo file.@ auto-pick main
ilo file.@ func a b call named fn
ilo 'f x:n>n;+x 1' 5 inline source
ilo --jit file.@ --bench main JIT + bench
ilo 'f x:n>n;+x 1' 5 inline source
ilo --jit file.@ --bench main JIT + bench
ilo file.@ --bench main --json bench NDJSON (--silent to drop program stdout)
```

`--silent` / `-s` mutes program-level `prnt` (and `prnv` / `jprn` / JIT prints) for the run. Paired with `--bench --json` it gives agent harnesses (e.g. persona cost rollup) a clean JSON stream on stdout instead of 10k+ lines of benchmarked output. Stderr is never silenced.
Expand All @@ -50,7 +45,7 @@ First positional dispatches to a fn when it has ident shape. Otherwise (paths, n

AOT-compiled binaries (`ilo compile`) follow the same contract byte-for-byte.

**Auto-echo suppression.** An entry-fn ending in a bare `prnt` call, a tail loop with no early return, or — when the body has an unconditional top-level `prnt` a wrapped string-literal tail `~"text"` / `^"text"` (status sentinel) does NOT auto-echo its return value. The collision-avoidance rules let you write `m>R t t;prnt "report";~"ok"` and get clean `report\n` on stdout instead of `report\nok\n`. A no-prnt function returning `~"ok"` (e.g. `addtask`) still emits `ok` — the wrapped literal IS the output. `~v` where `v` is a binding or call always auto-echoes; only string LITERAL sentinels are dropped.
**Auto-echo suppression.** After an unconditional `prnt` (or a bare-`prnt`/tail-loop ending), a string-LITERAL sentinel tail `~"ok"`/`^"err"` is not echoed: `m>R t t;prnt "report";~"ok"` prints just `report`. Without a `prnt`, the literal IS the output; `~v` for bindings/calls always echoes.

## Testing

Expand Down Expand Up @@ -101,20 +96,7 @@ main>_

## Constrained decoding

`ilo constrain` exports the parser grammar so an external LLM harness can apply logit masks at generation time, making syntactically invalid ilo unreachable before `ilo check` ever runs.

```
ilo constrain grammar state machine as JSON (default)
ilo constrain --mode masks per-state binary masks over token vocabulary
ilo constrain --mode completions --file foo.ilo --line 3 --col 12 valid tokens at cursor
```

Three JSON shapes:
- `--mode states`: `{"schemaVersion":1,"states":{"TopLevel":{"transitions":{...}},...},"initial":"TopLevel","accept":["End"]}`. 29 parse states.
- `--mode masks`: `{"schemaVersion":1,"vocabulary":["type","tool",...],"masks":{"TopLevel":[1,1,1,...0],...}}`. 59 token categories.
- `--mode completions`: `{"schemaVersion":1,"state":"FnHeader","validTokens":["<","ident",">",...]}`.

The state machine is static (grammar shape, not parser bookkeeping). Prevents lex/parse errors at generation; type errors and runtime errors still caught by `ilo check` and `ilo run`.
`ilo constrain` exports the parser grammar for generation-time logit masking — syntactically invalid ilo becomes unreachable before `ilo check` runs. Modes: default `states` (29-state machine JSON), `--mode masks` (per-state binary masks, 59 token categories), `--mode completions --file f --line N --col M` (valid tokens at cursor). Static grammar shape only; type/runtime errors still caught downstream.

## Branching

Expand Down
Loading
Loading