Changelog

The version this site currently advertises is v0.20.2. Installers and /dl/latest follow that tag. The notes below are the language changelog, without unpublished work.

What 0.x means

Ecko is still in 0.x. A minor bump can rename a module or change a diagnostic. The 0.20.0 notes include one of those: std.string is now std.str, because the old name displaced the string() converter. An unmigrated import fails immediately as No module called 'std.string'.

There is no separate compatibility promise beyond what a given tag's notes say. Read the notes below before moving a project across a minor version.

Platforms

Prebuilt binaries ship for Linux x86-64 and arm64, macOS Apple Silicon, and Windows x86-64. Intel Macs are not supported: an Apple Silicon binary cannot run on one, and there is no x86-64 macOS build to fall back to.

0.20.2

Fixed

  • A port race in the test suite. free_port() bound port 0, read the number, dropped the listener, then handed it back for a server to bind later. Cargo runs test binaries as parallel processes, so another one could take that port inside the window. Two HTTP tests flaked on it, and one failed a release gate. The helper had been copy-pasted into 17 test files; all now allocate from a pid-partitioned range and verify each candidate binds. No effect on the shipped binary - test infrastructure only. Released so the tag and the tree agree.

0.20.1

Changed

  • shadows-builtin no longer fires on an exported function. A package that exports get, set, push or find is naming its public API for its domain, not taking a builtin's name by accident, and renaming those to silence a warning would make the package worse to use. Every shadows-builtin finding across all 31 official packages was this shape - and an earlier analyzer change on this same rule once made four shipped packages unreleasable without a line of their code changing. The rule still fires on a private declaration, a let, or a parameter, which is the case it was written for: a helper that quietly displaced a builtin the same file goes on to call.

0.20.0

Breaking

  • std.string is now std.str. A module binds the last segment of its path, so std.string bound the name string and displaced the string() converter for the rest of the file. Every conversion then failed with Can't call module, a runtime error a long way from the import that caused it. ecko check warned, and the documented fix was import std.string as str - which is now simply the name.
    import std.str
    string(42)              # 42        - the converter still works
    str.title("a b")        # "A B"     - and so does the module
    string was the only one of the forty std modules that collided with a builtin, so this is a one-off rather than a naming convention. tests/module_name_collisions.rs now fails the build if another appears. The converter was left alone deliberately: int, float, bool, decimal and string are one family, and renaming one of them to str() would leave four full words and an abbreviation with no principle behind the split. Migration: import std.string -> import std.str, and string.foo(x) -> str.foo(x). An unmigrated import fails immediately and by name (No module called 'std.string'), so nothing fails silently. Two frozen compat/ programs were updated for this and the freeze manifest regenerated. Their golden output is unchanged - only the import moved.

Added

  • ecko check catches a regex written as a plain string (rule regex-interpolation). re.test("^[A-Z]{3}$", s) reads as a regex and is not one: {3} is an interpolation hole, so the engine receives ^[A-Z]3$ and returns false with no error at all. That silent wrong answer is now a warning naming the fix, the r"..." form. Only an integer hole is flagged. "^{prefix}$" is a legitimate dynamic pattern, and a rule that fired on it would be switched off. All eight std.re functions are covered, including through an aliased import.

0.19.0

Fixed

  • Runtime errors pointed at the wrong source line. Every error raised after a fused arithmetic or comparison op reported a location two source positions earlier per fusion, so a divide-by-zero on line 7 was reported on line 3, with the caret under unrelated code. The compiler's peepholes fuse by deleting the operand ops they just emitted; they shortened the bytecode without shortening the parallel span table, and every span after the first fusion then described a different instruction. Chunk::truncate now moves both together. This affected every release that shipped the fusion peepholes, and any program containing x * 2, i < n or similar before the failing line.

Added

  • A bytecode verifier runs at compile time. It checks that every index an op carries is in range for the table it addresses, that every jump lands on a real instruction, that the chunk ends in Return, and that spans stay parallel to code. A compiler bug now surfaces as a refused program naming the offending op, rather than as a panic deep in the interpreter or a wrong answer. It costs about 0.3% of startup and found the span defect above.

Changed

  • Value is 16 bytes instead of 32. Its width is paid on every push, pop, clone and drop the interpreter performs, so halving it moves everything that touches collections or strings. Four variants had to move behind an Arc to get there - Struct, NativeFunction, Decimal and Range - because a single payload wider than 8 bytes forces the whole enum to 24. 16 is the floor, not a waypoint: Int is a full i64 and leaves no niche for the discriminant, so the tag needs its own word. Decimal arithmetic is the one thing that got slower: every decimal operation now allocates, which costs about 40% on a tight m-literal loop. That was a deliberate trade - money arithmetic pays so that every other operation in the language benefits.
  • The VM is about 18% cheaper per unit of work overall, cumulative across this release's VM work. Geometric mean across the seven benchmark/cross compute workloads: instructions retired down 19.6%, cycles down 17.6%. Wall-clock geometric mean against PHP went from 2.96x to 2.45x. | workload | instructions | cycles | wall | |---|---|---|---| | primes | -29.5% | -27.1% | 259ms -> 192ms | | collatz | -26.6% | -19.8% | 1869ms -> 1491ms | | list_map_sum | -26.2% | -26.6% | 599ms -> 441ms | | fib | -22.3% | -15.5% | 777ms -> 653ms | | string_join | -16.2% | -21.6% | 128ms -> 100ms | | dict_ops | -9.8% | -11.6% | 194ms -> 180ms | | loop_sum | -3.1% | +2.8% | 324ms -> 327ms | list_map_sum now runs faster than CPython (441ms against 488ms). Nothing about the language changed: same results, same errors, same bytecode. Besides Value's width, two things were making the interpreter pay for code it was not running. Five hot helpers were emitted as out-of-line calls because a cold fallback shared the function and set its size - the largest being Vm::pop, whose unwrap_or(Value::Null) built a default eagerly and then called drop glue to discard it on every pop. And the throw! macro was inlined at 107 sites in the dispatch loop, giving it a 12.5 KB stack frame that pushed ip out of a register on every instruction, on the path where no error is raised at all.

0.18.0

Breaking

  • The anthropic provider was removed. Anthropic models are reachable through the new openrouter provider, which speaks the same wire format Ecko already uses for OpenAI. Migration:
    # before
    ECKO_AI_PROVIDER=anthropic  ECKO_AI_MODEL=claude-haiku-4-5-20251001
    # after
    ECKO_AI_PROVIDER=openrouter ECKO_AI_MODEL=anthropic/claude-haiku-4.5
    ECKO_AI_PROVIDER=anthropic now fails with that instruction rather than being silently ignored. The direct integration was a second wire protocol for one vendor - its own message shape, tool serialization, streaming frames, image encoding and error body - and every cross-cutting fix had to be written three times.

Added

  • openrouter provider: one key, hundreds of models across vendors, with vendor/model ids (anthropic/claude-haiku-4.5, google/gemini-2.5-pro). It shares the OpenAI adapter, so it cost a base URL and a name rather than a second protocol.

Fixed

  • release-check.sh did not deny warnings in test code, so it reported ALL GREEN while CI failed. cargo build never compiles tests, so a test that loses its #[test] attribute becomes unreachable dead code that only CI's global RUSTFLAGS: -D warnings catches. The gate now runs the test suite with the same flag, matching CI.
  • A broken provider configuration ran as mock mode instead of failing. A typo in ECKO_AI_PROVIDER - or anthropic after its removal - produced cheerful [AI Mock] output as though the run had succeeded offline, because the "is a provider configured" check reports false both for "nothing set" (mock mode, intended) and for "the environment names something unusable". A configuration error is now an error; a genuinely absent provider is still mock mode.

Fixed

  • ai[T] on a payload-free enum returned a map against a live provider. The schema for type Urgency = Low | High asks for a bare JSON string, but models routinely answer with the schema filled in ({"type": "High"}, {"enum": ["High"], ...}). Those shapes fell through to a raw map, so any match on the result failed live with "Non-exhaustive match" while mock mode returned a proper variant and the offline test passed. Those shapes now coerce to the variant, and matching is case-insensitive.
  • A typed ai call whose response matched no variant returned a raw map instead of retrying. It now yields Null, so the retry loop re-prompts - the contract Int/Float/Bool/Bytes already kept.
  • A variant is now inferred when the __type__ discriminator is missing and exactly one variant has all the object's keys, so a tool returning {sources, excerpt} for a single-variant type is accepted rather than rejected.
  • ai[String] made four provider calls instead of one. A Value::String was treated as a failed coercion for every target including String, so every call retried to the limit: four times the cost and latency, silently. In mock mode the retry scaffolding ("Previous attempt failed: ...") ended up inside the returned value.
  • Mock mode echoed Ecko's internal prompt scaffolding. ai[String] in mock returned the schema instruction along with the prompt; it now echoes only what the caller wrote.
  • A scalar ai[T] now accepts the schema echoed back. Models answer a scalar schema with the schema filled in ({"type": true} for {"type": "boolean"}) about as often as with the bare value. Measured against gpt-4o-mini, ai[Bool] "Is the sky blue?" produced a Bool in 3 runs out of 8 before this and 8 out of 8 after. Top level and scalars only: a one-entry object is the answer for Json, Map, List and record targets.
  • ai[Bool] accepts the words models actually use - yes/no/y/n, any case, with a trailing period tolerated - and a bare JSON 1/0, which was inconsistent with the "1"/"0" strings it already took.
  • ai[T] ... on img ignored its type against a live provider. The multimodal path never appended the schema instruction, so a typed vision call returned prose and coerced to a String while mock mode returned a schema-valid value.
  • Multimodal calls emitted no trace record at all, so a vision call was invisible to --trace and to any spend audit built on it.
  • List, Option and Map targets leaked a raw map instead of retrying. A weaker model answers a composite schema with the schema itself (gpt-3.5-turbo returns {"type":"array","items":{...}} for List<Int>), which passed straight through as a map the caller could not use and the retry gate never saw. They now yield Null so the retry re-prompts.
  • Option<T> and Map<K, V> never coerced through their parameters. ai[Option<Int>] answering "7" produced a String, and Map<String, Int> kept string values. Both now coerce like a record's fields do.
  • ai[Option<T>] of a scalar now uses the nullable-type schema ({"type": ["integer", "null"]}) instead of anyOf. Offering null as a co-equal branch invited the model to take it: llama3.2 answered null to "How many moons does Earth have?" 4 times out of 4 under anyOf and 0 out of 4 under the nullable form. gpt-3.5-turbo failed the same way. A composite inner type keeps anyOf, having no single type keyword to extend.
  • A scalar ai[T] now accepts a named answer key. Models echo the schema back beside the answer, not only instead of it: llama3.2 replies {"type": "boolean", "value": false}. value, result, answer and output are honoured, which took ai[Bool] on llama3.2 from 6 of 10 runs to 10 of 10.
  • session failed outright on some models. An empty tool list was sent as "tools": [] rather than omitted, and gpt-3.5-turbo answers that with a hallucinated tool call, which the session path rejected with "AI session provider returned an unexpected tool call". All three providers now omit the key when there are no tools.

Added

  • --trace now reports estimated cost per call, as $0.000003 in the stderr line and cost_usd in the JSONL record. It reported tokens but never money, which the Manifesto had promised. Omitted rather than guessed in mock mode and for models absent from the price table.

0.17.0

Added

  • ecko scaffold <template> <path> writes a small running program of a known shape: agent, web, sse, cli or package. --list shows what is available (a bare ecko scaffold does the same), --name overrides the project name, --force overwrites, and --ref picks a branch or tag.
  • Templates live in the public ecko-lang/templates repo rather than in the binary, so they can be fixed and added without an ecko release. The first ecko scaffold downloads and caches them; every run after that is offline. This is the only command that needs the network to get started. ecko scaffold --update refetches and reports what changed.
  • Each template declares the oldest ecko it works on, so a binary too old to use one refuses by name and version rather than failing to parse it, and --list marks it rather than hiding it.
  • ECKO_TEMPLATES_REPO points at another repo, an archive URL, or a local directory; ECKO_TEMPLATES_DIR moves the cache.
  • Scaffolding refuses rather than overwrites: every destination is computed and checked before anything is written, so a collision names the files and leaves the tree untouched.

0.16.0

Added

  • ecko lsp now serves find usages, document highlighting, quick fixes, document formatting and semantic tokens. Find Usages and occurrence highlighting resolve through the real scope stack and are scoped to the open file - a shadowed name resolves to its own declaration, not the outer one; cross-file search is left to a follow-up. A lightbulb offers a fix for four diagnostics: a misspelled name corrected to the resolver's did-you-mean, an unused import removed, an unused binding prefixed with _, and the missing arms of a non-exhaustive match filled in - unused-function deliberately gets no fix, since deleting a function is not a safe blind repair. Formatting matches ecko fmt exactly and applies to the whole document; a selection cannot be reformatted, because fmt::format_source parses a whole program rather than a fragment. Semantic tokens colour the document from the compiler's own analysis rather than the TextMate grammar: std modules and types are resolved and coloured precisely, and any other capitalised name is coloured as a union variant on the strength of Ecko's naming convention, not because it was checked against a declared variant. executeCommandProvider also gains ecko.fix.migrate, giving ecko fix --migrate its first editor surface.

Changed

  • ecko check and ecko lint now report a column as well as a line (file.ecko:12:7: rule: message), matching how parse errors already print.

0.15.0

Fixed

  • Language server completion lists had drifted from the runtime. has, slice, and a dozen other globals were missing from completion, as were whole std modules (archive, proc, watch, …) and members added since the lists were first copied in. Completion after sql. now includes transaction; after archive., zip_create.

0.14.0

Added

  • A browser playground at ecko.sh/play. The kernel compiles to a WebAssembly module (crates/ecko-wasm, no wasm-bindgen, no npm, no generated JS shim - about 1 MB) that runs entirely in the page: no install, no API key and no backend, because mock mode is deterministic and offline by design. Edit, run, and format the real language; a program shares as a link. Several things that are real language features elsewhere are unavailable in the browser and say so with a normal Ecko error rather than crashing the page: async fn/await, streaming ai (-> stream), the channel primitives (channel/send/recv/close), and sleep - the browser build has no threads. retry still works: a failed attempt just skips the wait before the next one instead of erroring, since the only thing lost is the delay itself. pmap still runs, but sequentially rather than in parallel, which produces the same result since pmap's contract is share-nothing already. Contract-checked recursion (@requires/@ensures, and anything else that runs on the AST evaluator) is capped at 256 levels in the browser; plain recursion is unaffected and keeps the usual 2000-level limit, because it runs on the bytecode VM's heap-backed frame stack rather than the browser's own call stack.

Changed

  • On wasm32 only: run_on_big_stack runs inline rather than spawning a 256 MiB-stack thread (wasm32 has no threads), ai call timing uses a monotonic WasmInstant shim (Instant::now() panics on wasm32), and print/print_no_newline capture to a drainable buffer instead of stdout (wasm32 has none). The wasm32 build's own call stack is also raised to 4 MiB (.cargo/config.toml) to give the AST evaluator's recursion room to run. Native behaviour is unchanged.

0.13.0

Added

  • is_map, is_list, is_string, is_number, and has(map, key). Writing JSON-shaped code meant type_of(x) == "map" in every condition, and asking whether a map had a key meant contains(keys(m), k), which builds the whole key list to answer one question. has also distinguishes a key holding null from a key that is absent, which the old spelling did too but did not look like it did.
  • slice works on strings. It was list-only, so slicing text meant dropping to s[a..b] for no reason a reader could see. Character indices, matching len and the rest of the string surface, and an out-of-range slice clamps rather than erroring, as the list arm has always done.

Fixed

  • A comment no longer stops the formatter breaking a construct. expr_fit renders a flat form to measure it, and rendering drains comments out of a queue that was never refilled, so a flat form containing a comment could not be re-rendered broken. One comment inside a branch made the whole construct unbreakable at any width: the card's repro measured 122 characters with the comment and 36 without. Worse than the width, output stopped being a function of the input alone. Which form got tried first decided what came out, so two builds could format one file differently and ecko fmt --check would fail on text the other build had produced. That happened in practice while writing the mcp package. The queue is now snapshotted before the flat render and rewound before the broken one, and restored to the flat form's state if breaking declines, so comments are neither lost nor emitted twice. This reflows some existing files. One of 101 corpus examples changed, and two stdlib packages (cookies, smtp-client) need reformatting. Anyone running ecko fmt --check in CI should expect a diff on upgrade; ecko fmt resolves it.

0.12.0

Added

  • std.proc can drive a long-lived child. proc.write, proc.read_line and proc.close_stdin. Children were spawned with stdin closed, so run - which waits for the child to exit - was the only thing you could do with one. That cannot drive a server, which is the shape a language server or an MCP server over stdio takes: read a request on stdin, answer on stdout, repeat.
    p = proc.spawn("mcp-server", [])
    proc.write(p, request + "\n")
    reply = proc.read_line(p, 5000)
    proc.close_stdin(p)
    spawn opens stdin; run deliberately does not, because a child reading a pipe nobody writes to would block forever. read_line returns null for both a timeout and end of output, so a loop reading until null terminates either way, and it strips the terminator including a CRLF \r. A final line with no newline is still returned rather than dropped.

0.11.0

Added

  • ai ... using accepts a computed tool set. It only ever took literal identifiers naming @tool-annotated functions, so the tools had to be known when the file was written. A tool discovered at runtime could not be offered to a model at all - which ruled out MCP servers, plugin registries and config-driven tool sets. When the using list is not written out as bare identifiers the expression is evaluated, and each element may be a tool spec map:
    answer = ai "what changed?" using mcp.as_tools(session)
    
    { name: "search", description: "Search the docs", params: ["query"], call: fn(args) ... }
    name, description and call are required; params defaults to []. A value created while the program runs cannot carry a parse-time annotation and has no identifier to be named after, so it has to carry its own name and description or it cannot be described to a model. Additive: using [weather, docs] is unchanged, and the two forms mix in one list. One behaviour change. ai "..." using [|x| x] used to fail with "needs a list of function names", because the list had to be identifiers. The list is now evaluated, so the lambda reaches the tool builder and is refused there for the reason that actually matters - nothing tells the model what it does - with an error pointing at the spec map.
  • io.read_line() and io.read_all() - Ecko can read standard input. It could not before: std.io exported only print, and std.term's read_key is a raw-mode TUI primitive, so cat data.txt | ecko script.ecko was impossible. Ecko could write a pipeline stage and not read one.
    import std.io
    
    loop {
        line = io.read_line()
        if is_null(line) { break }
        print(line)
    }
    End of input is null, not "" - an empty line is a real line, so the null is what a loop terminates on. A trailing \r is stripped with the \n so a CRLF file compares equal to what you expect, and a final line with no terminator is still returned. Ungated, like std.cli reading argv: a pipeline stage is handed its input by the parent the same way it is handed its arguments, so there is no authority for a capability to withhold. Reading a file is still std.fs and still needs fs:read. There is deliberately no io.lines() - it is expressible over read_line, and ecko-std is for what cannot be.

Changed

  • x = x OP y between two locals is one instruction. It compiled to GetLocal(y) + AddLocal(x): a Value clone, a 32-byte push, a 32-byte pop, and drop glue - to add two i64s. The new BinLocalLocalStore reads both slots in place. This is the variable-variable counterpart of the existing BinLocalConstStore; the expression form (BinLocalLocal) was already there, so only the assignment shape was missing.
  • Storing an integer into a slot that already holds one mutates it in place. slot = Value::Int(v) dropped the old value first, and drop glue for Value is a branch over every variant. On a pure-integer loop that was 13% of runtime, spent on values that own nothing. IncLocal and the fused store now overwrite the payload.
  • A bytecode instruction is 16 bytes, down from 32. Op's index fields were usize; a chunk cannot hold more than u32::MAX instructions (that is 64 GB of code) so the top half was never used, and every fetch copied it. Size is set by the widest variant, so this only pays off done wholesale - ForNext, ForRange, MatchPat and CallNativeCached each carried three of them. The conversions panic rather than truncate: 157 sites use try_into().unwrap(), and the single remaining as cast widens a u8. A silently truncated jump target would be a miscompile, which is not a thing to leave to a code review. Measured on benchmark/cross against 0.10.1, instructions retired (stable run to run, unlike wall time): | workload | instructions | wall | |---|---|---| | loop_sum | −41% (11.57G → 6.79G) | 707 → 349 ms | | primes | −4.9% | 273 → 245 ms | | collatz | −4.4% | unchanged | | fib | −2.6% | 791 → 741 ms | | list_map_sum, dict_ops, string_join | ~0% | unchanged | The fusion accounts for the loop_sum result; the narrower instruction is a broad 3-6% on everything that is dispatch-bound, and nothing on the workloads dominated by allocation. IPC on loop_sum rose from 3.28 to 4.31. Found by profiling rather than by reading the compiler: perf put 24% of loop_sum in drop_glue::<Value> and 7% in Value::clone on a loop that allocates nothing, and the instruction count (4.09G against PHP's 0.61G on primes) at near-identical IPC showed the gap was work done, not work stalled.

Fixed

  • A failing HTTP handler now says why in the server log. http.serve correctly refuses to put internal error text in a 5xx response body - and then dropped it on the floor, logging only handler returned status 500. The handler's actual error was discarded, so a 500 in production carried no message, no kind and no path anywhere an operator could reach:
    # before
    ecko http: 500 internal error: handler returned status 500
    # after
    ecko http: 500 internal error: handler failed (500): Runtime error: connection refused
    The client still receives exactly the generic Internal Server Error body it did before - that half was never the problem.
  • A thrown payload now survives a higher-order builtin. error(v) throws a first-class value and catch (e) binds it - but only if the throw did not cross a native callback boundary. A throw from inside a map, filter, pmap, reduce or sort_by callback was flattened to its message string, so the dispatch idiom the spec documents silently stopped working:
    fn boom(x) = error({ kind: "not_found", id: x })
    
    try { map(ids, |id| boom(id)) } catch (e) {
        get(e, "kind")    # was null; now "not_found"
    }
    Natives have an Err(String) ABI, and the payload rides a side channel across it (fail_with/take_pending_fail). Three bridges rendered the error without re-parking the payload - the compiled-closure fast path, the AST/contract path, and pmap, whose worker threads cannot see the main thread's slot at all. All three now carry it, and pmap hands it across the thread boundary explicitly. Structured capability denials were the sharpest case: a package's denial is a { kind: "capability", capability, package } map, and it arrived as bare prose whenever the gated call sat inside a pmap, so e.capability read null. Enforcement itself was never affected - only the shape of the error.
  • The package zip-bomb cap now counts bytes, not claims. ecko get / ecko install summed each entry's declared uncompressed size against ECKO_PKG_MAX_UNPACKED (200 MiB by default). That field is attacker-supplied metadata: a 100 KB archive declaring 1 byte per entry expanded to 100 MB with the cap never firing, because nothing compared the claim to the bytes actually decompressed. Extraction is now bounded as it writes and aborts the moment an entry runs past the budget, removing the partial file. The declared size is kept only as a cheap pre-check that rejects an honestly-labelled oversized package before anything touches the disk. ecko build bundles read their embedded payload the same way, and also no longer size an allocation from that field.

0.10.1

Fixed

  • ecko check no longer fails on warnings. It exits non-zero on error-severity findings only - which is what docs/cli/check.md has always documented ("Warnings alone do not change the exit status"). The implementation treated every finding as fatal, so a warning gated any build using ecko check as a CI step. This was not theoretical: shadows-builtin fires on a package that exports get, set or push - its own message calls the shadowing deliberate - and there is no way to silence one finding. cache, heap, html and xml passed the gate at 0.9.5 and failed it at 0.10.0 with no change to their code, leaving them unreleasable short of renaming their public API. Warnings are still printed, and a run ending with warnings and no errors now says so, so a green exit beside printed findings is not mistaken for a clean board. ecko check --strict restores "any finding fails" for projects that want it. The pre-run gate on ecko file.ecko is unchanged: it has always refused to start only on error-severity findings.

Added

  • ecko fix --migrate rewrites deprecated syntax. Deprecated forms already warned (deprecated-syntax) and were canonicalised by ecko fmt; now there is a migrator that touches only what is deprecated, so the diff is the migration rather than a whole-file reflow:
    ecko fix --migrate --list          # what can be migrated, and what runs by default
    ecko fix --migrate --check src/    # report, write nothing (exit 2 if any file needs it)
    ecko fix --migrate src/            # rewrite in place
    ecko fix --migrate --only=const src/
    Registered today: const -> let, and |params| body -> fn(params) body. Adding the next one is one entry in the registry. One-time codemods are now opt-in. --only=bytes and --only=exports belong to specific past breaking releases and are not in the default set. A deprecation rewrite is a canonicalisation, so canonical source is a fixed point; a codemod is a reinterpretation and cannot tell already-migrated code from code that never needed it - run the bytes codemod twice and a correct base64_decode, which wants bytes, becomes base64_decode_text, which does not. The older --migrate-bytes and --migrate-exports spellings still work and now select their codemod from the same registry. A bare ecko fix <file> is unchanged - it is still the AI error fixer.
  • A removal gate for deprecated syntax. crates/ecko-core/src/edition.rs carries ACCEPT_DEPRECATED_SYNTAX and REMOVED_IN_MAJOR; each deprecated parse path is gated on the first, so dropping the old grammar at the flagged major is one line plus a compat/ rebless rather than a hunt through the parser. Once flipped, the old form is a parse error naming its replacement and the migrator. A test fails the build if Ecko reaches that major with the switch still on, so the removal cannot be quietly missed.
  • Path-scoped filesystem capabilities. fs:read and fs:write may now name the subtree they are confined to, so a grant can say "may write its own log directory" instead of "may write anywhere":
    { "dependencies": {
        "logger": { "path": "github.com/acme/logger", "version": "v1.2.0",
                    "grant": ["fs:write:./logs"] } } }
    A bare fs:write still means anywhere, so nothing that worked before changes. A relative scope resolves against the directory holding the ecko.json that wrote it, not the working directory. Requested paths are made absolute and symlink-resolved first, so neither ../ nor a symlink inside an allowed directory escapes; scopes compare by path component, so /data does not cover /data-backup; and a glob is checked against its literal prefix. A grant is clamped to what the granter holds, so a package with its own dependencies writes the plain "grant": ["fs:read"] and passes on whatever the root program allowed. Authority still only narrows going down the tree. net, env and exec take no scope, and net:example.com is now an error rather than a grant that quietly did nothing. This closes the gap where physical actuation was indistinguishable from ordinary file access: writing /sys/class/gpio/export to drive a relay needed exactly the same fs:write as rotating a log file. See docs/design/path-scoped-capabilities.md.
  • A capability denial caused by a scope now carries a path field alongside capability and package, so it says which path was refused.
  • sql.open now checks a file database's path against the scope too; sql.open(":memory:") stays pure and ungated.
  • std.watch - filesystem events. inotify on Linux, FSEvents on macOS and ReadDirectoryChangesW on Windows, behind one surface. Layer 2 by the syscall bar: the only way to do this over existing primitives is to poll fs.list_dir in a loop, which is slow, misses fast changes, and is the thing being avoided.
    w = watch.open("./inbox", { recursive: true })
    e = watch.next(w, 5000)     # { kind, path }, or null once the deadline passes
    watch.close(w)
    Gated on fs:read rather than a capability of its own - seeing what appears in a directory is reading it, and a package granted nothing must not be able to sidestep fs:read by watching instead of listing. w.events is a real channel, so a watch composes with recv, a spawned task or an SSE response rather than imposing its own loop shape. watch.next takes a deadline because a watch on a quiet directory must not be able to hang the program with no way out. Costs about 132 KB of binary.
  • std.signal - OS signal handlers. Catch SIGTERM and SIGINT so a process can finish what it started instead of dying wherever it happened to be. The case this exists for is a rolling deploy: the orchestrator sends SIGTERM and waits, and a process that cannot see it dies mid-write, holding a queue lease, with the run's cost unrecorded.
    s = signal.on()                  # default set: ["term", "int"]
    name = signal.next(s, 5000)      # "term", or null once the deadline passes
    signal.close(s)
    Layer 2 by the syscall bar - catching a signal is sigaction(2), and there is no way to approximate it over existing primitives. No new dependency: signal-hook-registry was already in the tree via tokio. Gated on exec. A disposition is process-global, so a package able to install a handler can swallow the operator's Ctrl-C or stop an orchestrator's SIGTERM from being seen - process control, the same authority os.exit needs. Names are lowercase without the SIG prefix (int, term, hup, quit, usr1, usr2); signal.names() reports what the platform delivers. Windows has no POSIX signals, so only int exists there and the rest are refused by name rather than accepted and never fired. SIGKILL is absent because nothing can catch it. std.http's graceful shutdown now subscribes through this module instead of installing its own handler. Its behaviour is unchanged - still SIGINT only, still force-exit on a second press - but the two can no longer race for the process-wide disposition, which they would have done the moment a program called signal.on(["int"]) while serving. That race is this card's own use case, so it had to be part of the change rather than a follow-up.
  • std.archive - zip and tar containers. Create, list and extract .zip, .tar and .tar.gz; std.zlib only did raw gzip/deflate.
    archive.zip_create("out.zip", ["src/", "README.md"])
    archive.zip_extract("out.zip", "dest/")
    archive.tar_create("out.tar.gz", ["src/"])   # gzip follows the filename
    Layer 2, and the reason is extraction safety rather than the formats - a zip is deflate plus a directory and a tar is 512-byte headers, both expressible over std.zlib. What is not worth reimplementing per package is path containment: an entry named ../../../.ssh/authorized_keys writes outside the destination unless something stops it, and the failure mode is arbitrary file write. .., absolute paths and drive prefixes are refused rather than sanitised, tar symlink and hard-link entries are skipped, and the running total of extracted bytes is capped (ECKO_ARCHIVE_MAX_UNPACKED, default 1 GiB). zip was already compiled in for ecko pack and ecko build, so only tar is new. Gated fs:read for the archive and its inputs, fs:write for what is written. zip_create/tar_create take a list of inputs, so their read check happens inside the native where the paths are known - a package scoped to one directory cannot archive files outside it. Entry names are relative, rooted at the input's last component, and sorted - so the same tree produces the same archive, and an absolute input does not produce an archive that extraction would then refuse.
  • std.proc - child processes with a timeout that reaps the whole tree. os.exec runs to completion with no deadline, and killing a process kills only that process, so a timeout around sh -c "ffmpeg ..." kills the shell and leaves ffmpeg running. Measured on a dev box before the change: killing one shell orphaned four sleep processes.
    r = proc.run("ffmpeg", ["-i", "in.mp4", "out.mp4"], { timeout_ms: 5000 })
    r.timed_out                  # the deadline passed; the tree was reaped
    
    p = proc.spawn("server", [])
    proc.kill(p)                 # the group, not just the child
    A child is started in its own process group (process_group(0)) and the deadline signals the group with SIGTERM, then SIGKILL after a short grace - a process ignoring TERM is the case a timeout exists for. timed_out comes back on the result rather than as an error, so a caller can still read the output that arrived first. stdout and stderr are drained on background threads from the moment of spawn rather than read after waiting: a child that fills the 64 KiB pipe buffer blocks on write, so reading late would hang instead of timing out. Layer 2 by the syscall bar (setpgid/killpg), gated on exec. Unix only for the tree guarantee - Windows has no process groups in this sense, so kill there reaps the direct child and grandchildren survive.

Changed

  • Value is 32 bytes, down from 48. Every push, pop, clone and drop in the VM moves this many bytes, against 8 for a CPython stack slot, so its width is the per-op cost of the whole language. NativeFunction was the widest variant and set the ceiling on its own: Arc<str> (16, a fat pointer) + usize (8) + bool (8 after padding) + Arc<dyn Fn> (16, another fat pointer) = 40 bytes of payload. Narrowing those to Arc<String>, u32 and a thin Arc<Box<dyn Fn>> brings the payload to 24 and Value to 32, with no change to any pattern that matches the variant. | | 48 bytes | 32 bytes | | |---|---|---|---| | collatz | 9.47B cycles, IPC 2.95 | 8.90B, IPC 3.30 | -6% cycles | | listmap | 0.35B cycles, IPC 1.67 | 0.28B, IPC 2.06 | -20% cycles | | string_join | 0.70B cycles, 1.5M cache misses | 0.56B, 0.8M | -20% cycles, -47% misses | Six of eleven bench-gate workloads improved and none regressed. count now beats CPython (1.035x -> 0.947x) and listmap went 1.217x -> 1.016x, which also puts it ahead of Node. Instructions retired went UP (collatz +4.9%, loop_sum +6.0%) while time went down, because the thin pointer costs one extra load per native call but the machine moves a third less data and stalls far less. Instruction count is the right metric for a change that removes work; it is the wrong one for a change that moves fewer bytes. Cycles and IPC capture both.
  • Native builtins take a slice of the VM stack instead of an owned Vec. Every call did stack.split_off(len - argc) to build a Vec<Value> for the native signature, and split_off always allocates. Measured on a loop calling abs(i): | | before | after | |---|---|---| | per native call | 358 instructions | 155 | | per native call | 19.4 ns | 9.5 ns | A profile of a native-heavy loop had 23% of it in malloc/free. The signature is now Fn(&mut [Value], &Env); arguments stay where they already were, on the stack, and the frame truncates afterwards - on the error path too, so an erroring native leaves the stack where the frame below expects it. This taxed all ~90 builtins. dict_ops (600k native calls) is -6.7% instructions, and strjoin went 1.5x -> 1.364x against CPython, recovering most of the regression accepted for Arc-backed strings. Workloads with no native calls in their hot loop are unchanged - loop_sum 0.0%, collatz +0.3%, measured by instructions retired, because wall clock showed +/-9% swings on those same programs. Natives that accept named arguments still build an owned Vec: they append a trailing options map, so the argument list has to grow and cannot be a borrow of the stack. That is the rare path.
  • Loop bodies no longer build a value in order to throw it away. A block always left exactly one value, synthesising Const(Null) when its last statement produced nothing, and every loop body then popped it. That pair was two of the seven ops in a while i < n { s = s + i; i = i + 1 } body, paid on every iteration. Statements are now compiled in statement position, where the value is never emitted: | benchmark | before | after | | |---|---|---|---| | loop_sum (30M iterations) | 1024ms | 698ms | -32% | | primes (trial division to 150k) | 367ms | 263ms | -29% | | collatz (1..300k) | 2814ms | 2134ms | -24% | Call-bound and allocation-bound workloads (fib, list_map_sum, string_join, dict_ops) are unchanged, as expected - they were never paying this.
  • Integer division now takes the arithmetic fast path. fused_arith had fast paths for +, -, * and % but not /, so every integer division fell through to the generic binop - including a Decimal/Float type check that can never apply to two Ints. Profiling collatz put that check alone at 3.7%. Division by zero and i64::MIN / -1 still reach the slow path that words their errors, because checked_div returns None for both.
  • x = x OP k for a constant k compiles to one op. It was BinLocalConst followed by SetLocal - computing into the stack and immediately popping back into the same slot. IncLocal already did this for +; this generalises it to -, *, / and %. Together with the statement-position change, collatz is 2814ms -> 1939ms (-31%) and its executed instruction count is down 13%. The two are independent and additive: measured separately, the division fast path is -6.7% of instructions and the fused store -6.4%. Neither changes any other benchmark's instruction count, which is how they were told apart from noise - wall-clock alone showed +/-2% swings on workloads whose instruction counts were byte-identical. A peephole over Const; Pop would have been wrong: both arms of an if jump to the discard point, so deleting it corrupts those jump targets. Not emitting the value is the correct form. No semantic change - a loop is still an expression evaluating to null, and a block in value position still yields its last expression.
  • String values are Arc-backed copy-on-write, like the collections already were. Value::String held a plain String, so cloning a string value copied the bytes - and a string constant pushed inside a loop was reallocated on every iteration. Cloning is now a refcount bump. | | before | after | | |---|---|---|---| | string_join, time | 240ms | 158ms | -34% | | string_join, instructions | 2.01B | 1.14B | -43% | | string_join, peak RSS | 170 MB | 109 MB | -36% | Arc::make_mut copies only when the buffer is shared, so acc = acc + x still appends in place and stays linear - the O(n) string builder is unaffected, verified across 200k/400k/800k iterations. One real cost: building a string now allocates the Arc box as well as the buffer, which shows on code that creates many short-lived strings and never clones them. dict_ops regressed 6.8% on that; routing map-key insertion through a move that unwraps the Arc when it is uniquely owned brought it back to 1.4%, which is the inherent cost of the second allocation. Value::str(s) is the constructor to use for new code; Value::String remains for pattern matching. The perf baseline was deliberately rebaselined for this. scripts/bench-gate.sh flagged strjoin (200k push of "s" + string(i), then join) going 1.256x -> 1.5x against CPython: 20% of that workload is in the allocator, and an Arc<String> costs one allocation more to build than a bare String. It creates 400k short-lived strings it never clones, so it pays the cost and collects none of the benefit. 13ms in absolute terms. Accepted because the same change takes the 2M map+join idiom from 237ms to 156ms and its peak RSS from 170 MB to 110 MB - the worst memory result in the cross-language suite. Eight of eleven gate workloads improved (intloop 0.465x -> 0.333x, collatz 0.865x -> 0.548x, primes 0.853x -> 0.636x). Arc<str> would have been one allocation instead of two, and would have put the O(n) string builder back to O(n^2).
  • Op::Concat does one allocation instead of three. It copied the left side with to_string(), copied the right side to append it, then wrapped the result. It now reuses the left buffer when it owns it and appends a string right-hand side by reference. The AST evaluator's concat path lost the same wasted copy.
  • A lazy range is no longer capped at 10M elements. The cap sat on range construction, but both lazy paths allocate nothing: for i in 0..30000000 and sum(0..30000000) peak at 9 MB, while only list(0..10000000) actually builds anything (466 MB). So a 30M-iteration for loop simply could not be written, and the workaround was hand-rolling a while with its own counter. The cap now lives where the allocation is, and says what to do instead:
    range 0..30000000 is too large to turn into a list (30000000 elements,
    max 10000000) - iterate it instead: a `for` loop or a range-aware builtin
    like `sum` or `count` streams it without building the list
    Range equality is now lazy too: comparing two 20M ranges used to materialise both sides, allocating ~2 GB to answer a question about four integers.

Fixed

  • proc.run could lose a fast child's output. try_wait reporting that a process exited says nothing about whether the thread draining its pipe has caught up, so a child that printed and exited immediately could be read as having printed nothing. It showed up as an intermittent CI failure rather than locally, which is the usual shape for this. The drain threads now signal EOF and the read waits for it, instead of the timeout path's previous sleep-and-hope. The wait is bounded at 250ms because a grandchild that inherited the pipe keeps the write end open after its parent exits, so EOF may never arrive - waiting forever would turn a finished process into a hang. The child's own exit closes its end, which is the case that matters and resolves in microseconds.
  • ecko fmt now breaks three constructs it used to leave at any width. Sweeping every stdlib package, 44 lines over the 100-column budget survived ecko fmt; that is now 15, of which 11 are single long string literals that cannot be reflowed at all. Three separate causes, each a place where a construct never offered its sub-expression to the width check:
    • expr_fit judged a flat form on its first line only, on the stated assumption that a multi-line flat form must contain a string literal. It can also span lines because a nested block rendered multi-line, and those later lines are ordinary code. An else if chain whose first arm broke could trail a 179-character line past the budget unnoticed. It now judges the widest line; an unbreakable literal is still left alone, because expr_broken declines and the flat form stands.
    • A lambda never broke. expr_broken had no Lambda arm, so fn(v) if .. { .. } else { .. } stayed on one line however wide - 17 such lines in stdlib/validate/validators.ecko alone.
    • A for header rendered its iterable flat. A list that breaks happily as xs = [..] (22 chars) stayed at 118 inside for x in [..]. No file in this repo changes shape: examples/ still passes fmt --check untouched, the compat corpus is byte-identical, and all 30 stdlib packages still pass their own tests.
  • fs.copy never required read access to its source. It was gated on fs:write alone, so a package granted only write access could read any file the process could reach by copying it somewhere writable. It now requires fs:read on the source as well as fs:write on the target. A package that copies files and holds only fs:write will need fs:read added to its grant. Found while enumerating which arguments carry paths for path scoping.
  • ecko fmt no longer flattens a comment after the last statement. Trailing comments went through a path that trimmed their text, so an indented block - a commented-out snippet, say - came back with its nesting gone, while the same lines between two statements kept theirs. The two paths now agree, which also makes a blank comment line render as # rather than # everywhere.

0.10.0

Added

  • set, drop, slice and sort_with builtins. set(coll, key, value) replaces an element, where insert splices a new one in and grows the list - reaching for insert when you meant set was silently changing the length. drop(list, n) is the complement of take, so take(n) + drop(n) rebuilds the input, and slice(list, start, end) is the half-open range with clamped bounds. sort_with(list, fn(a, b) -> Int) sorts by a comparator, for orders that cannot be expressed as a sort key.
  • int, round, floor and ceil accept a decimal. They return an Int exactly, without routing through a float. round takes halves away from zero rather than the half-to-even a statistics library would use, because money does not work that way. float(aDecimal) stays rejected, for the same reason decimal(aFloat) is.
  • fmt.fixed(value, places) renders a number to a set number of decimal places. A decimal is rounded in decimal, so fmt.fixed(decimal("0.07"), 2) is "0.07" and not a float artefact. Halves go away from zero on both the float and decimal paths, which Rust's own {:.n} formatting does not do.
  • A lambda body can be a map literal. fn(c) { value: c } returns a map rather than failing to parse: no statement can begin with name :, so the brace is unambiguous. The |x| { ... } form always worked; only fn(...) did not. fn(x) ({ ... }) is still valid.

Fixed

  • ecko check now warns when a declaration shadows a builtin. Defining fn split(m, n) takes over every split(...) call in the file, including the ones that meant the builtin - which failed as Can't compare string and int from a line that looked unrelated. The warning fires only when the name is actually called, matching the existing rule for shadowing imports, and points at core.split as the way through.
  • The head of a |> pipeline is wrapped like any other expression. It was the one expression in the language that never broke, however wide it got: the same call wrapped correctly on its own and stayed on a 177-character line once piped, because pipeline stages were always rendered flat.

Changed

  • http.serve now runs on hyper instead of tiny_http. Nothing an Ecko program can observe about a request or a response changed: the request map, the streaming { status, headers, stream: ch } shape, header sanitisation, Server: ecko, the body cap, handler panic isolation and the 500/503 mapping are all the same code as before, now shared behind one engine-neutral seam. What changed is the machine underneath, which unblocked three things at once and brought several protocol features with it. ECKO_HTTP_WORKERS keeps its meaning - it caps how many handler invocations run at once - though it now sizes a blocking pool rather than spawning that many accept threads. The release binary drops from 18,014,976 to 17,583,936 bytes, because the duplicate rustls 0.20.9, ring 0.16.20 and webpki 0.22.4 that only tiny_http needed leave the shipped dependency graph. Throughput on the non-keep-alive path is unchanged (18,808 vs 18,266 req/s single worker, measured against a build of the previous commit on the same machine).

Added

  • HTTP/2, negotiated over TLS via ALPN, with HTTP/1.1 fallback for clients that cannot speak it. Cleartext h2c is not offered.
  • Chunked request bodies, 100-continue, trailers and request pipelining, all of which the new server handles natively.
  • Response compression. Buffered responses over 1 KiB are gzip- or deflate-encoded when the client's Accept-Encoding asks for it, gzip preferred, adding Content-Encoding and Vary: Accept-Encoding. Already compressed content types are skipped, as is any response whose handler set its own Content-Encoding. Streaming responses are never compressed: buffering an SSE stream to compress it would defeat the purpose.
  • ECKO_HTTP_READ_TIMEOUT_MS (default 30000), a per-connection deadline for receiving request headers.

Fixed

  • Calling an async fn no longer leaks its captured environment. Spawning a task deep-copies the environment so the task can mutate globals privately, and a function binding inside that copy closes back over the copy itself. The scope owned the closure and the closure owned the scope, which is a reference cycle that reference counting can never collect, so every call leaked its whole snapshot: about 25 KB, growing with the number of functions in scope. Worst on long-running servers, because feeding a channel from an async fn is the documented way to stream. A streaming benchmark grew from 10 MB to 231 MB over 8,000 requests and never came back; it now settles under 20 MB. A program making 40,000 async calls peaked at 998 MB and now peaks at 8.8 MB. A finished task now tears its snapshot down explicitly, but only after checking that nothing outside still points into it. A task may hand part of its snapshot out, by returning a closure or storing one in a shared cell, so the teardown counts the graph's references to itself and leaves it alone unless every one is accounted for. Getting that count wrong in either direction skips the teardown rather than emptying a scope some live closure still needs.
  • ab -k no longer hangs against an Ecko server. An HTTP/1.0 request carrying Connection: Keep-Alive was honoured - the socket stayed open and the next request was served - but the response never said so, and HTTP/1.0 clients key off that header, so they waited forever. ApacheBench speaks HTTP/1.0, so ab -k completed zero requests even at concurrency 1. The header could not be sent at all under the old server, which filtered Connection out of every response with no public API to bypass it. ab -k -n 3000 -c 10 now completes 3000 of 3000.
  • Slow clients are dropped instead of holding a connection. A client that opened a connection and dribbled partial headers could hold it indefinitely; ECKO_HTTP_REQUEST_TIMEOUT_MS only ever bounded handler run time, not how slowly a request arrived. The read deadline above closes it. The test for this (tests/http_slowloris.rs) had been written and then ignored because the old server exposed no hook to set a deadline on an accepted connection; it now runs.
  • Record field types now reach ai[T]. A type declaration's field types were parsed and then discarded at registration, so the JSON schema sent to a provider described every field of a record as a string, whatever it was declared as, and mock mode returned strings for Int, Float and Bool fields. Declaring age: Int and calling ai[User] told the model age was a string. Both engines now record the declared types, the schema carries them, and mock mode produces a schema-valid value per field. Construction still does not enforce them; that is a separate, breaking change.
  • A live provider's response now coerces record fields against their declared types, and Option<T> fields get a real schema and mock value. Two gaps left open by the fix above. First: coerce_json_to_type_expr's struct-building arm mapped every field of a provider's JSON response with plain json_to_value, ignoring the field's own declared type - unlike a bare ai[Int], which parses a stringy "34" into 34. A provider that ignored the schema hint and sent "34" for an age: Int field left it a String, so n = n + r.age still concatenated. It now coerces each field the same way ai[Int] does; a field that cannot coerce becomes Null, same as a failed top-level typed call - this is coercion, not enforcement, and does not itself trigger the retry loop. Second: a field typed Option<T> fell through the schema/mock generator's catch-all to the same permissive schema used for json, so type U = U { age: Option<Int> } mocked age as a string. The schema now sends {"anyOf": [<schema for T>, {"type": "null"}]}, and mock mode produces a schema-valid T value rather than null. Result<T, E> is deliberately left out of both changes: it is an Ok/Err ADT, not a single value shape, and far less common as a field type than Option<T>.
  • The doc-link check no longer goes blind on a whole file to excuse two paths. Six documents were exempt from code-span checking because some of their paths name files in the sibling ../docs repo. Exempting the file also stopped checking its real in-repo paths, and docs/reference/architecture.md alone cites 26 of those. Only three span occurrences actually needed an exemption, all in docs-ia.md, so they are now listed by exact path. Checked spans go from 101 to 138, and a renamed crates/ or tests/ file cited in architecture.md or lang-spec.md now fails the gate instead of passing it.

Added

  • The std.string toolkit is frozen in the compat corpus (string_module, taking it from 22 to 23 programs). partition, rpartition, zfill, center and swapcase all shipped after the corpus was seeded, so the promise that old code keeps running did not cover them. The existing strings program freezes the language's own literals and operators, which is a different surface, so this is an addition rather than a replacement.

Added

  • The compat corpus grew from 19 to 22 frozen programs. channels_bounded freezes the bounded-channel surface added after the original seed - channel(n) backpressure, a send that blocks while the buffer is full, and the non-blocking try_recv - none of which any prior frozen program exercised. imports freezes two things no prior frozen program pinned: std.io's io.print writing without a trailing newline (no other program imports std.io), and a full json.encode then json.decode round trip on the success path (errors.ecko only calls json.decode down an error path). doc_comments freezes the ## documentation-comment surface, previously uncovered by the corpus. Coverage only grows, per compat/README.md's own rule; nothing existing was edited. The corpus is also now hash-enforced: every file under compat/programs/ has a recorded hash in compat/SHA256SUMS, checked by scripts/compat-freeze.sh, so an unnoticed edit or an addition made without reblessing the manifest fails instead of passing silently.

Fixed

  • ecko fmt no longer moves a trailing comment inside a string literal. An ordinary "..." may hold a raw newline, and with interpolation the statement renders across two lines. The formatter put the trailing comment on the statement's first line, which was inside the string: quiet corruption when the comment was plain text, since it became part of what the program printed, and unparseable output when the comment contained a quote, since that closed the string early. The comment now goes on the first line that does not end inside a string.
  • The corpus fuzz test is reproducible across machines. corpus_mutations_never_panic walked the examples with read_dir and never sorted, while one seeded RNG was consumed in that order, so which mutation hit which program depended on the filesystem. A failure could not be reproduced from the same commit, and any change to the example corpus reshuffled every mutation. The corpus is now sorted by path, with corpus_is_collected_in_a_fixed_order guarding it.

Changed (BREAKING)

  • A record's declared field types are now enforced at construction. The entry above shipped the types through to ai[T]'s schema and mock mode but left plain construction unchecked; User("Sean", "not-a-number") accepted the mismatch. It no longer does, in every constructor path: the positional call, the brace form, a positional call inside a bridged closure (map(xs, fn(s) User(s, 0))), and the brace form on the AST tier, which is what an async fn or a contracted fn body constructs on - both engines run the same check. The error names the field, the type, the expectation and what arrived:
    field `age` of `User` expects Int, got string
    The one asymmetry: a Float field accepts an Int (widening is lossless, the same reason 1 + 2.0 is a float), but an Int field rejects a Float (narrowing loses data). A field left out of the call is checked the same way null would be, so a missing required field now throws instead of silently building the struct with that field null - unless the field's declared type already accepts null (no annotation, or Option<T>). Result<T, E> fields stay unmatched, consistent with the schema gap above. ecko check reports a mismatch statically wherever the value is a literal, in either construction form, before the program runs, so most of the migration is a list of file and line numbers rather than a runtime surprise. Assignment is checked too, by the same matcher. set_in_path is the single function both engines share for writing into a field, so one check covers construction and assignment alike, with the identical message shown above. A nested path is checked at the field it actually writes, not at the root: given type Server = Server { port: Int } and type Config = Config { server: Server }, c.server.port = "nope" reports:
    field `port` of `Server` expects Int, got string
    A plain map has no declared field types and stays permissive - m.a = "anything" is unaffected - which is the boundary that stops enforcement reaching past records, and a test pins it. Result<T, E> fields still accept anything, consistent with construction above. What this still does not cover: index assignment. xs[0] = "x" on a List<Int> field does not validate the new element, because the declared type belongs to the field, not to each element, and checking it would mean re-validating the whole list on every element write. Cost: 500,000 assignments measured 127ms before this check and 141ms after, interleaved - about 11 percent, or 30 nanoseconds per write, the same construction pays. See docs/specs/2026-08-01-type-record-enforcement-design.md for the full design record. No frozen program in compat/programs/ changed behavior under this: the corpus never relied on a wrong-typed field or a wrong-typed assignment.

0.9.5

Added

  • A JetBrains plugin (editors/jetbrains-ecko) for IntelliJ IDEA, WebStorm, PyCharm and the rest of the family, including the free Community editions. It registers the .ecko file type, ships the same TextMate grammar the VS Code extension uses, and wires up ecko lsp through LSP4IJ for diagnostics, completion, hover, go-to-definition and symbols. LSP4IJ is used rather than the platform's own LSP API because that API ships only in the paid IDEs. Verified Compatible by JetBrains' plugin verifier against IntelliJ IDEA Community 2024.2.5.
  • A publishing and maintenance guide for both editor integrations (docs/guides/editor-extensions.md): marketplace setup for VS Code, Open VSX and JetBrains, signing, the shared-grammar rule, and what to update when the language gains syntax.

Fixed

  • The VS Code grammar broke on type X = A | B. The union bar was read as the opening delimiter of a |x| lambda, and because that rule was an unbounded begin/end pair it never closed - every let, fn, string and keyword after the first type declaration in a file was scoped as a lambda parameter. Type declarations are now consumed by their own rule before the lambda rule runs, and the (deprecated) |x| form is matched as a single bounded token so a stray bar cannot run away again. Verified by tokenizing a full-syntax file with the real TextMate engine.
  • The grammar did not highlight the ai clauses (using, with, on, -> stream), ## documentation comments, or std.defaults, and it highlighted a += operator the language does not have. Module names in import std.<name> position are now scoped as modules rather than being unhighlighted - or, for std.string, mis-scoped as the string() builtin.
  • is_blank was documented as a global builtin. It is string.is_blank.
  • ecko doc documented a multi-file package as an empty page. A package that curates its public surface in main.ecko and keeps the implementation in siblings got a title and nothing else, because extraction read one file and export * from "./impl.ecko" is not a declaration. It now follows export ... from into those files with the same selectivity an importer gets: export * takes the whole exported surface, export { a } takes a and leaves its siblings private, as documents the alias, and private helpers never cross the boundary. Cycles terminate. This hit the three packages with the largest APIs - webkit (0 -> 34 entries), validate (0 -> 28) and cache (0 -> 7) - and it also made the package release workflow's "every export is documented" gate pass vacuously for them, since it counted holes in a surface it could not see.
  • ecko doc <file> -o <dir> wrote <dir>.md instead of a file inside the directory. The directory-mirroring added for multi-file output stripped the source path from itself and got an empty relative path, so -o docs produced a file called docs.md. Single files now use their basename. Covered by a test that runs the CLI, which is where the gap was - the existing tests only exercised the library.

Changed

  • Generated docs are titled after the package, not the file stem. A stdlib/url/docs/main.md headed # main told the reader nothing; ecko doc now reads module (or name) from an ecko.json beside the source and uses its last segment, falling back to the stem when there is no manifest.

0.9.4 - 2026-07-29

Added

  • Prebuilt binaries for four platforms, and an installer. Releases ship linux x86-64/arm64, macOS Apple Silicon and Windows x86-64, each built on its own native runner (cross-compiling native-tls means cross-compiling OpenSSL, and macOS additionally needs the Apple SDK; built natively, macOS uses Security.framework and Windows uses schannel, so neither needs any setup). Every job smoke-tests its own artifact before packaging. Linux gets two treatments the others do not need: a new vendored-tls cargo feature statically links OpenSSL - without it the binary needs libssl.so.3 at runtime and will not start on a distro shipping OpenSSL 1.1 or none - and it builds on the oldest supported runner, since a glibc-linked binary runs on that glibc or newer but never older. The workflow asserts both. scripts/install.sh is the curl -fsSL https://ecko.sh/install | sh target: POSIX sh (Debian's /bin/sh is dash), detects OS and architecture, verifies the checksum before unpacking, and never sudoes on the user's behalf. It makes no API calls: assets are named without a version and served from ecko.sh/dl/latest/, so installing is a single request with no version lookup and nothing to rate-limit. Artifacts publish to Cloudflare R2, served from ecko.sh/dl, because this repo is private and its release assets 404 for anonymous clients - the installer could not otherwise fetch them. Every release is written to an immutable dl/<tag>/; only full releases are copied to dl/latest/, so a pre-release is installable on purpose and never by accident. Publishing is refused unless every planned platform built. A -test tag builds Linux x86-64 only, so the pipeline can be exercised cheaply; -rc.N and plain version tags build all four, because a candidate that has not been built on Windows is not a candidate. Intel macOS is deliberately absent: the macos-13 runner is being retired and no longer schedules, and an Apple Silicon binary cannot run on an Intel Mac (Rosetta translates the other way), so there is no fallback to offer. The installer says so outright instead of 404ing.
  • ## doc comments and ecko doc. A doubled comment marker is documentation: ## Fetch a user. above a declaration documents it, and ecko doc <file|dir> [-o <dir>] renders markdown from it. Doubling # rather than adding a second comment syntax (/// has no // to build on in a language where / is only division) keeps one rule for comments. An example: line followed by indented lines becomes a fenced code block. Attachment is positional: a block documents the declaration below it, a blank line detaches it, and an unattached block at the top of a file documents the module. Exported declarations are listed whether documented or not, so gaps in the public surface show up; undocumented private helpers are omitted. Extraction is a separate pass (lex for comments, parse for declarations, join by line), so the AST carries no documentation and nothing about running a program changed. The formatter previously rewrote ## x to # # x, which would have destroyed every doc comment in a file; it now preserves the marker across all three of its comment-emission paths.
  • ecko.json loads automatically, and std.defaults exposes it. The manifest sitting next to the file being run is now read before the program starts: its optional environment object is applied to the process environment, and every other top-level key becomes a member of std.defaults (defaults.version), keeping its JSON type. Every key must be a valid identifier - JSON allows "api-url", but defaults.api-url is a subtraction, so such a file is refused when it loads rather than supplying values nothing can name; the same rule covers environment names. environment deliberately overrides the surrounding shell - the block exists to pin the environment a project expects - and because it is applied before evaluation it configures Ecko's own settings (ECKO_AI_PROVIDER, ECKO_MAX_DEPTH, ...) too. The manifest reader is unchanged and independent: it stays strict about what a package needs, while the defaults reader accepts any JSON object, so a project that only wants defaults never has to satisfy the manifest schema. Values must be a string, number or boolean under environment; an object or list is an error rather than a silent JSON encoding.
  • Modules can be indexed by string. module["name"] performs the same lookup as module.name, in both engines - the form that reaches a member whose name is only known at runtime, as std.defaults keys often are.
  • os.arch() and os.family() complete host detection alongside the existing os.platform(): the CPU architecture (x86_64, aarch64, ...) and the OS family (unix or windows, usually the right thing to branch on for path separators, shells and line endings). Both are constants fixed when the binary was built, so they cost nothing at runtime and cannot be derived from Ecko any other way. Ungated - unlike os.env, which can carry secrets, which machine you are on is not sensitive.
  • Map literals accept quoted keys. { "set-cookie": v } used to be a parse error - keys had to be bare identifiers, so any key with a hyphen, dot or space (that is, most HTTP headers) had to be built with nested insert(empty_map(), ...) calls. Bare and quoted forms name the same key; a quoted key is taken verbatim (no {expr} interpolation in key position), and ecko fmt renders keys bare wherever they are plain identifiers.
  • string.ord / string.chr - character to Unicode scalar value and back. There was previously no way to reach a character's numeric value at all, which blocked hashing, custom encodings and base-N conversion. Round-trips for non-ASCII: chr(ord("🦀")) == "🦀".
  • Inclusive slices: s[a..=b]. Slicing now accepts the inclusive-end range form everywhere slices work (strings, lists, bytes; both engines): "hello"[0..=2] -> "hel", xs[1..=2], s[..=2], and inclusive of a negative end (s[1..=-1] reaches the last char). ..= without an end is a clear parse error. Slicing semantics (negative indices, clamping, open ends) are now documented in the language spec.
  • std.string gains rsplit(s, sep, limit?) - split from the right with the limit counted from the end, parts returned in order: string.rsplit("a,b,c", ",", 1) -> ["a,b", "c"].
  • std.string partition / rpartition: split once around the first/last separator into a guaranteed 3-list [head, sep, tail], so destructuring is total: let (h, sep, t) = string.partition("key=val", "="). With no match, partition keeps the string in the head and rpartition in the tail (Python behavior); an empty separator is an error. (The global partition(list, pred) HOF is unchanged - the string form is module-namespaced.)
  • std.string character-class predicates: is_digit, is_alpha, is_alnum, is_space, is_ascii, is_upper, is_lower - true iff the string is non-empty and every char is in the class (Unicode-aware, backed by Rust's char::is_*). is_upper/is_lower use the cased-char refinement ("A1!" is upper; "123" is neither). The empty string is uniformly false, including for is_ascii (a deliberate divergence from Python's "".isascii() == True).
  • std.string casing + alignment: swapcase(s) flips every cased char; eq_ignore_case(a, b) compares case-insensitively (Unicode lowercase-based; full case folding is deliberately not claimed); center(s, width, fill?) pads both sides with the odd leftover on the right; zfill(s, width) zero-pads after a leading sign (zfill("-5", 4) -> "-005").
  • @untrusted marker (on let bindings and function parameters) plus the untrusted-in-prompt analyzer warning: flags a tainted value interpolated into an ai prompt outside {input …}. Static-only, no runtime cost. v1 is explicit-marker-only (a trust-boundary marker); automatic source-tainting is a planned follow-up.
  • Import aliasing (card fed35981). import <path> as <name> chooses the local binding for a whole module, so it never has to collide with your own definitions: import std.time as t (keeping a local fn time() free), import "./helpers.ecko" as h. Mutually exclusive with the selective { … } form (which already had per-name as). Works on both engines, round-trips through ecko fmt, and the analyzer resolves the aliased name. tests/exports.rs.
  • VS Code grammar 0.7.0: catch up syntax highlighting with recent language surface - bitwise word operators (band/bor/bxor/shl/shr/bnot), b"..." byte-string literals, sql { … } query blocks with {expr} bind holes, secrets builtins (secret/reveal/is_secret), plus missing builtins (approx, bytes, list, try_recv) and std modules (dns, image, zlib). Indent rules gain unless/template; snippets for sql/secret/unless. editors/vscode-ecko/.
  • General binding destructuring (card 667d6018). The strict list destructure from for (k, v) in ... now works for bindings and assignment: let (a, b) = pair and mut (x, y) = point bind new variables, and (a, b) = pair reassigns existing ones (handy for a one-line swap, (x, y) = [y, x]). Flat names only, two or more, no nesting; the value must be a list of exactly that length (a mismatch is a runtime error). _ discards a position, and a single parenthesized name stays an ordinary assignment. Identical on both engines; ecko fmt round-trips it. tests/binding_destructuring.rs.
  • Cross-file static analysis + multi-file LSP (card 8f448b77). ecko check <file> now resolves the file's imports (file / vendored package / std) and checks across module boundaries: unresolved-import (missing module / unvendored package / unknown std.*), unknown-member (a member the imported module doesn't export, with did-you-mean), and arity-mismatch on calls into an imported user module. Std-module arity is intentionally not checked (its recorded arity omits optional args - no false positives). New analysis::lint_at(source, path); ecko check uses it. The LSP gains workspace symbols and cross-file go-to-definition (jump from module.member to its definition in the imported file). Cross-file rename is a follow-up. tests/cross_file.rs.
  • Multimodal ai: image input to vision models (card 32e1384c). A new on clause attaches image input to an ai call - ai "describe" on img, or on [a, b] for several - where the image is a std.image handle (so a resize/crop pipeline flows straight in). Composes with typed output (ai[Kind] "..." on img). Live, the image serializes into the provider message content (OpenAI image_url data-URLs, Anthropic base64 image blocks); the same code runs on either. Offline/mock mode echoes each image's real dimensions ([AI Mock] describe [image 4x2]), so vision pipelines stay deterministic and testable without a key. Combining on with tools / session / voting / streaming is a follow-up (a clear parse error for now). Kernel ImageResolver hook (mirrors LiveLlm, installed by ecko-std). tests/multimodal.rs, examples/vision.ecko, LANG_SPEC (### Vision), design in docs/design/multimodal-ai-design.md.
  • std.sql transactions (card b0656d4b): sql.transaction(db, |tx| ...) commits on success and rolls back if the closure raises (re-raising the error, so partial writes never persist), plus manual sql.begin / sql.commit / sql.rollback. tests/sql_tx.rs, examples/sql.ecko.
  • Multipart parser anchors on the CRLF-prefixed boundary (RFC 7578), so a boundary token appearing inside binary file content no longer mis-splits an upload. tests/http_form.rs.
  • hash.password / hash.verify (card d90224dc): Argon2id password hashing with a per-call random salt (self-describing PHC string) and a constant-time verify that returns false on a malformed hash. Native - a memory-hard KDF can't be done safely in pure Ecko. Crate argon2. tests/password.rs.
  • std.web static files + middleware (card e729fe03): web.static(prefix, dir) serves files (content type by extension, traversal/symlink-escape rejected as 404), and web.router(routes, [middleware]) runs a chain of |req, next| functions - auth gates, CORS, logging, rate limits compose by calling next(req) or short-circuiting. tests/web_static_mw.rs, examples/web.ecko.
  • ecko dev <file> (card d2e8612e): runs a program and hot-reloads it whenever it or any .ecko file beside it changes - restart-based (nodemon-style), so it works for one-shot scripts and blocking servers. Polls mtimes, no new dependency.

Changed

  • Plain-http package sources are now refused for remote hosts. ecko get (and any archive-URL fetch) requires https: ecko.sum only catches tampering on re-fetches, so a first fetch over http was silently MITM-able. Loopback hosts (localhost, 127.0.0.1, ::1) stay allowed for local testing, and ECKO_ALLOW_HTTP=1 opts back in explicitly. Redirects are held to the same policy per hop (an https source that redirects to remote plain-http is refused), and redirect chains are capped at 10.
  • BREAKING: ecko get <host/owner/repo>[@version] replaces ecko add. Packages are fetched by source path, Go-style: ecko get github.com/ecko-sh/validate@v1.2.0 lists the repo's git tags, picks the requested tag (or the highest semver tag when no @version is given), vendors the package, and pins its file-tree hash in ecko.sum. An explicit archive URL or local path still works as an escape hatch. Publishing a package is now just cutting a git tag (git tag vX.Y.Z && git push --tags) - there is no registry to publish to. (A hosted-registry direction - an ecko add <name> index client and ecko publish - was briefly built during this cycle and dropped, unreleased, in favor of fully decentralized path fetching.)
  • Package integrity is now fail-closed. A sum-managed project (one that has an ecko.sum file) must pin every package it imports: importing - or ecko installing - a package with no ecko.sum entry is a hard error (run ecko get to pin it), and a tampered vendor/ tree is caught at import, not just at install. A project with no ecko.sum is unmanaged and skips verification, so hand-placed vendor/ and quick scripts are unaffected. A project becomes sum-managed the first ecko get that writes an ecko.sum. ecko.sum is now the sole integrity source (the redundant ecko.lock hash was dropped).
  • const is deprecated (syntax consolidation). It was always identical to let (both immutable - they compile the same), so it added a keyword without adding meaning. ecko check warns (deprecated-syntax) and ecko fmt rewrites const to let; a future major removes the keyword.
  • The |params| body lambda form is deprecated (syntax consolidation). fn(params) body is canonical - it reads plainly as a function. The |...| pipe form still parses and runs, but ecko check now warns (deprecated-syntax) and ecko fmt rewrites it to fn(...); a future major removes it. Named functions (fn name(params) = body) are unaffected.
  • BREAKING: modules are private by default; export is a per-definition modifier. Mark a definition public inline (export fn, export type, export x = …) instead of the separate export { … } list (removed). A top-level definition is now private to its file unless exported. Compose multi-file packages with re-export, which surfaces another module's exports without binding it locally: export * from "./m.ecko", export { a, b as c } from "./m.ecko", and export import "./m.ecko" (bind locally and re-export). Re-export collisions are hard errors. A definition may share a builtin's name (e.g. a package get); reach the shadowed builtin as core.<name>, and ecko check warns on the shadow. Migrate old code with ecko fix --migrate-exports.
  • reqwest now uses native-tls (OpenSSL) instead of rustls/aws-lc-rs. Its default TLS backend pulled the heavy aws-lc-sys C crypto library into every binary; switching to native-tls (already linked via tungstenite) drops that whole stack. Raw-socket TLS keeps ring-backed rustls, so the build carries one crypto stack, not two - a smaller binary, and the test link fits CI's linker (which was OOM/SIGBUS-ing on the oversized static link).
  • std.zlib (card f1cc70e0): gzip/gunzip/deflate/inflate over bytes and strings, via flate2 (already in the tree through zip, so ~zero added binary size). Compression is bytes-native (a string compresses its UTF-8 bytes; decompression returns bytes and fails loudly on bad input); compressors take an optional level 0-9; all pure (no capability). Pairs with std.http content-encoding. tests/zlib.rs; lang-spec ("Compression"); examples/zlib.ecko. (zstd deferred - a separate C dependency.)
  • std.image (card bc60466a): decode/load/resize/crop/encode/save for PNG and JPEG, over the image crate - the groundwork for multimodal ai (image inputs to vision models). Images are opaque integer handles (a process registry, like std.net/std.sql - the kernel is untouched); image.encode returns bytes, pairing with the bytes type and std.encoding for a base64 round trip. load/save are capability-gated (fs:read/fs:write); decode/encode/resize/crop are pure. tests/image.rs; lang-spec ("Images"); examples/image.ecko (offline, embedded base64 PNG).
  • ecko build bundles run fully in memory (card b314c692): the embedded payload is served straight to the import machinery (a virtual-filesystem overlay in interpreter::vfs that user and package imports consult before the real filesystem) - no temp extraction, no cache directories, no disk writes at all, which also retires the extraction cache's tampering surface. Vendored packages, capability grants, and relative imports all resolve from the payload. Release binaries are now symbol-stripped and built with fat LTO + a single codegen unit (every MB of interpreter is a MB in every bundle; together these took the release binary from ~24 MB unstripped to 18 MB, with no perf cost). Windows note: appending the payload invalidates an Authenticode signature - sign the bundled output. tests/build.rs.
  • for-loop destructuring (card 22e72f3d): for (k, v) in map, for (i, x) in enumerate(xs), and any list-of-lists bind each item's elements directly instead of pair[0]/pair[1] indexing. Two or more parenthesized names; strict per item (a list with exactly that many elements, else a loud error); _ discards a position. Purely additive - for (x) was a parse error before and stays one. Both engines, fmt round-trip, compat corpus untouched. tests/for_destructuring.rs; lang-spec Control Flow; examples/maps.ecko.
  • Bounded channels + try_recv (card c5758f83): channel(n) bounds the buffer to n values - send blocks cooperatively while it's full (real backpressure for producer/consumer pipelines), is cancellation-aware like recv, and wakes as receivers drain or the channel closes. try_recv(ch) is the non-blocking receive: the next value, or null immediately (the same null-on-close convention as recv). channel() stays unbounded and unchanged. tests/bounded_channels.rs; lang-spec Channels section; examples/channels.ecko.
  • bytes type + word bitwise operators (card b930fbd4, design docs/design/bytes-bitwise-design.md D1-D5): Value::Bytes with b"..." literals (\xNN escapes, no interpolation), band bor bxor shl shr bnot (checked shifts; element-wise on equal-length bytes), index/slice/iterate/ concat/compare/match-pattern support, an explicit text/bytes boundary (bytes(), strict string(), string.from_utf8[_lossy], list()), binary-safe contains/index_of, and JSON/ai[bytes] as base64. tests/bytes.rs; lang-spec "Bytes & Bitwise Operators"; examples/bytes.ecko.
  • Std accepts bytes (stage 2, card fd07a325): encoding.base64_encode / hex_encode, hash.sha256 / hmac_sha256 (plus raw-digest sha256_bytes / hmac_sha256_bytes), net.send, and fs.write accept bytes (strings keep meaning their UTF-8 bytes); new fs.read_bytes. tests/bytes_std.rs.

Changed (BREAKING)

  • std.sql BLOB columns return bytes: a SQLite BLOB value now maps to Value::Bytes (exact binary) instead of a lossy from_utf8_lossy string that corrupted non-UTF-8 data - the same silent-corruption class the bytes migration fixed elsewhere. Binding a bytes parameter now stores a BLOB (it previously became a JSON/base64 string). Callers that stored text in a BLOB column and read it back as a string should wrap the read in string(). tests/sql.rs.
  • Decoders and net.recv return bytes (stage 3, card 8ac94272; decided pre-1.0 so v1.0 ships without the silent-corruption behavior): encoding.base64_decode / hex_decode and net.recv now return the exact bytes instead of a lossy UTF-8 string that corrupted binary data. The explicit text forms - encoding.base64_decode_text / hex_decode_text / net.recv_text - behave identically on valid UTF-8 and error loudly otherwise. Migrate mechanically with ecko fix --migrate-bytes [--check] <paths> (lexer-based: rewrites *_decode -> *_decode_text and net.recv -> net.recv_text, leaving strings/comments/channel recv untouched).
  • New reserved words: band, bor, bxor, shl, shr, bnot are now keywords (like and/or/not) and can no longer be used as identifiers; they remain usable as field names after ..

Fixed

  • ecko check no longer rebuilds the builtin surface per file. The resolver constructed an Environment and ran all ~95 native registrations (an Arc and a boxed closure apiece) on every analysis - once per program run, and once per keystroke under the LSP. It is derived once per process now: checking 60 files in one invocation went 17.4ms -> 14.3ms (-18%), with byte-identical output across all 182 .ecko files in the workspace. load_builtins registers unconditionally and reads no environment, so there is nothing to invalidate. Single-run startup is unaffected either way - one run only resolves once.
  • Builtins refuse extra arguments. Builtin arity was enforced as a minimum only, so abs(-1, 99) returned 1 and upper("a", 99) returned "A", silently dropping the extra - while the same typo in a user function was a hard error that refused to start the program. Calling a builtin with too many arguments is now an error in both engines, and ecko check reports it statically with the same arity-mismatch message user functions get, so the two surfaces finally agree about whether a mistake is a mistake. The five builtins with genuinely optional trailing arguments (trim's charset, split's limit, approx's tolerance, channel's capacity, and cost's explicit-prices form) keep their real maxima; the analyzer reads those from the registrations rather than a hand-kept table, so it cannot drift.
  • std.web: a web.get route now answers HEAD. web.get matched the GET method only, so every route fell through to the 404 arm on a HEAD request - and HEAD is what uptime monitors, health checks, load balancers and link checkers send first, so a perfectly healthy Ecko server reported itself down. It also made debugging misleading: curl -I sends HEAD, so inspecting response headers returned the 404 page's, and correct middleware looked broken. The GET handler now runs for HEAD and the body is dropped as the response is written, so the status and headers - including the content-length GET would have sent - are the real ones. An explicit web.head(path, handler) is available and takes precedence over the fallback; web.static serves HEAD on the same terms.
  • ecko check now warns when a module import shadows a builtin. import std.string binds string, displacing the builtin string() conversion; the program then failed at runtime with a bare Can't call module that never mentioned the import, and check said nothing. It now reports shadows-builtin with the fix in the message (import std.string as str, or core.string). Only fires when the file actually calls the displaced name.
  • ecko fmt no longer leaves over-budget lines around multi-line strings. A call whose argument was a multi-line string literal was exempt from wrapping entirely - the flat form contains a newline, and the fitter returned any such form unchanged - so the formatter un-wrapped source the author had already wrapped, producing lines of 120-200 columns. Width is now judged on the first line, and breaking is skipped only when rendering actually consumed comments (which cannot be rendered twice).
  • ecko fmt accounts for the export prefix. It is prepended after a statement body is rendered, so every exported statement was judged 7 columns narrower than it really is and could settle 7 columns over budget.
  • trim charset and split limit are honored instead of silently ignored. trim(s, chars) / string.trim_start / string.trim_end now strip any character in the given set (trim("xxhixx", "x") -> "hi"), and split(s, sep, limit) caps the number of splits (split("a,b,c,d", ",", 2) -> ["a", "b", "c,d"]). Both forms previously accepted the extra argument and did nothing with it; a wrong-typed extra argument is now an error rather than being dropped.
  • Selective import now works for file and package modules, not just std. import "./util" { double, greet as hi } (and the same from a vendored package) used to silently ignore the { … } selection and bind the whole module under its stem, so the selected names came out undefined at the use site. It now binds the chosen names directly (with clean errors for names the module doesn't export), matching import std.x { … }.
  • Template directives in a plain string now error clearly. {input …} and {for …} are template-body directives; used in a plain interpolated string they used to silently misparse ({input x} read a variable input, erroring undefined-variable). They now produce a directive-specific parse error pointing at templates. A bare {input} variable and a genuine {if …} expression are unaffected.
  • Ollama LLM errors are now readable (card c8960cec). The error extractor only understood the OpenAI/Anthropic shape ({"error":{"message":...}}), so Ollama's flat {"error":"..."} collapsed to an empty string - a failed call surfaced as e.g. Ollama returned 404: with nothing after the colon. A single error_detail(provider, body) helper now parses each provider's shape, falls back across the others (for proxies/gateways that reshape errors), and shows a truncated raw body rather than nothing when the shape is unrecognized. Applied across the chat, tool-call, streaming-fallback, and embedding paths. crates/ecko-std/src/llm_live.rs.
  • A malformed importer manifest is now a hard error, not a silent zero-grant. When resolving import <pkg>, a present but unparseable ecko.json (e.g. a dependency entry missing the required source/sha256) previously had its parse error swallowed, defaulting the dependency's grant to [] - which then surfaced downstream as a misleading "package attempted X without capability Y" denial. It now halts at import naming the malformed ecko.json and the parse reason. An absent manifest is still fine (a loose script legitimately has no declared grants). tests/capabilities.rs::a_malformed_importer_manifest_is_a_hard_error.
  • ecko dev now reloads on served files, not just .ecko. The file watcher also picks up common web/static files (.html, .css, .js, .json, .svg, .md, images, fonts, …), so editing the page or assets a server serves triggers a reload. .ecko-only watching meant static edits never reloaded. (A curated set, not every extension, so a program writing an unrelated file under the tree can't spin an endless reload loop.)
  • ecko dev shuts down cleanly on Ctrl-C. It now installs a SIGINT handler that tears down the child server and waits for it before exiting, instead of the parent taking the default SIGINT and dying immediately - which orphaned the child (it kept running and printed its shutdown after the shell prompt returned).
  • http.serve binds with SO_REUSEADDR. Restarting a server on the same port - a dev reload, or a manual Ctrl-C then rerun - now rebinds immediately instead of failing with "address already in use" while old connections sit in TIME_WAIT.
  • Structured errors survive native higher-order calls. A thrown error({ kind, ... }) caught after map/filter/sql.transaction/any native callback now reattaches its payload instead of flattening to the message string - call_function re-parks the thrown value across the string-only callback ABI. crates/ecko-core/src/interpreter/evaluator.rs.
  • Web/SaaS readiness P0 (card 39135c89) - the gaps that blocked shipping a SaaS on Ecko, all in std.http / std.random:
    • Binary bodies both ways: served responses may be bytes (http.response(200, png_bytes) or a bare bytes return) so files and images serve directly; request bodies are exposed raw as req.body_bytes (the old from_utf8_lossy corrupted uploads); client responses carry body_bytes. tests/http_bytes.rs.
    • Form + multipart parsing: req.form holds url-encoded or multipart text fields; req.files holds multipart uploads as { name, filename, content_type, data } (data as bytes). Empty, never absent, otherwise. tests/http_form.rs.
    • random.bytes(n) / random.token(n): a CSPRNG pair drawing from OS entropy (getrandom), distinct from the seedable RNG - safe for session tokens, CSRF tokens, and API keys. token returns url-safe base64. tests/random_secure.rs.
    • Streaming / SSE responses: return { status, headers, stream: ch } from an http.serve handler and each value the channel yields is written as a body chunk until close - the Server-Sent Events substrate for streaming AI output to a browser. tests/http_stream_serve.rs, examples/streaming_http.ecko.
    • All four survive the std.web router (the recommended app shape) - uploads reach handlers as req.files, bytes/stream responses pass back out unchanged. tests/web_router_p0.rs.
  • REPL polish (card 215ecbe2): multi-line input now shows a dim ··· continuation marker on each fresh continuation line (rendered as a display-only hint, so it can never be inserted into the buffer), and eval errors render inline Rust-style - the offending input line with a caret under the column, via the same Diagnostic renderer the CLI uses on files. Location-less errors keep the one-line form. The banner is now a single plain uncolored ASCII line (ecko v0.9.0 // REPL on Linux // ecko.sh) - the old box-drawing frame (U+27E9, U+256D…) rendered as substituted or missing glyphs on fonts without them, collapsing the box into scattered text - and the prompt is >>> , green, the only colored element of the startup surface. Tab completion is bash-style now (complete to the common prefix, second TAB lists candidates) instead of circular cycling, and an exactly-typed name is never replaced by a longer candidate. src/repl.rs.
  • ecko fmt wraps long lines (card 074a41d6): lines over 100 columns now break Prettier-style - list/map literals and call arguments go one element per line with trailing commas, two-stage pipelines break onto leading |> lines, same-precedence operator chains break after the operator, and overlong inline if/try branches fall back to block form. Broken forms insert newlines only where the parser ignores them, so output re-parses to the identical AST - wrapping is idempotent and the whole example corpus still runs byte-identically (the fmt gate enforces both). String literals are never reflowed, so a single long atom may still exceed the limit. Examples reformatted (compat/ untouched, as always). tests/fmt.rs.
  • Keyword map keys (T-P.2): keywords are accepted as map-literal keys and struct-pattern keys - { from: "a", type: "b" } and match m { { from: f } => ... } now parse (previously only dot access m.from worked, per T-P.1, so such maps could only be built with insert(m, "from", v)). Pattern shorthand { from } stays an error with a fix-it (bind explicitly). Disambiguation is unchanged: name : can't start a statement, so blocks like { match x { ... } } still parse as blocks. tests/parser.rs.
  • std.dns (card 4d2088a2): hostname resolution and typed record lookups above getaddrinfo - dns.resolve(host) → sorted IPs (A + AAAA), dns.reverse(ip) → primary PTR hostname, and dns.lookup(host, type) for A/AAAA/MX/TXT/CNAME (MX as {priority, host} maps sorted by priority), via hickory-resolver (net feature, net capability). Deterministic offline tier: the reserved .test TLD and localhost (plus their loopback / TEST-NET-1 / 2001:db8::/32 addresses) answer from a built-in fixed hosts table, so examples and tests never touch live DNS. tests/dns.rs, examples/dns.ecko, LANG_SPEC ## DNS, compat program dns.
  • A bare GitHub repo URL resolves to its latest release asset in the package fetcher: ecko get https://github.com/owner/repo (the explicit-URL escape hatch) fetches …/repo/releases/latest/download/repo.zip (the asset is named after the repo, the only name derivable from the URL). Full asset URLs, non-repo paths, and local/other sources are unchanged. Applies everywhere fetch is used (get/install/update). resolve_source in src/pkgcmd.rs.
  • hash.sha1 / hash.sha1_bytes (std.hash): SHA-1, for legacy wire protocols that need it (e.g. MySQL/MariaDB mysql_native_password). Weak by modern standards - not for new hashing - but the one native primitive that lets a pure-Ecko MySQL client exist. tests/utils.rs.
  • std.net raw-socket TLS + framing (card d6fa668e, DB-connectivity Phase 0): net.connect_tls(host, port, {verify}) (TLS from the first byte) and net.starttls(conn, {verify}) (upgrade a live plaintext socket in place - the STARTTLS shape), plus net.recv_exact(conn, n) (read exactly n bytes, errors on early close) and net.recv_until(conn, delim) (read through a delimiter without over-reading). Certs verify against the OS trust store by default; { verify: false } for self-signed/dev. Uses rustls 0.23 with the ring provider. This is the keystone for pure-Ecko database clients (Redis/Postgres) and useful for any TLS wire protocol (SMTP/IMAP/...). tests/net_tls.rs; lang-spec Networking. Proven by two standalone, installable pure-Ecko packages built entirely on this plumbing (not in this repo): a Redis client (RESP2) and a PostgreSQL client (v3 wire protocol + SCRAM-SHA-256 auth, with PBKDF2 running in the interpreter). The plumbing is here; the clients are normal Ecko modules.

Performance

  • Closure calls no longer contend across threads. Entering the VM cloned the program's function table and definedness Arcs on every call. Both are shared by every thread running the program, and the VM is entered once per higher-order-function element, so those two refcount bumps became cache-line contention severe enough that the same map/reduce workload took longer on 8 threads than on 1 (442ms -> 1018ms). execute now takes them from the Vm for the duration instead of cloning, and VmCallSession holds its own pair so the hot callback path skips even that. Measured: the 8-thread workload above went 1018ms -> 681ms, and single-thread HOF work improved too (442ms -> 416ms). Bench-gate ratios: fold 0.796x -> ~0.72x, listmap 1.292x -> ~1.20x, count 1.182x -> ~1.07x. On an HTTP handler doing 200 closure calls per request, throughput at 8 workers went 54,000 -> 92,500 req/s (+71%) and CPU per request 172us -> 108us. Ruled out by measurement along the way, recorded so they are not re-attempted: a thread-caching allocator (tcmalloc made it worse), and per-request/per-call std::env::var reads (no measurable change).
  • std.sql no longer serializes every statement in the process. The connection registry held one global mutex for the whole of every query, so two unrelated database handles still queued behind each other. Connections now sit behind their own mutex and the registry lock is released as soon as the handle is looked up. Measured: four concurrent queries on four separate connections went from ~203ms (fully serialized) to 41ms (the cost of one).
  • std.sql caches prepared statements. Every sql.query/sql.exec call re-parsed and re-planned its SQL; statements are now cached per connection (capacity 128) and parameters rebound. Measured over 100,000 rows: inserts in a transaction 557k -> 1,078k rows/s, primary-key selects 177k -> 231k/s, indexed aggregates 136k -> 185k/s. That moves Ecko from last to ahead of CPython's sqlite3 on all three, and ahead of Node's node:sqlite on aggregates.
  • http.serve reads ECKO_HTTP_MAX_BODY and ECKO_HTTP_REQUEST_TIMEOUT_MS once instead of on every request, matching how the VM treats its own budget variables. This measured as no throughput change; it is hygiene, not a speedup. Both are now fixed at process start rather than re-read per request.
  • Ranges are no longer materialized by the builtins that don't need the elements. count, min, max, first, last, and take each built the entire list a range denotes before answering - 48 bytes per element, so count(1..=1000000, pred) allocated ~48 MB to return a single integer, and first(1..=2000000) allocated ~99 MB to return start. min/max/first/ last now answer from the range's bounds in constant time, take reads only the prefix it returns, and count streams like sum/reduce already did. Measured: bench/report/count.ecko 92.6ms -> 63.1ms (best-of-21), its ecko/CPython ratio 1.67x -> 1.16x, and peak RSS 53.8 MB -> 8.2 MB.
  • The AST tier builds ranges lazily too. eval_range produced a materialized list where the VM produced a Value::Range, so contracted and async functions paid the full allocation. Constructing 1..=10000000 on that tier went from ~255ms to ~175us. The two engines now produce the same value, which they always claimed to.
  • In-place list builtins stop reboxing their Arc. push, pop, sort, and reverse unwrapped to a bare Vec and re-wrapped, freeing and reallocating the control block on every call; they now mutate through Arc::make_mut, which is in-place when uniquely owned (the linear-update path) and copy-on-write when shared. unique and flatten take ownership of their input instead of cloning it. Measured: bench/report/strjoin.ecko 56.1ms -> 53.0ms, ratio 1.34x -> 1.22x.

0.9.0 - 2026-07-11

Added

  • ecko build: single self-contained executable (card a7570174): compile a program to a standalone binary - a copy of the interpreter with the program appended (the entry file, or the whole project + vendor/ when an ecko.json is present). ecko build main.ecko -o app then ./app runs with nothing else installed; arguments become the program's os.args(), and CLI subcommands are disabled inside a bundle. Packaging, not compilation (no speed change, py() still needs host Python, same-platform only). Completes the Manifesto "one binary, ship it" story alongside the container path.
  • Flagship agent example (card c76834c8): examples/agent.ecko - a single runnable orchestration demo wiring std.rag, @tool + using, typed ai[Urgency], @requires/@ensures, session, retry, and a simulated std.web POST /ask handler. Fully deterministic in mock mode (corpus + fmt gate).
  • Anonymous async lambdas (card 00cab2d1): async |args| body now creates a first-class async closure with the same eager task, share-nothing snapshot, explicit-cell sharing, cancellation, and error semantics as named async fn. async fn(args) body is accepted as an alias and formatted to the canonical pipe form. map returns tasks for async callbacks, while callback consumers requiring immediate values reject them explicitly.
  • Nested async and contracted functions (card 78ef7d62): functions declared inside VM-compiled scopes may now use async and contracts while retaining lexical captures, recursion, mutable outer bindings, defaults, and escaping closure lifetimes across the VM/AST bridge. Async calls snapshot ordinary arguments and captures while preserving explicit cell sharing, matching the language's share-nothing task model.

Fixed

  • Lexical AI tool resolution (card 2a8c70ad): VM ai ... using [...] calls now resolve local @tool functions at the call site, preserving lexical captures and shadowing instead of consulting only the shared global environment.
  • Scalable parameter metadata (card c4285953): VM defaults and named arguments now work beyond parameter 64 without supplied values being overwritten or reported missing. The common 64-or-fewer path remains allocation-free, and duplicate named arguments are rejected consistently by both execution tiers.
  • VM AI session isolation (card 0d2b60e9): the VM-to-AI bridge now passes evaluated session values directly instead of storing them in the shared __vm_ai_session global, preventing hidden state leakage, user-binding corruption, and cross-call races. Live session calls now count against ECKO_AI_MAX_CALLS, and an unsolicited provider tool call fails explicitly instead of silently appending an empty assistant turn.
  • Cancellation-aware runtime waits (card 67c03c6a): cancelling a task now interrupts runtime-managed blocking waits (await, channel recv/select and iteration, and streaming consumption/resolution) instead of requiring an unrelated loop or call checkpoint. Cancelling a parent parked on a child also cancels that child so both can unwind without starving a cap-one task pool. Blocking external syscalls remain cooperative and return only when the underlying call does.
  • Runtime task and WebSocket admission limits (card 5810a9dc): queued async calls no longer create an unbounded set of 256 MiB-stack worker threads before acquiring ECKO_MAX_TASKS; VM closures now release their running-task permit while awaiting, matching the AST tier and preventing a cap-one deadlock; concurrent WebSocket upgrades reserve ECKO_MAX_WS_CONNS slots atomically instead of overshooting the cap.
  • Decimal/float comparison safety (card 5810a9dc): all six comparison operators now reject decimal/float mixing, matching arithmetic and the language specification instead of silently converting the decimal to a binary float.

0.8.0 - 2026-07-09

Added

  • Security pass on py() and the web server (card 34831653): py() no longer risks RCE - the function name must be a plain dotted identifier path (validated before it reaches Python), and the Python worker resolves it with importlib + getattr and NO eval(), so a name built from untrusted data is rejected rather than executed. Web server: handler-returned headers are sanitized (CRLF is dropped, closing an HTTP response-splitting hole - tiny_http emits raw CR/LF verbatim); 5xx responses no longer leak internal error text (paths, limits) to the client (logged server-side, generic body returned); http.serve gained a host: option for local-only binding; WebSocket upgrades are capped (ECKO_MAX_WS_CONNS, default 1024) so they can't spawn unbounded threads; a generic Server header replaces the library fingerprint. Slowloris read-timeout and two LOW py() worker notes are carded (need socket-level / trusted-code work).
  • Resource limits (card 34831653): adversarial input degrades to a catchable error instead of a process abort or hang. Deep expression/block nesting (which stack-overflowed the parser with an uncatchable SIGABRT) is now a parse error (ECKO_MAX_PARSE_DEPTH, default 128). Infinite/runaway recursion (which hung the VM by growing the call stack unbounded, or stack-overflowed the AST tier) is a catchable runtime error (ECKO_MAX_DEPTH, default 2000). Execution runs on a 256 MiB-stack thread so those caps have real headroom (the stack is virtual - no startup or memory cost measured). An opt-in loop budget (ECKO_MAX_STEPS, default unlimited) bounds runaway loops for sandboxing untrusted code.
  • Fuzzing harness + four fmt round-trip fixes (card 34831653): a shared fuzz surface (ecko::fuzz) runs two ways - coverage-guided under cargo fuzz (nightly, fuzz/) and as a deterministic seeded driver on stable (tests/fuzz_smoke.rs, now in CI). Invariants: the parse pipeline (lexer/parser/fmt/resolver) never panics on any input, fmt output always re-parses, and the VM and tree-walker agree on execution-safe programs. It immediately found and fixed four formatter round-trip bugs, all where fmt emitted output the lexer couldn't read back: @tool descriptions used Rust Debug escaping (\u{b}), ai with a bracket-leading prompt re-read as the typed ai[T] form, quoted import paths lost their quotes when the path wasn't a bare identifier, and the plain-string escaper emitted \} which the lexer doesn't invert (non-idempotent). Two deeper fmt idempotence edges (comment reattachment, branch/block canonicalization) are carded.
  • Restructure stages 4-5 (the tail): the kernel's RNG is now an internal xoshiro256** with splitmix64 seeding - the rand crate left both ecko-core and ecko-std, and seeded sequences are now identical on every platform. parking_lot stays by measured decision (std::sync is ~7%% faster under read contention but poisons on panic, and a panicking task thread must never brick the shared environment). Kernel: 34 transitive deps. The three-layer growth policy is recorded in CLAUDE.md.
  • Workspace split (three-layer restructure, stage 3): the crate is now ecko-core (the kernel: 43 transitive deps, zero network/database crates - enforced by the crate graph, not convention), ecko-std (the native primitive modules, self-registering, with the live LLM layer installed through a core hook facade), and the root ecko crate (facade + CLI). Same binary, same batteries, 807 tests unchanged; embedders call ecko::ensure_std() or the facade eval functions.
  • CI hardened: the workflow now enforces the zero-warnings rule (RUSTFLAGS=-D warnings - a single warning fails the build), runs the resolver over the whole example corpus (ecko check, zero findings required), builds and unit-tests the lean --no-default-features kernel profile (the job that keeps the stage-2 feature boundary from rotting), and smoke-runs every benchmark non-gating. CI badge in the README. Superseded runs are cancelled.
  • Three-layer restructure, stages 1-2 (docs/design/core-slim-design.md): std modules now self-register through a module registry with declarative capability profiles (core no longer knows which modules exist), and the heavy dependency clusters are cargo features - net (tokio/reqwest/ tiny_http/tungstenite), sql (rusqlite), tokens (tiktoken), cli (rustyline/lsp/zip/packaging) - ALL ON by default: the shipped binary is batteries-included and byte-identical. --no-default-features builds a lean mock-ai core with 73 transitive deps instead of 268.
  • ecko update [name...]: re-fetches dependencies from their pinned sources, re-pins the sha256 in ecko.json, refreshes ecko.lock, and re-vendors - completing the add/install/remove/pack surface. Grants are authority and are never touched; a source that now serves a package with a different name is refused (identity-swap guard).
  • Package capability enforcement (the package design's section 4, made real): a package's code may perform gated operations only if its effective capability set allows - net (http/net/ws/llm/db/web, ai even in mock mode, embed), fs:read/fs:write (fs + json/csv/toml/ yaml file IO, read_file/write_file, sql.open on a file - :memory: stays pure), env (os.env), exec (py()). Grants come from the IMPORTER's manifest (dependencies[dep].grant, default none) and attenuate down the tree - a dep can never hold more than its parent, and granting a capability you don't hold is an import error. Package closures keep their restrictions wherever they travel; root code is completely unaffected. Denials are structured (kind: "capability" with capability and package fields). Unknown capability names are rejected at manifest parse.
  • Bridge fast path for map/filter callbacks (perf): a reusable VmCallSession (one Vm for the whole loop) replaces the per-element Evaluator + Vm construction, and AST-tier callbacks reuse one Evaluator. listmap (1M-element map) drops 250ms -> 154ms (4.17x -> 2.6x vs CPython); error rendering is byte-identical to the generic bridge.
  • Docker guide + Dockerfile (docs/guides/): a two-stage build (LTO'd binary on distroless/static + the five runtime libraries copied from the build stage) producing a 25 MB image with no shell or package manager; verified end to end, including ai-in-mock-mode inside the container. Keys are passed at run time, never baked into layers. A repo-root .dockerignore keeps the build context small.
  • VM compile decisions read the real AST (tech-debt): the Debug-string scans that decided direct-call dispatch, frame boxing, global promotion, and resolver arity-skips are replaced by a shared AST visitor (visit_program/assign_target_names/expr_creates_fn/ program_features). Kills a silent perf cliff - a string literal containing text like Lambda { (easy in prompts that embed code) used to disable promotion and box every frame - and defuses the latent miscompile risk of scans that parsed derived Debug output.
  • std.test + ecko test: first-class testing. test.case(name, fn) (alias group) with eq/ok/err/fail asserts that throw structured { kind: "assert" } errors; failing asserts end their case, later cases still run. ecko test discovers tests/*.ecko and *_test.ecko, forces mock mode (keys stripped - deterministic, offline, free), prints counted per-case results, and exits non-zero for CI; running a test file directly also fails the process on case failures. craaft-ecko's test.sh is deleted in favor of it (the dogfood finding that motivated the card).
  • Multi-core: env RwLock + full-width pmap default: the global environment's root lock is now a read-write lock - by-name lookups from parallel tasks take shared read locks instead of convoying on one mutex. 32 async tasks hammering global calls went 1.59s -> 0.27s (5.9x; CPU went from spin-bound to 30 productive cores). ECKO_MAX_PARALLEL now defaults to available_parallelism() instead of 8 (pmap and tool rounds share the knob; set it lower for rate-limited APIs). Single-threaded benchmarks unchanged.
  • One error dialect (design: docs/design/error-dialect-design.md): operational stdlib failures now throw structured { kind, message, ... } maps you can match on - kinds: parse (json/csv/toml/yaml/regex, with format/path), fs (file IO, with path), net (HTTP/socket/ws, with url/host), sql, closed (closed channels), budget (ECKO_AI_MAX_CALLS, with calls/max), plus the existing cancelled. Programmer mistakes (wrong types/arity) stay prose-string panics; get(e, "kind") is now TOTAL (null on any non-hit, never an error), so one match get(e, "kind") handles every caught value; Ok/Err are reframed as plain data-modeling types (nothing in the stdlib returns them). Messages are unchanged, so uncaught errors render exactly as before; catch (e) on migrated failures binds a map instead of a string (breaking for contains(e, ...) dispatch - which is the point).
  • Resolver v2 + pre-run gate: ecko check grew scope-aware unused-variable/param/function/import findings (replacing the old text-count heuristics), builtin-shadowing warnings (only when the file calls that builtin), top-level use-before-definition detection, Damerau-style did-you-mean (transpositions now match), and an unwrapped-credential rule (os.env("*_KEY"-shaped) not wrapped in secret()). Rules are severity-classed; running a file now lints it first and refuses to start on definite will-crash findings (undefined names, impossible arities, use-before-definition) - warnings never block. Zero findings across examples/ and craaft-ecko (whose CRAAFT_KEY read is now secret()-wrapped, closing the loop on the credential rule).
  • Copy-on-write collections (performance; no semantic change): lists/maps/structs/modules inside Value are Arc-backed - cloning is an Arc bump, mutation copies only when shared. Passing large collections to functions and spawned tasks stops deep-copying (the share-nothing spawn tax becomes O(1) until written); nested-path writes copy just their spine; equality got a pointer fast path. Value semantics are guarded by tests/cow.rs aliasing traps on both engines.
  • In-place x = x + rhs (both engines, call-free rhs): list and string append now take the value out of its binding and extend it in place, so accumulation shapes the x = push(x, ...) peephole can't see (xs = xs + [i], s = s + "chunk") run in O(n) total instead of O(n^2) - 20k list appends went from 8.3s to instant. A type error restores the untouched value.
  • std.term keyboard input (unix): term.raw_mode(on) (termios raw mode, restored on exit), term.read_key() / term.read_key(timeout: ms) (one keypress as a friendly name - "a", "enter", "up", "ctrl-c", "alt-x", "f5", ...; null on EOF/timeout), and term.poll() (input ready, non-blocking). The escape-sequence parser is pure and unit-tested; interactive demo in demos/keys.ecko.
  • Secrets primitive: secret(v) wraps credentials; the wrapper renders as [secret] through every stringifying sink (print, interpolation, logs, traces, errors, JSON encoding). reveal(s) is the only, greppable door out; is_secret(v) tests. "x" + secret is a type error pointing at reveal. Design: docs/design/secrets-design.md.

Fixed

  • Mock tool loop no longer swallows tool errors: a failing tool in ai ... using [tools] without a provider now throws (catchable, with structured kinds preserved) instead of quietly becoming the string "tool error: ...". Live mode still feeds error strings back to the model - there the model is the recovery mechanism; in mock, the developer is.

Breaking: os.args() is the program's own argv

  • os.args() no longer returns the raw process argv (interpreter binary + script path + args); it returns only the arguments after the script, and is empty in the REPL/embedders. The new os.script() returns the running file's path (null when not running a file). Migration: delete [2..] slices.

Breaking: silent-failure semantics hardening (pre-1.0)

  • Arity is enforced at the call site. Missing required arguments and extra arguments are errors ('f' expects 2 argument(s), got 3); named calls that skip a required parameter error with 'f' is missing required argument 'x'. Previously missing params bound null and extras were silently dropped.
  • fn bindings are immutable, like let. Bare reassignment errors; explicit let redeclaration remains legal.
  • Builtins are shadowable, not assignable. sum = 0 declares a new binding in the current scope; inside a function it declares a local - the old behavior permanently replaced the global builtin (e.g. len = fn(x) 99 used to clobber len for the whole program).
  • Collection access is strict. xs[i] out of bounds, m.key / m["key"] on a missing key, and missing struct fields are now errors (previously all returned null and propagated). get(collection, key) is the blessed nullable lookup; slices remain clamped.
  • New lint: implicit-global-write. Bare assignment from inside a function to a top-level binding is legal (rule 1) but now surfaced by ecko check / the LSP.
  • Float equality is exact. The epsilon-fuzzy == (non-transitive) is replaced by IEEE equality; the new approx(a, b, eps = 1e-9) builtin is the blessed tolerance comparison.

Nothing past v0.7 has a release tag yet (Cargo.toml is still 0.7.0), so everything below ships unreleased. Subsections map to the roadmap phases that delivered them (docs/roadmap/platform-roadmap.md, docs/roadmap/stdlib-roadmap.md).

Bytecode VM - Phase 0 (skeleton + go/no-go: GO)

  • First rung of the P4.2 bytecode-VM milestone: a bytecode compiler + stack VM (src/vm.rs, internal run_via_vm) that compiles a minimal subset - arithmetic, comparisons, if/else, globals, fn/call/recursion, native builtin calls - and runs it on a value stack with call frames instead of the tree-walker's per-call Arc<Mutex<Environment>>. Result: a naive VM (no specialization yet) runs fib(30) 5.64x faster than the tree-walker (0.246s vs 1.386s), clearing the milestone's go/no-go kill switch. The tree-walker remains the shipped engine; the VM grows behind run_via_vm, phase by phase, until the Phase 4 cutover. tests/vm.rs (10), including byte-parity with the tree-walker on fib. Reuses the Value enum and apply_binop unchanged.

std.rag - first-class retrieval-augmented generation

  • New std.rag module turns Ecko's existing AI primitives (embed, cosine, the ai keyword) into a cohesive RAG pipeline: rag.chunk (split a document into overlapping word passages), rag.index (embed a corpus into an explicit index value - no hidden global), rag.retrieve (top-k passages by hybrid dense-cosine + lexical word-overlap scoring, i.e. built-in reranking), and rag.answer - RAG in one line: retrieve context, ground an ai call in it, return the answer. Fully deterministic and offline in mock mode (mock embeddings + mock ai), so it runs with no API key; the hybrid lexical signal keeps retrieval sensible offline and sharpens it with a real embedding model. No new dependencies - it is pure orchestration over primitives Ecko already had. tests/rag.rs (9) + examples/rag_module.ecko.

ecko lsp - Language Server (complete)

  • New ecko lsp subcommand: a Language Server Protocol server (built on tower-lsp) shipped inside the one binary - no separate install, works in any LSP editor (VS Code, Neovim, Helix, Zed). Adds one dependency (tower-lsp, over the already-vendored tokio), still one binary.
  • Phase 1 - live diagnostics as you type: lexer/parser errors as Errors and every ecko::analysis::lint finding (dead code, unused imports/vars, unreachable code) as a Warning, mapped to proper 0-based LSP ranges.
  • Phase 2 - IDE features: completion (keywords, builtins, std module names, and the document's own top-level symbols; after <module>. it offers that std module's members), hover (a user function's signature, or a builtin/module label), go-to-definition (jumps to a user symbol's declaration), and document symbols (the outline of top-level functions/types/constants/variables). All four are pure, unit-tested functions of (source, position) over a new reusable analysis::symbols pass; the server layer is thin glue with a document store. The static keyword/builtin/ module sets are ported from the VS Code extension's stopgap provider.
  • Phase 3 - VS Code client: the bundled VS Code extension now launches ecko lsp as a language client (vscode-languageclient) instead of shipping a static, non-scope-aware completion provider. It finds the binary on PATH or via the ecko.server.path setting, and degrades gracefully (syntax highlighting + snippets keep working, with a one-time notice) when the binary is absent. Extension bumped to 0.5.0.

Richer diagnostics - source snippets, "opened here", stack traces

  • Error messages leveled up from single-line (line N, col M) to Rust/Elm-class diagnostics. A new ecko::diagnostics module renders a source snippet with a caret under the exact column; unbalanced blocks point back to where they were opened ("opened here"); an undefined variable suggests the nearest in-scope name ("did you mean 'greeting'?", by edit distance); and runtime errors print the source line for each call-stack frame. Carets align correctly under tabs. This is the shared error-type foundation the future ecko lsp surfaces. The parser keeps its String error contract - the CLI adapts messages into structured diagnostics at the boundary - so embedders and the one-line eval_file are unchanged; the new eval_file_pretty drives the rich CLI rendering.

ecko lint - static analysis linter

  • New ecko lint <file> command, built on the same ecko::analysis module as ecko explain. Fully deterministic and offline; four false-positive-free checks: never-used functions, unused imports (whole-module and per-item for selective imports), unused variables (let/const/bare assignment; _-prefixed names are exempt), and unreachable code (a statement after an unconditional return/break/continue, found by a complete block walk - a conditional return inside an if does not count). Reference checks count whole-word over the raw source, so they only ever under-report - a genuinely live name is never flagged. Output is the grep-friendly file:line: rule: message; exit code is 0 (clean), 1 (issues), or 2 (read/parse error) for CI. Shadowing and scoped unused locals are deferred to the bytecode VM's resolver pass.

ecko explain - AI-aware code insights

  • New ecko explain <file> command: the AI-native language explains your code. Two layers. First, deterministic, offline static analysis (a new ecko::analysis module, reusable by a future ecko lint): the program's structure - top-level functions and their arities, imported modules, whether it uses ai - plus a conservative dead-code signal (functions defined but never referenced; counted over the raw source so a use inside a string interpolation still counts, and it never falsely flags a live function). Second, when a provider is configured, a plain-language AI explanation grounded in those static facts (not just the raw source). Runs fully offline for the static half. From the AI-aware-compiler flagship idea.

std.cli

  • New module for declarative command-line parsing (pairs with std.term for building CLI tools). A CLI is a plain spec map; cli.parse(spec, argv) returns { options, args, rest, command, help } and cli.help(spec) renders usage text. Supports long/short options (--name v, --name=v, -n v, -nv), flag: true booleans, a value type inferred from each option's default (Int/Float/String), the -- options terminator, positional args + rest, subcommands (via a commands list), required-field validation, and --help/-h (which short-circuits validation). Bad input raises a first-class catchable error. Parsing is pure, so it is fully testable with an explicit args list.

std.term

  • New module for building CLIs and TUIs: styled output (named colors + bright_*, rgb truecolor, color 256, attributes, and a combined style(...)), cursor movement (goto/up/down/left/right/save/restore/hide/show), screen control (clear/clear_line/clear_down/alt_screen), and info + utilities (size() -> {rows, cols}, is_tty(), strip(), visible width(), and OSC-8 link()). Every function returns a string, so a whole frame can be built and printed once (flicker-free). Escape codes are emitted only when interactive - styling degrades to plain text and control to "" when stdout is not a TTY or NO_COLOR is set, and CLICOLOR_FORCE forces them on - so the same program is colorful in a terminal and clean in a pipe. New dependency: terminal_size (for size()).

Internal: spawn_task helper for governed background tasks

  • The four permit-governed spawn sites (std.bg's spawn/after/every and the async fn task spawn) hand-rolled the same "acquire a task permit, run with catch_unwind, release, complete the handle" boilerplate. Extracted a single spawn_task(handle, body) helper (plus a shared panic_message) that owns it, so the permit is always released - even on early return or panic - and a new spawn site can't forget it. Behavior unchanged; from code-review finding #3. Guarded by tests/task_permit_release.rs.

Friendlier, on-brand user-facing text

  • Reworded the CLI, --help, and REPL in Ecko's voice (lowercase ecko, warmer banner/prompts, :help/:exit colon commands accepted alongside the bare words) and gave command output a human tone ("Added mathx - vendored and locked.").
  • Swept the runtime, parser, and lexer error messages to a consistent, friendly voice (contractions, needs instead of expects, couldn't instead of failed to), and hand-wrote the highest-traffic errors to guide toward a fix - e.g. Can't add {} and {} - try converting one with int(), float(), string(), or decimal()., Can't divide by zero., Undefined variable 'x' - is it defined, in scope, and spelled right?, and This string is missing its closing quote. No error semantics changed.

Documented: empty-list / absent-value convention

  • The language spec now states the rule (no behavior change): retrieval (first/last/get/find/indexing) returns null when absent; reductions that need an element (min/max) error on empty; identity-value operations (sum, filter, frequencies, ...) return their neutral value. Enforced by tests/empty_values.rs. Resolves code-review finding #15 - the flagged min/max vs first/last "inconsistency" is two different kinds of operation, each internally consistent.

Performance: unique is O(n) for scalars

  • unique was O(n^2) (a values_equal scan per element). It now uses a set-backed O(n) fast path when every element is a cleanly hashable scalar (Int/String/Bool/Null), falling back to the original scan only for floats and composite values (lists, maps, structs) where values_equal semantics matter. Behavior is unchanged. From code-review finding #19.

sort / sort_by are deterministic (behavior change)

  • sort and sort_by now raise an error when elements (or keys) are not mutually comparable - mixed types like [1, "a"], unorderable values such as maps, or NaN - instead of silently treating them as equal, which left the order nondeterministic. Homogeneous lists (including mixed Int/Float, which are comparable) are unchanged. From code-review finding #5.

Internal: stdlib modularization

  • Split the ~1,886-line import_std_module monolith in evaluator.rs into one src/interpreter/stdlib/<module>.rs file per std.* module, each exposing a register(module) that import_std_module now dispatches to. Behavior is unchanged (same 502 tests, corpus gate green); evaluator.rs shrank by ~1,840 lines. Addresses the top finding of the code review.

std.string

  • New module gathering the full string-manipulation toolkit (the common ops stay as globals; import std.string for the fuller set). Every operation is character-indexed, so it is UTF-8-correct. Surface: case (upper, lower, capitalize, title), trim (trim, trim_start, trim_end, trim_prefix, trim_suffix), pad_start / pad_end (cycling fill), substring / char_at (negative indices), search (contains, starts_with, ends_with, index_of, last_index_of, count), replace / replace_first, split / split_whitespace / lines / join / chars / reverse, len / is_empty / is_blank / repeat, and from (convert any value). Note: importing binds string, shadowing the global string() constructor in scope - use string.from(x) instead.

Money-safe decimals (P3.3)

  • New first-class decimal type: an exact base-10 number (~28 significant digits, backed by rust_decimal) for money and any value where 0.1 + 0.2 must equal exactly 0.3. Write a literal with a trailing m (19.99m, 3m) or build one with decimal(int|string).
  • Arithmetic is checked (overflow and divide-by-zero are runtime errors, like int). decimal mixes with int and stays decimal; mixing with float is a hard error, and decimal(aFloat) is rejected - a float has already lost precision, so pass a string. +/-/* preserve scale (19.99m + 0.01m is 20.00); //% normalize (decimal(10) / 4 is 2.5). fmt round-trips the m suffix; json_encode emits a decimal as a plain JSON number. int was already checked-overflow, so bignum was not needed for money-safety.

std.net

  • New module for DNS and raw TCP, below the HTTP/WebSocket layer: net.lookup(host) resolves a hostname to a list of IP strings; net.connect( host, port) opens a TCP connection (30s timeout) returning a handle, then net.send(c, data) / net.recv(c, n) (one read up to n bytes, default 64 KiB; null on close) / net.close(c). Connections are opaque handles behind their own mutex. Std-only, no new dependency.

WebSockets (std.ws)

  • New module: a WebSocket client over ws:// and wss:// - ws.connect(url) returns a connection handle, then ws.send(c, text) / ws.recv(c) (blocks; null on close) / ws.close(c). Connections are opaque integer handles in a registry (like std.sql), each behind its own mutex. Crate tungstenite (native-tls).
  • Server-side: http.serve accepts an on_ws: |c| { ... } callback. An incoming WebSocket upgrade is handshaked (via tungstenite::derive_accept_key + tiny_http's connection upgrade) and its handler runs on a dedicated thread, using the same ws.send/ws.recv/ws.close on the handle. Without on_ws, an upgrade is refused with 501.

HTTPS/TLS for http.serve

  • http.serve accepts cert: and key: (PEM file paths) to serve over TLS: http.serve(8443, handler, cert: "cert.pem", key: "key.pem"). Enables tiny_http's ssl-rustls feature (adds rustls 0.20 + rustls-pemfile + zeroize). Both options are required together; without them it stays HTTP.

HTTP client response streaming

  • http.get/post/etc. accept stream: true, returning a response whose body is a stream of text chunks (for chunk in resp.body) drained in the background instead of a buffered string. Using resp.body as a value still blocks for the full text. Reuses the existing stream machinery; no new deps.

std.math

  • New module: constants (pi, e, tau, inf, nan) and float-domain functions - trig (sin/cos/tan/asin/acos/atan/atan2), exp/ln/ log2/log10/log, sqrt/cbrt/pow/hypot, sign, clamp, and factorial (checked overflow). The Int-aware basics (abs/floor/ceil/ round/min/max) stay global. No new dependency.

Stdlib housekeeping: std.csv, std.json file I/O

  • Renamed std.data to std.csv and dropped the redundant _csv suffix: csv.parse / csv.stringify / csv.read / csv.write, matching the std.toml / std.yaml shape. Breaking: import std.data no longer resolves.
  • Moved data.load_json into std.json as json.read(path), and added json.write(path, value) for symmetry with encode/decode and the config modules.
  • Moved the global uuid() into a std.uuid module with uuid.v4() (random) and uuid.v7() (time-ordered, a better database key). Breaking: the bare uuid() global is gone; import std.uuid and call uuid.v4().

Packages - vendored imports + ecko add/install/remove/pack (P2.1)

A registry-free dependency story: zips hosted anywhere, vendored under ./vendor/ and hash-pinned, committed for offline reproducibility.

  • Imports & manifest: a bare import name (not std.*, not "./path") resolves to ./vendor/<name>/ via its ecko.json entrypoint, binding exports under <name>. ecko init scaffolds a manifest; validation enforces name↔directory match, a relative in-package entrypoint, and reserved std.
  • ecko add <url|path>: fetches a package zip (http or local), verifies and unpacks it to vendor/<name>/, pins its sha256 in ecko.lock, and records the dependency in ecko.json.
  • ecko install: rebuilds vendor/ from the lockfile, re-verifying each pinned hash (a mismatch is a hard error).
  • ecko remove <name>: deletes the vendored package and prunes it from ecko.json + ecko.lock.
  • ecko pack [-o <file>]: builds a deterministic, self-contained distributable zip (manifest + .ecko + vendor/).
  • Hardening (untrusted-input surface): zip-slip rejection, zip-bomb/size caps (ECKO_PKG_MAX_BYTES / ECKO_PKG_MAX_UNPACKED), a streamed download cap, and a 30s fetch timeout (crate zip, deflate-only). Capability sandboxing (the capabilities manifest field) is parsed but not yet enforced - the next phase.

Config formats - std.toml / std.yaml (P3.1)

  • std.toml / std.yaml: parse/stringify/read/write for each, routed through serde_json with sorted keys for deterministic output. Crates toml, serde_yaml_ng.

Async runtime & structured concurrency (P1.1)

Made async/await real (structured concurrency on threads) - the interim "async is a no-op" stderr notice is gone.

  • Tasks & await: calling an async fn spawns its body on a task thread and returns a Value::Task; await joins it (identity on a non-task). Errors propagate at await as first-class values.
  • Channels: channel/send/recv/close (unbounded MPMC) with for v in ch draining until closed.
  • Cooperative cancellation: cancel(task) sets a flag the tree-walker checks at loop/call checkpoints; awaiting a cancelled task raises { kind: "cancelled" }.
  • Streaming with live SSE: ai "..." -> stream + for chunk in s; untyped live streams deliver provider tokens as they arrive over real SSE for OpenAI/Anthropic/Ollama (llm::chat_stream).
  • Running cap: ECKO_MAX_TASKS (default 256) bounds concurrently running tasks; a task parked on await releases its permit and reclaims one on resume.

First-class errors & AI-moat hardening (P0)

  • First-class error values: error(v) throws any value; catch (e) binds it. The { kind, message } map idiom lets handlers branch and re-throw; builtin/string errors are still caught as their message (backward compatible).
  • Native message-array sessions: ai "..." with s sends prior turns as a role-separated message array, not a flattened transcript.
  • cost / tokens truthfulness: cost(in, out, in_per_1m, out_per_1m) prices any model absent from the built-in table; tokens is OpenAI-exact (cl100k_base) and approximate elsewhere.
  • Tool-loop bounds: a round's tool threads are bounded by ECKO_MAX_PARALLEL; the tool-loop timeout now signals cooperative cancellation instead of purely abandoning the thread.

Hardened http.serve (P1.3)

  • Per-request isolation: a panicking handler becomes a 500 (worker survives, via catch_unwind); a runaway handler is cut off by a per-request timeout (ECKO_HTTP_REQUEST_TIMEOUT_MS) and returns 503.
  • Graceful shutdown: Ctrl-C (SIGINT) or a new http.stop() drains in-flight requests, then serve returns cleanly; a second Ctrl-C forces exit.

Standard-library buildout - Tiers 1-3

Tier 1 (credibility):

  • std.time: now/now_iso/monotonic/format/parse (Int millis, UTC).
  • std.re: test/find/find_all/captures/replace/split (pattern-first), clear errors on bad patterns.
  • std.os / std.fs: os env/env_or/set_env/args/exit/exec (→ {code, stdout, stderr}); fs exists/is_dir/is_file/list_dir/ mkdir/remove/read/write/join.
  • std.sql: embedded SQLite - open/exec/query/query_one/close, ? parameter binding (crate rusqlite, bundled).
  • std.web router: web.router([...]) over http.serve - get/post/put/delete/patch, :param extraction, 404 fallthrough.

Tier 2 (productivity):

  • std.random / uuid / std.hash / std.encoding: uuid(); random seed/int/float/choice/shuffle; sha256/hmac_sha256; base64/hex/url encode + decode.
  • CSV (std.data): parse_csv/to_csv (sorted headers)/read_csv/write_csv.
  • Collection helpers (globals): group_by/frequencies/chunk/window/ partition/find/count.
  • std.fmt: format (raw-string templates), pad_left/pad_right/ truncate/repeat.
  • std.log: debug/info/warn/error with sorted field maps; ECKO_LOG verbosity.

Tier 3 (AI moat):

  • Tool / function calling: @tool("...") + ai[T] "..." using [tools] - a live multi-round loop across OpenAI/Anthropic/Ollama that invokes Ecko functions and feeds results back, coercing the final answer to T. A round's tool calls run concurrently; ECKO_AI_MAX_TOOL_ROUNDS (default 8) bounds it.
  • Embeddings: embed/embed_all/cosine globals; deterministic mock vectors offline.
  • Token counting & cost: tokens (cl100k_base) and cost (per-model table).
  • Retry & memory: retry(n, f) with exponential backoff (ECKO_RETRY_BASE_MS); session() + ai "..." with s for multi-turn calls.
  • Keyword tokens (type, match, …) accepted as field names after ..

Interpreter speed - "Fast Tier" (branch milestones/v0.8)

  • Faster variable lookups (v0.8): the environment now hashes with FxHash (rustc's hasher) instead of the default SipHash - variable names are short, trusted source tokens, so the crypto hasher was pure overhead. Measured (release, best-of-5): fib 84→74ms, loops 1069→908ms, matching 407→339ms, strings 38→32ms (~12-17% on compute-bound work).
  • parking_lot::Mutex for the interpreter's locks: perf-neutral (std::Mutex is already futex-fast uncontended on Linux) but adopted for robustness - worker-thread panics (pmap/http.serve) no longer poison locks, and the poison-recovery boilerplate is gone.
  • Profiling note: scope-allocation elision and loop-scope reuse were tried and reverted (zero measured benefit). The remaining gap to CPython needs the slot-frame rewrite (per-access lock removal), not micro-tweaks - see fast-tier-design.md.
  • Linear-update optimization (v0.8 Stage 1): x = f(x, …) now moves x out of its environment slot instead of deep-cloning it, so accumulators (x = push(x, item), m = insert(m, k, v)) run in O(n) instead of O(n²). Measured: a 20k-element push loop went 8.77s → 0.01s (~900x). Value semantics are preserved exactly - the move fires only when the variable is mentioned once, so x = x + x and cross-variable assignments still copy. See fast-tier-design.md (Option A approved).
  • cell - thread-safe shared state (v0.8 Stage 2): cell(v), cell_get, cell_set, cell_update(c, fn) (atomic read-modify-write). The explicit escape hatch for sharing mutable state across pmap/ http.serve workers, ahead of Stage 3's share-nothing model. Verified with a concurrent HTTP request counter (no lost updates).

v0.7 - "Fast & Frugal" (branch milestones/v0.7)

  • Majority voting: ai[T] n "prompt" runs n independent samples (1-25) and returns the majority-coerced value; bypasses the cache, counts against the budget, incompatible with -> stream.
  • Prompt caching: content-addressed ai calls via ECKO_AI_CACHE=<dir> or --cache. Hits replay through normal coercion, are budget-free, and trace as cached: true. Collision-safe (entries verify the full prompt).
  • Persistent py worker: one long-lived Python process (JSON-lines over stdio) replaces subprocess-per-call - 500 calls in 35ms (≈130x+). Interpreter state persists across calls; Python exceptions are catchable without killing the worker; print goes to stderr. Crash → respawn.
  • sleep(seconds) builtin.
  • (in progress: interpreter speed pass)
  • Error-provenance and streaming hardening (external review): failed streaming ai calls now surface their error at first use instead of silently becoming null, and streams resolve in operators/conditions; streaming calls get the same coercion-retry + tracing as sync calls; errors crossing the builtin-callback boundary (map/filter/pmap) keep their line and call stack; contract errors preserve span/stack; retry counts unified (initial + ECKO_AI_MAX_RETRIES everywhere); coercion failures now reach the retry prompt even inside contract retries; string-contract and std.llm.chat calls are traced and budget-counted; std.llm.chat model override no longer mutates global env (pmap race); pmap cancels queued work after the first error and reports panic messages; interpolation/template errors point at real source lines.

v0.6 - "Web, Data & Templates" (branch milestones/v0.6, PRs #4-#5)

  • Triple-quoted strings: """...""" multi-line literals with dedent (leading newline stripped, closing indentation stripped, common indent removed, relative indent preserved). REPL supports multi-line entry.
  • Template engine: template name(params) = """...""" - functions whose bodies are template strings with {expr} interpolation and {for}/{if}/{else} control flow. Directives alone on a line vanish from output. Templates are first-class values and feed ai directly.
  • Named arguments: f(x, tone: "formal") on every function - positional bind first, named by parameter name, defaults fill the rest.
  • ecko fmt: canonical formatter (ecko fmt <files>, --check for CI). Comments preserved; string/template literals byte-for-byte; idempotent; verified to never change program behavior.
  • Licensing: proprietary (LICENSE added; README, extension manifest, and Cargo aligned; publish = false).
  • VS Code grammar 0.2.0: template keyword, triple-quoted strings, {end} directives, let/export/finally.
  • Full HTTP client: http.get/post/put/patch/delete(url, headers: {...}, json: {...}, body: "...", timeout: 30){ status, headers, body }.
  • HTTP server: http.serve(port, handler) - request map in (method/path/params/headers/body/json), response helpers (http.html/json/text/not_found/response), bounded worker pool (ECKO_HTTP_WORKERS), handler errors become 500s.
  • escape_html builtin.
  • Raw strings: r"..." / r"""...""" - no interpolation, no escapes.
  • Vector store: std.db - add/search(query, limit: n)/remove/ count/clear/save/load; provider embeddings via ECKO_AI_EMBED_MODEL, deterministic mock embeddings offline.
  • {input expr} template directive: injection-safe prompt context - values are delimited and embedded closing tags neutralized.
  • Schema-valid typed mocks: ai[T] without an API key returns deterministic values through the real coercion path (Int→42, enums→first variant, structs→mock fields) - typed pipelines fully testable offline.
  • ECKO_AI_MAX_CALLS: hard per-process budget on ai calls.
  • Fixed: \} in strings and templates now produces a literal } (matching \{); literal braces no longer leave stray backslashes.

v0.5 - "Verified & Observable" (branch milestones/v0.5, PR #3)

  • Runtime diagnostics: errors carry line:col spans and a call stack.
  • AI-verified contracts: natural-language @requires/@ensures checked by the LLM; self-correcting retry with failure feedback for ai-bodied functions (ECKO_AI_MAX_RETRIES).
  • Tracing: every ai call logged with call site, provider/model, latency, retries, token usage, and prompt content hash (--trace, ECKO_TRACE for stderr one-liners or JSONL files); process-wide across pmap workers and streaming threads; drainable buffer for tooling.

v0.4 - "At Scale" (branch milestone/v0.4, PR #2)

  • Foundations rework: real control flow (return/break/continue/ loop), shared-scope closures, evaluated match guards, scoped pattern bindings, short-circuit and/or, enforced let/const/mut, field/index assignment, ranges, |x| lambdas, statement termination rules, UTF-8 lexer with spans, line:col parse errors, ~60 builtins, Result/Option built in, full-language integration suite, CI.
  • User modules: import "./file" with export { ... } and circular-import detection.
  • Provider independence: ECKO_AI_PROVIDER = openai | anthropic | ollama; shared runtime; timeouts and retries; mock mode without a key.
  • Parallel + streaming AI: bounded pmap batching (ECKO_MAX_PARALLEL), -> stream.

v0.3 - "Data & Verification"

  • Boolean @requires/@ensures contracts; json<T> generic types with schema generation and coercion; stdlib structured-output types; ecko test --generate; py() Python FFI.

v0.2 - "Scripting with Superpowers"

  • Core language, ai keyword with typed output, provider CLI flags, ecko fix.

Download v0.20.2 →