0.73.0
Changed
- Every program's bytecode is checked before it runs. When a program is compiled, Ecko now verifies how deep the value stack is at every instruction, along with every index into the program's tables and every captured variable. A fault in Ecko's own compiler is refused before your first line runs, instead of stopping the process partway through a job. Programs that ran before run the same way, at the same speed.
0.72.0
Fixed
- A decimal no longer rounds a number that has too many digits. A decimal holds 28 significant digits. Given more,
json.decode(text, decimal: true),decimal("...")and amliteral all used to round to the nearest value that fit and carry on, so0.12345678901234567890123456789012quietly became0.1234567890123456789012345679. Each is now refused:json.decodeanddecimal()raise a catchableparseerror, and the literal is a parse error before the program runs. Zeros at the end of the fraction are not digits you lose, sodecimal("0.10000000000000000000000000000000")still works. - A
test.groupwhose cases failed was reported asokand counted as a passing case. The run still failed, but the report said otherwise: a group holding two failing cases printedok <group>and the summary counted three cases, one of them passed. A group now printsFAIL <group> - 2 of 2 cases failedafter its cases, and only the cases are counted. An error the group body raises outside every case is still reported, as a failure of the group. ecko explainstopped with an internal error on a function whose name starts with a non-ASCII letter. A file definingfn été(x) = xreported a bug in Ecko instead of explaining the program. It now explains it.- Running out of threads is an error, not an abort. When the operating system refused a thread, starting a program, the first
async fncall, the firstaicall, a DNS lookup or a package fetch could stop the whole process with a Rust panic. Each now fails with an error naming what couldn't start; inside a running program, atrycatches it like any other. - A function returned from a
pmapcall used the caller's variables instead of its own. Eachpmapcall works on its own copy of the program's variables, and a function it creates should keep that copy. Called directly, asfs[0]()org(), it read and wrote the caller's variables of the same name instead: below,fs[0]()returned0, not5, and a function that wrotecounterchanged the caller's. It now uses the copy, however it is called, and the caller'scounterstays0.mut counter = 0 fs = pmap([5], fn(i) { counter = counter + i fn() counter }) print(fs[0]()) # 5 - WebSocket connections shared their handler's
mutvariables. HTTP handlers stopped sharing them in 0.69, buton_wsdid not: every connection wrote the same copy, so a per-connection counter kept counting across connections and two connections writing at once raced. Each connection now gets its own copy when it opens and keeps it until it closes. Use acellfor state every connection should see.mut sent = 0 http.serve(8080, handler, on_ws: fn(c) { sent = sent + 1 ws.send(c, string(sent)) # 1 on every new connection })
0.71.0
Changed
- An error's location and call stack are the same whichever way your code runs. A function with
@requires/@ensures, anasync fnand code in an imported module run on a different engine from the rest, and they reported errors differently: columns pointed at a different character, a frame could appear or not, and a function called through a variable was named after the variable. Both engines now follow one set of rules: - a call is located at what it calls (innerininner(x), the.ofh.f(v)), not its(. Columns in error output and instd.log'slocation: truefield move to match; - a call rejected before the function runs (wrong number of arguments, a parameter's type,@requires) or as it returns (the return type,@ensures) is reported at the call, without a frame for the function; a type error used to point at the declaration inside the function; - a frame is named after the function, whatever variable held it. A pipeline into something that is not a function now saysCan't call inteverywhere, as a direct call does.
Performance
- Arithmetic, loops and calls are 7-24% faster. When the interpreter produced a number, it wrote it to memory in two pieces, and the next step, reading it back in one piece, had to wait for both writes to land - several times per loop iteration. Numbers and booleans are now written in one piece on x86-64. Measured on the benchmark set: plain loops about 24% faster, counting and arithmetic-heavy code 15-20%,
foldandmapabout 13%, function calls 7-12%,fibandcollatzabout 10%. A loop that mostly reads fields of a record is unchanged to 4% slower. ARM builds are unchanged. - Top-level code runs at full speed in files that define functions or import modules. One
fn, lambda orimportanywhere in a file used to make every top-level variable go through a slower lookup, so a loop written at the top of a script ran up to 13x slower than the same loop in a file without one, and a singlemap(xs, fn(x) ...)slowed the rest of the top-level code about 5x. Variables that no function uses now stay fast. On a script shaped like a data job (a top-level loop calling helper functions) that is about 2x faster overall; a loop calling a function from the top level is about 4.5x faster. Nothing changes in what a program does. Code that usesaiorpykeeps the old behaviour for now. - Reading a field of a local map or struct is about 1.8x faster.
p.xcopied the whole value onto the stack to read one field out of it; it now reads the field where it is. - Small maps and structs are faster to read and build. A map or struct with up to 16 keys, which covers nearly every record and JSON object, is now stored as a short sorted list instead of a hash table. A field read on one is about 20% faster on top of the change above, and record-heavy scripts about 5-7% faster overall. Larger maps work as before. Printing,
keys,for (k, v)and JSON still give keys in sorted order, as they always did. - Adding to a number in a loop is faster.
total = total + p.x,sum += xs[i] * 2and similar lines, where the right side is more than a plain variable, now update an integer or float total in place. Integer totals are about 3-9% faster per loop and float totals about 19% faster. The field-read benchmark, which adds three fields per pass, is about 21% faster. Results are unchanged, including overflow errors and floatinf/NaN. pmapscales with the number of cores instead of slowing down. With a cheap callback, every worker queued on a few shared locks and counters for each item, so adding cores made it slower: on a 32-core machine, doubling four million numbers took 3.6 seconds against 0.18 formap. It now takes 0.18 seconds. The workers keep their own results until the end, read theECKO_LIMIT_*settings once instead of per item, reuse one call setup for a compiled callback, and skip restoring a captured variable that did not change.http.servehandler threads use the same per-thread copy and get the last of these too. Results, ordering, errors and the share-nothing rules are unchanged.- Calling a function held in a variable is faster. A callback passed as a parameter (
fn apply(f, x) = f(x)), a function stored in a local, or a loop that callsf(acc, x)used to copy the function onto the stack and then shuffle the arguments to take it back out on every call. A loop that does little more than call a function through a variable now runs about 22% faster. Calls by name are unchanged. - Reading fields of a record in a loop is faster, most of all on wider records. Each
p.xon a variable now remembers where it foundxlast time and looks there first, and works out its lookup key once instead of on every read. Reading the last fields of a 12-key record is about 30% faster, a 3-key record about 7%, and JSON round trips about 2%. Records over 16 keys and code that reads one field from many differently shaped records work as before, including acrosspmapworkers.
Fixed
sort_withsorts by a Float comparator. A comparator returning a Float was truncated to a whole number, sosort_with([0.3, 0.1, 0.2], fn(a, b) a - b)treated every difference under 1 as "equal" and returned the list unsorted. Only the sign is read now. A comparator that returns NaN is an error.cosineis computed in full precision and handles large values. It worked in 32-bit floats, so results were off in the seventh digit, and vectors with elements past about 1e19 overflowed and came back0.cosine([1e300, 0.0], [1e300, 0.0])is1now. Scores you print may differ from earlier releases in their last digits.- A
forloop over a range of more thani64::MAXvalues runs.for i in -9223372036854775807..9223372036854775807ran zero times, because its length did not fit and wrapped negative. time.formatandtime.format_localrefuse a Float that is not a time. NaN formatted as the epoch, and a value out of range as some other date. Both are errors now; an ordinary Float is still truncated to whole milliseconds.- Counts no longer wrap in the browser playground. On a 32-bit build, a count past about four billion -
repeat,"ab" * n, asplitlimit, a pad width,chunk,window, a channel's capacity - wrapped to a small number and the call quietly did something smaller than it was asked. A count that big is now too large, or treated as unlimited where that is what it means.
0.70.0
Added
- Every syntax error in a file is reported, not just the first.
ecko check, running a file, and your editor's diagnostics used to stop at the first mistake, so a file with three took three rounds to fix. Now the parser skips past each mistake and keeps reading, and all of them are listed in source order (up to 25). A file with one mistake reads exactly as before. A mistake that breaks the lines after it, like a missing bracket or comma, is still reported once.
Fixed
ecko fmtkeeps a comment inside a list, map or argument list where you wrote it. A comment between two entries used to leave the literal: it went to the end of the statement's first line or below the whole statement, where it described a different line. Such a literal now prints one item per line, each comment above or after the item it was written against. Files already formatted by an earlier release are unchanged.ecko fmtgives the same output when run twice on a few more shapes. A bare-expression branch that prints over several lines (else -{ ... }), a comment trailing a one-lineif x { ... }, and a comment beside a statement that starts with a block (for ... { } + x) each changed again on a second run, soecko fmt --checkfailed on the formatter's own output.
0.69.0
Added
test.skip(reason)marks a test case as skipped. A case that cannot run where it is running, because it needs root, a network or a missing tool, used to return early, andecko testreported itok. Now it printsSKIP <name> - <reason>, and the summary counts skips apart:3 case(s): 2 passed, 0 failed, 1 skipped. A skip does not fail the run. The reason is required. The skip throws an error with the new kind"skip", so the rest of the case does not run.hash.sha512,hash.sha3_256andhash.blake2b, each with a_bytesform for the raw digest, for formats and protocols that name those algorithms. Likehash.sha256, they take a string, bytes or a stream, sohash.sha512(fs.open(path))hashes a file of any size.hash.blake2bis the full 512-bit BLAKE2b and matchesb2sum.hash.hashernow also takes"sha512"and"sha3_256"; there is no BLAKE2b hasher.
Fixed
try … finallywith nocatchno longer swallows the error. Thefinallyran and then the program carried on as if nothing had failed, withnullas the value of thetry. Now the error reaches the caller after thefinallyruns, with its value andkindunchanged, as in other languages.ecko checkalso counts the error kinds that escape this way. Migration: code that relied on the old behaviour to ignore an error needs acatch:try { … } catch { null } finally { … }. A baretry { … }with neither clause is unchanged.
0.68.0
Added
fs.exists(p, follow: false)sees the link itself.fs.existsfollows a symlink, so a dangling link, or a lock held as a symlink, reportedfalse. Withfollow: falseit answers for the link. The default is unchanged.ecko -e '<code>'runs code given on the command line, andecko -reads the program from stdin, likepython -candpython -. Both get the same pre-run check,ecko.jsonand exit status as a file run, and everything after the code is the program's arguments.-eused to be read as a file name.fs.create_new(path, data)creates a file only if nothing is at the path, returningtruewhen it did andfalsewhen the path was taken, so exactly one of two racing processes gets the lock.fs.open(path, "x")is the stream form and throws{ kind: "fs" }when the path is taken.fs.owner(p)andfs.chown(p, uid, gid)read and set a file's owner.ownerreturns{ uid, gid }; both takefollow: falseto act on a symlink itself. Unix only; on Windows they throw{ kind: "fs" }.- Incremental hashing:
hash.hasher,hash.update,hash.finish. Hash data a piece at a time as it passes through your code, so a file can be uploaded in parts and hashed whole from one read.hash.hasher("sha256")(or"sha1","md5") starts one,h = hash.update(h, piece)returns a new hasher, andhash.finish(h)orhash.finish_bytes(h)gives the digest. pmap(list, f, limit: n)sets the number of workers for one call.pmapruns one worker per core, which suits CPU-bound work; for uploads, HTTP or model calls the right number has nothing to do with cores, so the same program ran at a different concurrency on each machine.limit:is 1 to 1024 and overridesECKO_LIMIT_PARALLELfor that call.
Changed
minandmaxtake values as well as a list.max(3, 7)andmin(a, b, c)work;max(xs)andmin(0..10)are unchanged.min(3, 4)used to be an arity error. A list among other arguments,min([3, 1], 99), is still refused.ecko check'sfloat-boundfollows a/through a variable and intomax/min.count = n / 2thenrange(0, count)fails every run, likerange(0, n / 2), but only the second was caught; the finding is now at the use and names the line of the/. A bound frommax(1, n / 2)is a Float when the division wins, and is reported as one that "can be" a Float.
Fixed
- A statement from another task no longer runs inside a transaction on a shared db handle. When two tasks shared one handle, a plain
sql.execorsql.queryfrom the second ran inside the first one'ssql.transaction, so a rollback silently discarded the second task's write. It now waits for the transaction to finish, up to the handle's busy timeout, then runs on its own; past the timeout it fails with{ kind: "sql" }. If a transaction waits on a task that uses the same handle, that task's statement now fails at the timeout instead of joining the transaction; give the task its ownsql.open. "{", "}"is two strings, and"{" + x + "}"a concatenation. A{that started a string balanced against the}of a later string on the line, and everything between became the text of an interpolation hole: the list was one element short and gained the text,, and the concatenation printed+ x +, with no error. A{followed at once by its string's closing quote is now a literal brace. Holes after other text, and a literal hole followed by more text ("{"}"} b"), are unchanged.- An empty request body sends
content-length: 0.http.put(url, body: bytes("")), an empty string body and a POST with no body went out with no length at all, and S3 refuses those (411 MissingContentLength): every empty file, copy and multipart start failed against it. fs.matchcompiles each pattern once. It built a new regex on every call; matching 642,000 paths against 8 globs spent 205 s there. It now sharesstd.re's cache, and costs the same asre.test.
0.67.0
Added
io.limit(stream, n)is a stream over the nextnbytes ofstream, then the end. The stream underneath is left exactly that far on, so consecutive limits cut a file into parts, and a limit works anywhere a stream does: as an HTTP request body, inhash.sha256, inio.read. Over a file it knows its length, sohttp.put(url, body: io.limit(s, part_size))sendscontent-lengthand uploads one part of a large file without reading it into memory. Closing a limit does not close the stream underneath.fs.walk(dir, on_error: fn(e) ...)lists everything it can read instead of stopping at the first directory it cannot. Each unreadable directory goes to the function as the{kind: "fs", message, path}the walk would have thrown, its contents are left out, and the walk carries on. Withouton_error:the walk still throws.ecko checkwarns when a file assigns to an imported name (shadows-import):json = xafterimport std.json, at the top level or inside a function. Writelet json = xif the shadow is deliberate.
Changed
- Assigning to an imported name shadows it, as assigning to a built-in name does. Inside a function in an imported module,
json = "text"used to replace that module'sjsonfor every later call, sojson.encodefailed with "Can't access field 'encode' of string" somewhere else entirely. It now makes a local of the function, which is what the same code already did in the file you run. Migration: a module that replaced one of its imports from inside a function on purpose keeps the replacement in its own top-levelmutbinding and assigns that instead.
Fixed
- A built program can use a package from any file in the project.
ecko buildproduced a binary that failed at start with "import 'xml': needs an ecko.json declaring it as a dependency" whenever a file below the project root, such assrc/s3.ecko, imported a package. Only the entry file's package imports worked. io.read(s, n)is documented as one read, which can return fewer thannbytes before the end on any stream. On a file one read takes at most 1 MiB. Useio.read_exactwhen the count matters.ecko build -o /abs/pathprints a runnable path. The hint readRun it with .//abs/path; it now adds./only in front of a bare file name.pmapand extra HTTP workers now scale when the work calls a function. Running a lambda throughmap,filterorreduceon several threads at once made the threads queue behind one another.pmapover CPU-bound work ran no faster thanmap, and a handler that mapped a lambda served about the same requests per second on 8 workers as on 1. On a 32-core machine, thatpmapis now about 16x faster thanmap. The handler serves about 4x as many requests on the default 8 workers, at a third of the p99 latency.
0.66.0
Added
fs.readlink(path)returns where a symlink points, exactly as stored, even when the target does not exist.fs.set_modified(path, ms)sets a file's modification time (unix milliseconds, the unitfs.modifiedreturns). It needs the same permission as writing the file.fs.walkentries say what they are. Each is now{path, is_dir, kind, size, mtime}, wherekindis"file","dir","symlink"or"other".walkdoes not follow symlinks.ecko test --jobs Nruns up to N test files at once. Output is buffered per file and printed in file order, so the report is the same as a sequential run. The default stays 1.;separates statements.for i in 0..3 { a(i); b(i) }is now valid instead of a parse error, at top level and in every block (function, loop, branch and match-arm bodies). A trailing;is accepted. Inside(...),[...]or a map literal,;is still an error, with a message that says so.ecko fmtputs each statement on its own line, so it removes the;s andecko fmt --checkreports a file that uses them as unformatted.hash.md5(data)andhash.md5_bytes(data)compute MD5 for checksums and for protocols that require it. They take a string, bytes or a stream likehash.sha256, sohash.md5(fs.open(path))checksums a large file without reading it into memory. S3'sContent-MD5header isencoding.base64_encode(hash.md5_bytes(data)). MD5 is not secure; usesha256for anything that must resist tampering.hash.encrypt,hash.decryptandhash.keyadd authenticated encryption.hash.key()returns a random 32-byte key;hash.encrypt(key, data, aad: ctx)returns sealed bytes (ChaCha20-Poly1305, a fresh random nonce per call, a leading version byte so the format can evolve);hash.decrypt(key, sealed, aad: ctx)returns the original bytes. A wrong key, a differentaad:, or any altered or truncated data throws an error with the new kind"crypto", and a key that is not exactly 32 bytes is an ordinary argument error.datais bytes or a string; streams are not accepted.http.head(url)asks for a resource's status and headers without downloading it - how S3 and other storage APIs report an object's size and metadata. It takes the same options ashttp.get; the response'sbodyandbody_bytesare empty.- An HTTP request
body:can be a byte stream.http.put(url, body: fs.open("backup.tar"))uploads the file as it is read, so it no longer has to fit in memory. A file's size is sent ascontent-length, which storage APIs such as S3 require; a stream that cannot say how long it is is sent chunked. http.responsetakesheaders:by name.http.response(201, body, headers: { location: "/x" })works like the client verbs; the positional formhttp.response(201, body, { ... })still does. Previously the named form failed with "doesn't accept named arguments".
Changed
- The HTTP client reuses connections. Each call used to open a fresh connection, so a loop of requests to one host paid a TCP and TLS handshake every time. Calls now share a connection pool for the life of the program; nothing changes in how you call it.
Fixed
- Building a collection with
set,pop,take,droporremovein a loop is linear.m = set(m, k, v)copied the whole map on every call, so indexing 8,000 files took a second and 100,000 took minutes;xs = pop(xs)andxs = take(xs, len(xs) - 1)had the same cliff. They now update the collection in place when nothing else holds it, aspushalready did: 100,000sets take milliseconds. A failed call caught bytryalso leaves the variable as it was -m = insert(m, 1, "v")used to leavemnull. - Non-ASCII HTTP response headers are no longer dropped. A header such as
x-amz-meta-path: /home/me/Músicaused to arrive as"". Values are now decoded as UTF-8, or as Latin-1 when they are not valid UTF-8. fs.walkno longer skips directories it cannot read. A directory without read permission used to come back as an empty entry, so a backup or scan finished without telling you part of the tree was missing. It now throwskind: "fs"with thepath. Migration: wrap the walk intryif you relied on it passing over unreadable directories.- File names that are not valid UTF-8 can be opened again.
fs.list_dirandfs.walkreplaced the bad bytes with U+FFFD, so the listed name pointed at a file that did not exist. Each such byte now appears as one private-use character (U+F780 to U+F7FF) that everyfsfunction maps back to the original byte. Names that are valid UTF-8 are unchanged. Migration: code that compared a listed name against U+FFFD should compare against the private-use form, or not at all. fs.matchwith its arguments swapped now fails.fs.match("*.txt", "a.txt")answeredfalsefor every input. It now throws when the second argument has no wildcard but the first does. The call isfs.match(path, pattern). Migration: swap the arguments.ecko buildbundles what your program imports, even without anecko.json.ecko build main.eckoused to pack onlymain.ecko, so a program withimport "./src/args.ecko"failed when the binary started. The build now follows relative imports andexport * fromre-exports recursively and bundles every file it reaches. If an import names a file that does not exist, the build stops with an error naming it. When an import is missing from a bundle at run time, the error now says why and what to do (for example, runecko initto bundle the whole project).proc.killkeeps the handle, soproc.waitstill returns the exit status.killused to reap the child, soproc.wait(p, 5000)afterwards failed with "process is finished or was never started" and the status was lost. Nowkillonly sends the signal;waitcollects the exit code (-1if a signal ended the process). Code that never callswaitafterkillworks as before.ecko testruns each file in its own process. Two test files used to share one process, so they printed the sameos.pid(), and two files that each started an in-processhttp.servefake server could interfere with each other through leaked module state. Every file is now isolated. The report, summary and exit code are unchanged.ecko checkand the pre-run gate follow local imports. A mistake in a file you import (an unknown module member, say) used to surface only when that file was checked itself, or at runtime. Both now analyze the entry file and every local.eckofile it imports, reporting each finding under its own path. Vendored code is not followed, and import cycles are safe.- A non-function literal passed where a builtin needs a function is an error (
callback-type-mismatch), with a hint when the arguments look swapped, e.g.reduce(xs, {}, fn(acc, k) ...). - Assigning to a function's name says so.
immutable-reassignon afnname now explains that inside another function the assignment reaches the function rather than making a local, and suggests another name. - Sending
bytesas an HTTP request body sends the bytes.http.put(url, body: fs.read_bytes("photo.jpg"))used to send the printed form of the value (b"\x89PNG...") instead of the file, so uploads of anything but text arrived corrupted without an error. A string body is still sent as its UTF-8 text. ecko fmtkeeps comments on the code they describe. A comment on amatcharm, on a closing}, above an arm, at the end of a block, or after a function with an@attributeused to move to the first line of the statement or out past its end - where it read as a note about different code."parse" => {} # a bad file means no settingsnow stays on its arm,} # doneon its brace, and} else { # otherwiseon theelse. Files that were already formatted are unchanged.
0.65.0
Fixed
pmapandhttp.servehandlers no longer sharemutvariables. The documented model is share-nothing, but a callback that wrote a capturedmutwrote the caller's variable from every worker at once: the result changed from run to run, and could fail with "Can't add null and int".mut count = 0thenpmap(range(0, 2000), fn(i) { count = count + 1 })inside a function leftcountanywhere from about 700 to 1,000. Now each call works on its own copy, starting from the state before the map (or the server) started: the caller'scountstays 0, and a handler that adds one to amutcounter sees 1 on every request. Migration: a program that counted or collected acrosspmapcalls or requests through a plainmutwas relying on the race. Use acell:hits = cell(0),cell_update(hits, fn(n) n + 1),cell_get(hits)- or collect the valuespmapreturns.
0.64.0
Added
printtakes any number of arguments, joined by a space:print("total:", n)printstotal: 3. With none it prints an empty line.print_no_newlineandio.printjoin the same way.T?is the nullable annotation: aTornull. It works on parameters, return types, record fields, inside generics and inai[T?]:fn port(p: Int? = null) = p ?? 8080 type Contact { name: String, email: String? = null }- A record is written once:
type User { name: String }is the same type astype User = User { name: String }, andecko fmtnow prints a single variant named after its type in the short form. ai[T] "prompt" votes 5names the vote count after the prompt.ecko checkwarns when a condition is plainly not a Bool (non-bool-condition).if count { }skips at zero andif name { }at"", and the line does not say whether that was meant; the warning names the comparison to write (count != 0,len(xs) > 0). Truthiness itself is unchanged, and is now written down in one place:false,null,0,0.0,0m,"",b"",[],{}and an empty range are false.- A misspelt assignment that declared a new name is an error (
likely-typo). Inside a loop or branch,totl = total + nused to declare a new local and leavetotalunchanged, so the program printed a wrong answer with only an unused-variable warning. When the new name is never read and is one slip from amutin scope, the program no longer starts, and the error names themutit probably meant. if x = 2says to write==instead of "Unexpected token: '='".
Deprecated
Option<T>, in favour ofT?. It still means "T or null";ecko checkwarns, andecko fmtandecko fix --migraterewrite it.- The bare vote count
ai[T] 5 "...", in favour ofai[T] "..." votes 5.ecko fmtandecko fix --migratemove it.
Changed
ecko fmtoutput changed for records (type User { ... }),Option<T>(nowT?) and vote counts (nowvotes Nafter the prompt), soecko fmt --checkreports files that use them. Migration: runecko fmton those files, orecko fix --migrateto touch only the deprecated forms. A program refused withlikely-typoeither meant themutthe error names, or keeps its new local by reading it or prefixing it with_.
0.63.2
Changed
- Ecko is now free for any use, personal or commercial.
LICENSEno longer limits business use to a 30-day evaluation, and there is no commercial licence to buy. Ecko stays proprietary, not open source.
0.63.1
Added
ecko checkwarns about a/where only an Int can go (float-bound). A range bound, a slice bound, and the countsrange,take,dropandslicetake must be whole numbers, and/always returns a Float, so the line fails every time it runs - often in a branch your tests do not reach.ecko checknow says so before it runs:
Writefn first_half(xs) = take(xs, len(xs) / 2) print(first_half([1, 2, 3, 4])) # warning[float-bound]: the count passed to take() must be an Int, but '/' # always returns a Float; write '//' to divide to an Int//. A list index is not checked, because a map accepts a Float key.
Changed
- Ecko no longer describes itself as "the runtime that doesn't crash".
ecko --help, the README and the documentation now open with "a small language for work that is not", and say plainly that Ecko is not crash-free yet: what it does promise is that failures are values you can catch, mistakes fail at the line that made them, input has limits, and an internal fault is reported as a bug in Ecko. Nothing about how programs run has changed. - Arithmetic-heavy loops run faster in the release binaries. The published builds are compiled with an earlier Rust compiler that generates faster code for Ecko's interpreter: a trial-division prime count runs about 25% faster and the benchmark suite about 6% faster overall on x86-64 Linux, with no change to what any program does.
0.63.0
Added
ecko checkwarns about a/that should be//(exact-division). Since 0.51,/always returns a Float. A/left over from before then, where a whole number was meant, usually fails loudly withneeds an integer, but not always: if the value only feeds more arithmetic, the program keeps working and gives the right answer, just slower, because every later step runs in floating point. A loop that halves a number this way can take 1.7 times as long, with nothing to say why. The warning fires where the code itself proves the division is exact - inside the branch of anx % k == 0check:
Elsewhere afn steps(n) { mut x = n mut count = 0 while x > 1 { if x % 2 == 0 { x = x / 2 } else { x = 3 * x + 1 } count = count + 1 } count } print(steps(27)) # warning[exact-division]: 'x % 2 == 0' makes 'x / 2' exact, but '/' always # returns a Float; write 'x // 2' to keep an Int/may be meant to give a fraction, so it is not flagged. If you upgraded from before 0.51, search your code for/between whole numbers whose result is only ever used as a whole number.
Changed
- A non-exhaustive
matchis an error. Amatchover a declared type that leaves a variant without an arm, and has no_, now stopsecko checkand keepsecko file.eckofrom starting. It used to be a warning: the program started, and failed only when the missing variant actually arrived.
A guarded arm (type Status = Active | Suspended | Closed fn label(s) = match s { Active => "on" Suspended => "paused" } # error[non-exhaustive-match]: match over Status does not cover: ClosedClosed when old => ...) does not cover its variant, since the guard can fail. Migration: add the missing arms - your editor offers to add every one - or a_ =>arm for the rest.
0.62.0
Removed
- The demo types
Sentiment,ClassificationandEntityare no longer built in. Every program had them, with their variants (Positive,Spam,Person, ...), so those common names were taken before you wrote a line. Declare the set you want instead;ai[T]extracts into it exactly as before. Migration: add the declaration to any program that used one without declaring it:
The error names the declaration for each of the three.type Sentiment = Positive | Negative | Neutral
Fixed
ai[T]with a type that does not exist is an error, notnull. A typo such asai[Sentimnet], or a type the program never declared, used to send an empty schema and returnnullwith no complaint. It now fails, naming the type:ai[Sentimnet]: there is no typeSentimnet- declare it withtype Sentimnet = ....
0.61.0
Removed
- Six standard-library modules, deprecated in 0.59, are gone. Importing one is now an error that says what to use instead: | removed | use instead | |---|---| |
std.humanize| thehumanizepackage - same functions, same answers | |std.cli| theclipackage 1.0 - same spec,parseandhelp| |std.debug|type_of(v),time.monotonic(),fmt.inspect(v)| |std.db,std.rag| theembed,cosineandaibuiltins they were built on | |std.serial| nothing in the standard library | Migration: for the two packages,ecko get github.com/ecko-lang/humanizeorecko get github.com/ecko-lang/cli, thenimport humanizeorimport cliin place ofimport std.humanizeorimport std.cli. For retrieval, embed your documents once and rank them bycosine- the Embeddings & RAG page shows the dozen lines that replacestd.db. - The styling half of
std.term, fourstd.fmtfunctions andimage.freeare gone. They were deprecated in 0.59. Calling one is an error naming its replacement, fromecko checkand at run time:term.bold,term.red,term.style,term.goto,term.widthand the other string builders (44 in all): the functions of the same names in thetuipackage.term.is_tty,size,color_enabled,raw_mode,pollandread_keystay.fmt.pad_left,pad_right,repeat,truncate:str.pad_start,str.pad_end,str.repeat, and a slices[..n].image.free: nothing - an image is a value; delete the call. Migration:ecko checklists every call with its replacement.
- The setting names used before 0.58 are no longer read.
ECKO_API_KEY,ECKO_TRACE,ECKO_MAX_DEPTHand the other renamed settings worked, with a warning, through 0.60. Now one that is still set prints the name that replaced it, and is otherwise ignored - so a limit or a key set under an old name is no longer in force. Migration: rename the variable in your shell, CI andecko.json:ECKO_API_KEY->ECKO_AI_API_KEY,ECKO_TRACE->ECKO_AI_TRACE,ECKO_MAX_*->ECKO_LIMIT_*(orECKO_HTTP_MAX_*for the server caps),ECKO_ALLOW_HTTP->ECKO_NET_ALLOW_HTTP,ECKO_NET_TOKEN->ECKO_PKG_TOKEN. The environment reference lists all seventeen.
0.60.0
Changed
- A declared return type is now checked.
fn f(x) -> Intused to be accepted and ignored, so it could say something untrue. Every value the function returns, its last value and eachreturn, must now fit the type, by the same rules as a typed parameter:Intwidens toFloat,Option<T>takesnull, a record type compares by name. A mismatch is a catchable error,fis declared to return Int, but returned string, raised before any@ensuresruns.ecko checkrefuses a literal that can never fit, such asfn f() -> Int = "x".
Migration: a function whosefn label(n) -> String { if n < 0 { return n } # now an error for label(-1) "n={n}" }-> Twas wrong now fails where it used to return quietly. Correct the annotation, or remove it. A function that can end without a value needs-> Option<T>. - Each
foriteration gets its own loop variable. A closure created in a loop used to see the loop's last value, because the variable was one binding shared by every iteration:
Only the loop variable changed. Amut fs = [] for i in [1, 2, 3] { fs = push(fs, fn() i * 10) } map(fs, fn(g) g()) # now [10, 20, 30]; was [30, 30, 30]mutdeclared outside the loop is still one variable shared by every closure. Loops whose body makes no closure behave and perform exactly as before. Migration: code that relied on a closure seeing the loop's final value should capture it explicitly: assign it to a variable declared before the loop and read that instead.
Fixed
ECKO_AI_MAX_CALLSno longer shuts a server'saioff for good. It counted every call the process ever made, mock calls included, so a server withECKO_AI_MAX_CALLS=50refused everyaicall from its 51st onward until it was restarted. Insidehttp.serveit now limits each request, including thepmapworkers, tasks and tool rounds that request starts. A WebSocket handler gets a fresh count for each messagews.recvreturns. A script still counts its whole run. Thebudgeterror gains ascopefield"request","message"or"process"- and its message says... calls made in this request.
0.59.2
Added
location: trueon a log sink names each line's call site.log.to_stderr(location: true)ends every line withat=app.ecko:12:5; a JSON sink gets an"at"field. A call inside an imported module names that module's file. It also works onlog.to_fileand inlog.configure's sinks. Sinks without it, the default one included, print exactly what they did.
Fixed
forover a string or a map is linear. Each step used to start again from the beginning - a string re-walked its characters, a map re-sorted its keys - so the loop's cost grew with the square of the size: a 20,000-character string took 7x longer than it does now, and a 3,000-key map about 6x. The order is unchanged, and so is the map you are looping over.s[i]ands[a..b]on a string no longer copy the whole string. Each access built a list of every character in the string first; now it walks only as far as the answer, with a fast path for ASCII text. Indexing a 20,000-character string in a loop is about 10x faster. For visiting every character,for c in sis still the linear way.- A function that creates a lambda no longer slows down every variable in it. Any lambda inside a function used to put all of that function's local variables into shared cells, even when the lambda used none of them - so a loop in a function that also called
sort_by(xs, fn(r) r.age)ran 2-3x slower than the same loop without it. Now only a lambda that actually uses one of the function's variables does that. Nothing about what a closure sees has changed. - A positional pattern on an imported type matches.
match o { deals.Offer(a, b) => a + b, _ => -1 }returned-1for adeals.Offer(1, 2): the arm never matched, so thematchfell through to whatever came next, and without a_it failed with "Non-exhaustive match". The bareOffer(a, b)failed with "the variant has 1 fields". Both now bind by the field order the module declared, including throughexport * from. The brace form (deals.Offer { a, b }) was unaffected. - A positional pattern on a type nothing exports is an error, not
null. When a module builds a value of a type it does not export,Ghost(x)boundxtonulland carried on. It now stops with a message to export the type or use the brace form,Ghost { name }. A bare name that two imports export with different fields is refused the same way; qualify it. ecko --helpno longer saysECKO_LLM_API_KEYis accepted. It was removed in 0.58, and setting it prints what replaced it; the help still advertised it. UseECKO_AI_API_KEY.
0.59.1
Fixed
ecko checkno longer warnsunit-changed. It flagged everysleep,time.monotonic,debug.timer/debug.elapsed,humanize.durationandhttp.*timeout:call, including correct ones, and nothing could silence it - soecko check --strictfailed on any program that slept. It was meant for 0.58 only. If you are upgrading from 0.57 or earlier,ecko fix --migrate --only=msstill converts code written for seconds.
0.59.0
Breaking
- An image is a value.
image.load,image.decode,resizeandcropreturn a map{ format, width, height, bytes }instead of an integer handle into a process-wide registry. Nothing needs freeing, an image can cross a channel or a task, and no number can reach someone else's image. Everystd.imagefunction takes the value as it took the handle, so most code is unchanged. A transform's result is stored as PNG, so chaining steps on a JPEG loses nothing until youencodeit. Decoding refuses more pixels thanECKO_LIMIT_ALLOC, andresizerefuses an output that large, instead of trying the allocation. Migration: code that treated an image as a number, or passed one toai ... onas anInt, passes the value itself.image.freedoes nothing now and can be deleted. cost(model, in, out)is removed. Its built-in price table was out of date, and an estimate presented as a price is a quiet wrong answer.cost(in_tokens, out_tokens, in_per_1m_usd, out_per_1m_usd)remains;--traceandecko profilereport tokens and no longer show an estimated dollar figure. Migration:cost("gpt-4o", n, m)becomescost(n, m, 2.5, 10.0)with your provider's current prices.
Deprecated
These still work in this release, and ecko check warns at each use with the replacement (deprecated-module, deprecated-member). The next breaking release removes them.
- Modules:
std.humanize→ thehumanizepackage, with the same functions (ecko get github.com/ecko-lang/humanize).std.cli→ theclipackage 1.0, with the sameparseandhelp(ecko get github.com/ecko-lang/cli).std.debug→type_of,time.monotonicandfmt.inspect.std.serial,std.dbandstd.rag→ no replacement in the standard library.dbandragwere written over theembed,cosineandaibuiltins, which remain.
- Members:
std.term's 44 styling and control functions (bold,red,rgb,style,goto,clear,strip,width, ...) → the same names, with the same output, in thetuipackage from 0.39.termkeepsis_tty,size,raw_mode,poll,read_keyand the newcolor_enabled.fmt.pad_left,fmt.pad_right,fmt.repeatandfmt.truncate→str.pad_start,str.pad_end,str.repeatand the slices[..n].image.free→ nothing; see above.
Added
fmt.inspect(v): the typed, recursive rendering that wasdebug.inspect, with types shown, keys sorted and secrets never revealed.term.color_enabled(): whether to write colour codes, followingCLICOLOR_FORCE,NO_COLORand whether stdout is a terminal.
Fixed
web.staticneedsfs:readon the directory it serves. It used to need onlynet, so a package granted the network could publish any directory over HTTP.
0.58.0
Breaking
- Every duration is milliseconds. Five APIs used seconds while everything else used milliseconds. They keep their names and now take or return whole milliseconds: | API | was | now | |---|---|---| |
sleep(x)| seconds, Int or Float | milliseconds, Int | |time.monotonic()| Float seconds | Int milliseconds | |debug.timer()/debug.elapsed(t)| Float seconds | Int milliseconds | |humanize.duration(n)| seconds | milliseconds ("250ms"under a second) | |http.*timeout:| seconds, Int or Float | milliseconds, a positive Int | A Float is refused, sosleep(0.5)andtimeout: 1.5fail with the millisecond spelling in the message. Anhttptimeout:of zero, a negative number or a non-number is now an error; it used to become 60 seconds without a word. New:time.monotonic_ns(), the same steady clock in nanoseconds. Migration: a whole number such assleep(2)still runs, and now waits two milliseconds. Runecko fix --migrate --only=ms src/once: it turnssleep(2)intosleep(2000)andtime.monotonic()into(time.monotonic() / 1000), so code written for seconds means what it did. For this releaseecko checkwarnsunit-changedat every call to one of these five. - Options are named arguments only. A built-in's options used to arrive either as named arguments or as a map, depending on the function. Several accepted only the map, so
proc.run(cmd, args, timeout_ms: 5000)was an error while the map form worked. Now every option is named, and a map in its place is refused with the named spelling:
This coversproc.run("ffmpeg", args, timeout_ms: 5000) sql.open("app.db", busy_timeout_ms: 10000) json.decode(text, decimal: true) log.to_file("app.log", level: "warn", json: true)http.get/post/put/patch/delete/serve,proc.run,proc.spawn,sql.open,net.connect_tls,net.starttls,log.to_file,log.to_stderr,json.decode,json.read,csv.each,archive.tar_*,term.style,term.read_keyandwatch.open. Two mistakes that were silently ignored are now errors that name the problem: an option the function does not have (timout_ms, with a did-you-mean), and a value of the wrong type (timeout_ms: 1.5used to mean no timeout at all).ecko checkreports both before the program runs. Maps that are data, such ashttp.responseheaders and log fields, are unchanged. Migration: changef(x, { name: value })tof(x, name: value).net.connect_tls(host, port, false)never turned verification off; writeverify: false. - Environment variables are renamed to
ECKO_<AREA>_<SETTING>. The old names still work for this release, with a warning naming the new one, and the new name wins when both are set: | old | new | |---|---| |ECKO_API_KEY|ECKO_AI_API_KEY| |ECKO_TRACE|ECKO_AI_TRACE| |ECKO_MAX_DEPTH,_STEPS,_ALLOC,_PARSE_DEPTH,_TASKS,_QUEUED_TASKS,_PARALLEL|ECKO_LIMIT_DEPTH,_STEPS,_ALLOC,_PARSE_DEPTH,_TASKS,_QUEUED_TASKS,_PARALLEL| |ECKO_ARCHIVE_MAX_UNPACKED|ECKO_LIMIT_UNPACKED| |ECKO_MAX_CONNS,_STREAMS,_WS_CONNS|ECKO_HTTP_MAX_CONNS,_STREAMS,_WS_CONNS| |ECKO_ALLOW_HTTP|ECKO_NET_ALLOW_HTTP| |ECKO_NET_TOKEN,ECKO_NET_TOKEN_HOSTS,ECKO_GIT_BASE|ECKO_PKG_TOKEN,ECKO_PKG_TOKEN_HOSTS,ECKO_PKG_GIT_BASE|ECKO_LLM_API_KEYandECKO_FUZZ_TRACEare removed; if one is still set,eckosays what replaced it. Migration: rename the variables in your shell profile, CI secrets andecko.jsonbefore the next release, when the old names stop working. - An invalid setting stops the run.
ECKO_LIMIT_DEPTH=lotsused to be ignored silently in favour of the default; noweckorefuses to start, naming the variable and what it should be (exit 2). A safety limit - depth, allocation, unpacked size, connection caps - may not be0. For every timeout0now means off:ECKO_HTTP_READ_TIMEOUT_MS=0andECKO_PY_TIMEOUT_MS=0used to mean one millisecond, andECKO_AI_READ_TIMEOUT_MS=0used to mean the default. - The shell wins over
ecko.json'senvironmentblock. An entry now applies only to a variable that is not already set, the same rule asstd.config. It used to override what you had exported. Migration: a project that relied on the block overriding the shell should have its users unset the variable, or set it in the program. - Server settings are
http.serveoptions.workers:,max_body:,max_conns:,max_streams:,max_ws_conns:,read_timeout_ms:,request_timeout_ms:anddrain_ms:set them in the program; the matchingECKO_HTTP_*variable, when set, still overrides the option.
Added
- Compound assignment:
+=,-=,*=,/=,//=and%=.x += eis shorthand forx = x + eand follows the same rules: the binding must bemut, and it works through fields and indexes.
A target whose index or object could have a side effect, such asmut total = 0 for n in [3, 4, 5] { total += n }xs[next()] += 1, is refused with a message to bind the index first, because the long form would callnext()twice. - Typed parameters:
fn inc(x: Int). A parameter can declare a type, with the same rules as a struct field:Intwidens toFloat,Floatdoes not narrow toInt,Option<T>takesnull,List<T>checks each element.ecko checkrefuses a literal that can never fit, such asinc("5"), before the program starts. Any other value is checked when the function is entered, and a mismatch is a catchable error:
Annotations are optional, and an unannotated parameter takes anything, as before. On anerror: parameter `x` of `inc` expects Int, got string@toolfunction they are the schema the model sees, solimit: Intis offered as an integer rather than a string. A return type (-> Int) is accepted but not yet checked. x in xstests membership. It gives the same answer ascontains(xs, x): an element of a list, a substring of a string or bytes, a key of a map, an Int inside a range. It binds like a comparison, sonot x in xsmeansnot (x in xs), and an unsupported pair such as1 in 5is an error rather thanfalse.
Fixed
proc.wait(h, 0)checks without killing. A timeout of 0 is documented as "check, do not block", but it was read as a deadline that had already passed, so the process was killed. It now returnsnulland leaves a running process alone.proc.spawn(cmd, detach: true)works without an argument list. It failed with "arguments must be a list".- A project's
ecko.jsoncan no longer choose who receives your package token. It was already refusedECKO_PKG_TOKEN, but not the host list the token is sent to, so a cloned repository could add its own host. Both spellings of that setting are refused now. - A dropped HTTP connection is logged only at
ECKO_LOG=debug. AnyECKO_LOGvalue used to turn those lines on,errorincluded.
0.57.0
Added
io.tellandio.seekresume a file where you left off.io.tell(s)is the byte offset of the next byte you will read - what the stream has read ahead is not counted - andio.seek(s, offset)moves back there. Store the offset as a checkpoint and a job that stopped halfway through a large file starts from that line rather than reading the first half again. Only a file stream has a position; on stdin, a socket or a pipe they are anioerror.with_timeout(ms, f)puts a deadline on any piece of work. It runsf()and returns its value, or throws{ kind: "timeout", ms }iffis still running aftermsmilliseconds.fis stopped, not left running: the deadline is checked between steps and ends any wait Ecko controls -sleep, channels,await, stream reads - at once. One call blocked inside a library, such as a slow database query, finishes first and then the timeout is raised.row = try { with_timeout(2000, fn() lookup(id)) } catch (e) { if e.kind == "timeout" { cached(id) } else { error(e) } }sleepwakes up when its task is cancelled. Acancel(t), or awith_timeoutdeadline, used to wait out the rest of a longsleep.
Fixed
- A failed HTTP handler's log line says where it failed. The server's stderr now carries the error's
kind, thefile:line:colthat raised it and the call stack, not only the message. - Lambdas have names in call stacks. A lambda bound to a name takes it (
check = fn(x) ...shows ascheck, and an arity error says'check' expects 1 argument(s)), and one passed straight to a call shows as<lambda at line 12>. The findings that stop a program before it starts name the file too. - A
matchwith no arm for its value no longer hides the error you were handling. Dispatching one.kindinside acatchwith no arm for the kind that arrived reported onlyNon-exhaustive match: no pattern matched, and the original error was gone. The message now names the value and, inside acatch (e)block, the error:no pattern matched "io" (while handling: disk full). - Every location in an uncaught error names its file. An error inside an imported module was reported against your program, at the line that called into the module, quoting the wrong source line;
ecko devshowed the same. Now the error header reads--> h.ecko:2:6and quotes that file, and each call-stack frame readsname at file:line:col. A module whose top level fails shows a<module h.ecko>frame at theimportline. A function imported from a module appears under its own name rather than<expr>. - An error inside a callback or a task points where it happened. An error raised in a function passed to
map,pmap,filter,sort_byor any other built-in that calls back, or in anasync fnyouawait, was reported at themap(...)orawaitline with no way into the function that failed. It now points at the line that raised it, and the call stack runs from your code through the callback. A caught one'smessageno longer starts withRuntime error:, soe.messageis the same with or without themapin between. ecko getandecko removechange onlydependenciesinecko.json. They wrote the whole manifest back, which dropped an explicit"capabilities": []- a statement that the package is pure - and reordered your keys. Now every other byte of the file stays as you wrote it.json.encodeis as fast as it was in 0.51. Refusing a value with no JSON form made every encode walk the value twice, about 11% slower on encode-heavy work and 3% on a JSON-returning HTTP handler. The check now happens in the encoder's single pass; what is refused is unchanged.
Breaking
- Ecko's flags go before the file; everything after it is the program's.
ecko app.ecko --model x --port 1took--model xfor Ecko and handed the program only[--port, 1], thoughecko run --helpsays everything after the file is the program's. Now it is, verbatim - so a program can have its own--modelor--trace. A command that runs no program (ecko repl --provider ollama) still takes the flags anywhere. Migration: move--provider,--model,--cacheand--tracebefore the file:ecko --provider openrouter report.ecko. --keyis removed. A key on the command line is in your shell history and inpsoutput, where other users on the machine can read it. Migration: setECKO_API_KEYin the environment.\u{...}is a Unicode escape. There was no such escape, so"\u{1}"was a backslash, auand an interpolation hole holding1: the three characters\u1. It is now one character, U+0001, and"caf\u{e9}"iscafé. An escape that is not a character is an error. Migration: a string that meant the three characters writes"\\u1".ecko checkrefuses a number in an interpolation hole."^[A-Z]{3}$"is the string^[A-Z]3$, a regex that never matches a currency code, and it ran without a word. A hole holding only a number is now an error (literal-interpolation) that points at the fix. Migration: write the regex or template as a raw string,r"^[A-Z]{3}$", or escape the brace,"\{3}".- A hole holds one expression.
"{a b}"was the value ofa, withbdropped. It is now a parse error.
Added
model(..., reasoning: "low")sets how hard a reasoning model thinks.none,minimal,low,medium,high,xhighormax, sent as OpenRouter'sreasoning.effort, OpenAI'sreasoning_effort, or Ollama'sthink(off fornone). Less is faster and cheaper, which suits a summary or a quick answer.ECKO_AI_REASONINGsets it for every call without its own. The effort is part of the cache key.
Fixed
- An AI request no longer has a fixed 120-second limit. The limit was on the whole request, so a reply still streaming at 120 seconds - a long answer from a slow model - failed and was retried from the start, three times. It is now a limit on silence: a request fails when nothing arrives for
ECKO_AI_READ_TIMEOUT_MS(default 120000). The error names the cause, too: a stalled read said only "error decoding response body", not that it had timed out. - A regex in a loop is compiled once. Every
re.*call compiled its pattern from scratch, about 0.4 ms each for a realistic log pattern. A pattern is now compiled on first use and kept, so 200,000re.testcalls on one pattern take about 2 s instead of 80. No change to how you call it. ecko checkcatches more of what it said it did, before the run. Each of these passed the check and failed at run time, after whatever came first: too few arguments to a builtin (slice("abc", 1),split("a")); a field a struct does not have, read off a binding that holds one (s = Sq(2)thens.side); an inlinefn(...)with the wrong number of parameters for the function that calls it (sql.transaction(db, fn() ...),map(xs, fn(a, b) ...)). And a member astdmodule does not export, likejson.parse, was reported byecko checkbut did not stopecko app.eckofrom starting; it now does.- Interpolation errors say where. An unclosed or empty hole (
"a{b","{}") was reported with no line or column; it now names the line and column of the hole's{.
0.56.0
Breaking
- Encoding a function to JSON is an error.
json.encode,json_encode,json.write,http.jsonand an HTTP request'sjson:body wrote a function, task, channel, stream, module or range as its printed form, sojson.encode(fn(x) x)produced"fn(x)". They now throw{kind: "bug"}naming what has no JSON form. Migration: encode the data, not the value that produces it. For a range,list(r).
Added
csv.eachreads a CSV file of any size, one row at a time.csv.readloads the whole file and refuses one over 256 MiB.csv.each(path, fn(row) ...)hands each row to your function as a map and keeps memory flat; it also takes a stream (a pipe, a socket, stdin). A row with the wrong number of fields or bytes that are not UTF-8 goes toon_erroras a value -{ kind: "parse", line, raw, message }, ready for a dead-letter file - and the job carries on. It returns{ rows, errors }.round(x, places, mode?)rounds to a number of places. A decimal comes back a decimal at exactly that scale -round(12.345m, 2)is12.35mandround(12.3m, 2)is12.30m- where before onlyfmt.fixedrounded one, to a string. Halves go away from zero by default;modeis one ofhalf_up,half_even,half_down,up,down,ceiling,floor. One-argumentroundis unchanged.json.decode(text, { decimal: true })reads exact decimals. Every number that is not an integer decodes as a decimal from its own text, so a money field survives a round trip:json.decode(json.encode({fee: 0.30m}), { decimal: true }).feeis0.30m.json.readtakes the same option. Without it numbers decode as before.
Fixed
- A string contract no longer passes silently offline. With no AI provider,
@ensures("result is a valid email")has no model to judge it and lets the call through - but a test run looked as if it had checked it. A run now ends with a note on stderr naming each string contract that went unchecked, andecko testaddsN string contracts not checked offlineto its summary. Output on stdout is unchanged. - A decimal is stored in SQLite with its exact text.
12.50mbound as a parameter was stored as'12.5'; it is now'12.50'. Thestd.sqlpage now documents how each type maps, and that money belongs in atextcolumn. - A tool with two or more parameters works offline. In mock mode, and so in every
ecko test, a tool was called with the prompt as a single argument whatever it declared:@tool fn record(claim, source)failed with "expects 2 argument(s), got 1" and worked only against a live model. It is now called with the prompt for each parameter a model would have to supply, and a parameter with a default keeps it, the same shape of call as live. The docs showed a spec tool ascall: fn(args);calltakes one value per entry inparams, in order. - A decimal keeps its scale in JSON.
json.encode(12.50m)wrote12.5; it now writes12.50.
0.55.0
Breaking
- Every error the runtime throws is a
{kind, message}map. A programmer mistake - an index out of bounds, a missing field, a wrong type or arity - used to reachcatch (e)as a bare string, soe.messagefailed on it. It is now{kind: "bug", message}, with the same text inmessage. Malformed input toint,float,decimal,json_decodeandtime.parsewas a bare string too and is now{kind: "parse"}; a failedassertis{kind: "assert"}. A value you throw yourself witherror(v)is caught exactly as you threw it.
Migration: where a handler used a caught mistake as text (try { [1, 2][5] } catch (e) { print(e.kind) } # bug"{e}",contains(e, "...")), reade.message. Aget(e, "kind") == nulltest for "this was a programmer mistake" becomesget(e, "kind") == "bug". - Values are immutable unless you say
mut. A barex = vused to declare a mutable binding, so any laterx = ...- including one inside a function, which silently rewrote a top-level name - was accepted. It now declares an immutable binding, exactly likelet. Reassigning it, or writing through it (m.key = v,xs[0] = v), needs the binding declaredmut. Parameters and loop variables are immutable too.ecko checkrefuses a reassignment before the program starts (immutable-reassign); at run time it is a{kind: "bug"}error. Redeclaring withletormutstill shadows, including in the REPL, and_ = f()can be repeated.
Migration: runmut total = 0 for n in [1, 2, 3] { total = total + n }ecko fix --migrate --only=mut src/. It addsmutwhere each reassigned binding is declared. A reassigned parameter or loop variable is reported but not rewritten: start the body withmut n = n.
Fixed
- The documentation no longer describes functions that do not exist. Pages showed
http.stream(return{status, headers, stream: ch}from the handler instead),http.form/http.files(they are the request'sformandfilesfields),fs.path(usefs.join),test.assert(use theassertbuiltin),divmod,sql.connect,type P = { ... }without a variant name,catch e {without parentheses, andsql.transaction(db, fn() ...), whose function takes the transaction:fn(tx). Thestd.strpage said importing it bindsstring; it bindsstr. ecko fmtandecko checktake a directory.ecko fmt --check .- the CI command the help text itself suggests - failed with "Is a directory". A directory now means every.eckofile under it, skippingvendor/,target/,node_modules/and dot-directories, asecko testdoes. New:ecko fmt --stdinformats a buffer from stdin to stdout for editors, andecko fmt --stdin --checkanswers by exit code.- File failures in
std.fs,std.tomlandstd.yamlcarrykind: "fs".fs.copy,fs.rename,fs.temp_dir,fs.temp_file, and thereadandwriteoftomlandyamlthrew a bare string, somatch get(e, "kind")could not route them. They now throw{kind: "fs", path, message};copyandrenameaddto.fs.walkon a directory that does not exist now throws the same instead of returning[], and a malformedfs.globpattern iskind: "parse". ecko runandecko devwith no file print their usage. They reported "the Ecko runtime hit a bug and stopped" and exited 70 - the exit reserved for a crash in Ecko itself - over a missing argument. Both now sayusage: ecko run <file.ecko> [args...]and exit 2, like the other commands.- An overloaded upstream is retried, not reported as a malformed response. OpenRouter answers HTTP 200 with the error in the body when the upstream fails after accepting the request (
{"error":{"code":503,"message":"Upstream error from Nvidia: ..."}}). That failed the call with "Unexpected OpenAI tool response". An error in a 200 body is now judged by its code as the status would be: 429 and 5xx are retried, anything else fails with the provider's own message. - A tool-calling round retries transient failures. Each round of
ai ... using [tools]was a single attempt, so one network error, 429 or 503 ended the whole call. It now retries them like a plain call does, up to three attempts. - A reasoning model's
content: nullis an empty answer. A reasoning model can finish with no content, its text in a separatereasoningfield. That failed both plain and tool-calling calls as an unexpected response shape; it now answers"". ai[String]no longer asks the model for JSON. Every typed call appended "Respond with ONLY valid JSON. Schema: ...",ai[String]included, where the schema is{"type": "string"}. Models took it literally: GPT answered a research write-up with the single wordstring, and Gemini wrapped its text in an object of its own. A string needs no JSON, soai[String]now sends the prompt as written, like an untyped call, on every path: plain,using [tools],with sessionandon <image>.
0.51.0
Breaking
/divides;//is the new floor division./on two ints dropped the fraction, so7 / 2was3and an average or a percentile of whole numbers came out as a whole number with no error./now always divides: two ints give a float (7 / 2is3.5,6 / 3is2.0). When you want the whole number, write//, which floors - rounding toward negative infinity, as in Python:7 / 2 # 3.5 7 // 2 # 3 (0 - 7) // 2 # -4//gives an int for two ints, a whole float when either side is a float, and a whole decimal for a decimal.%is now its remainder, so it floors too: the result takes the sign of the divisor (-7 % 2is1, was-1), anda == (a // b) * b + a % balways holds, so a weekday or a minute computed with%is right for a time before 1970.%also accepts a float with an int, like the other arithmetic operators, instead of refusing them. Migration: find each/between ints that is meant to give a whole number - a midpoint index, a page count, a day number, a colour channel - and write//instead. The usual sign is a later error such asneeds an integer, because a float reached a place that takes a count or an index. Where operands can be negative,//floors where the old/truncated toward zero:-7 / 2was-3,-7 // 2is-4.%changes only for a negative operand; for the old truncating remainder writea - b * int(a / b). There is no codemod: whether a/meant a whole number depends on values the source does not show.+joins a string only to another string."count: " + 5used to give"count: 5", because+formatted whatever sat beside a string into it. So a number that arrived as text concatenated where the program meant to add:"5" + 1was"51", not6, with no error. Adding a string to anything other than a string, in either order, is now an error that says what to write instead:
Interpolation is unchanged and still formats any value. The case most code hits is a caught error, which can be a"count: " + 5 # error: Can't add string and int "count: {5}" # interpolate "count: " + string(5) # or convert{kind}map: writeprint("failed: {e}")rather thanprint("failed: " + e). Migration: runecko fix --migrate --only=strplus <path>. Wherever one side of a+is visibly a string, it wraps the other side instring(...), which keeps the meaning whatever the runtime types are. It cannot see a+whose sides are both variables (label + count) or one inside an interpolation; the first run of the program names those lines. Run it once.
Added
ai ... via mchooses the provider and model for one call. One program can now put different questions to different models - a cheap one to sort, a strong one to judge, a local one for private data:judge = model("openrouter", "anthropic/claude-sonnet-5") local = model("ollama", "llama3.2", base_url: "http://gpu-box:11434") verdict = ai[Verdict] "Judge this: {text}" via judge notes = ai "Summarise" using [read_notes] via local quick = ai "One word: {q}" via "gpt-4o-mini"model(provider, name)takesopenai,openrouterorollama, with optionalbase_url:andkey:; a plain string names a model on the configured provider.viaworks with every other clause, streaming and tools included. A call that switches provider reads its key fromkey:,OPENROUTER_API_KEYorOPENAI_API_KEY, and never sendsECKO_API_KEYto a provider it was not set for.- Typed calls against Ollama constrain the model to the schema. An
ai[T]call now also sends its JSON Schema as Ollama'sformat, which Ollama enforces on the output, so a small local model returns aTinstead of prose. Untyped calls,ai[Json]and tool rounds are unchanged. For an Ollama older than 0.5, which rejects the field, setECKO_AI_OLLAMA_FORMAT=0. a ?? b:a, unless it is null.get(settings, "port") ?? 8080. Onlynullis replaced -0,falseand""are kept - andbonly runs when needed. It binds tighter than comparison, soget(m, "n") ?? 0 > 5compares the defaulted value.merge(a, b)returns a new map withb's entries overa's.a + bon two maps now says to use it, instead of suggestingint()orfloat().type_name(x)gives a record's declared name ("User") wheretype_ofgives"struct".ECKO_AI_MOCK=1forces mock mode whatever the keys, provider orviasay, andllm.is_mock()reports which mode is in effect - so a tool can skip its side effects offline, where the tool loop calls it with the prompt as its argument.ecko testnow setsECKO_AI_MOCK, so a test that names its own model withviastays offline too.time.format_local(ms, pattern)formats in the machine's time zone;time.formatstays UTC.os.unset_env(name)removes a variable.fs.temp_file()no longer needs a suffix argument.- Two new
ecko checkwarnings.http-timeout-secondsflags a literaltimeoutover 600 on an HTTP call: it is in seconds, sotimeout: 20000- meant as 20 s - waits five and a half hours.cli-flag-takenflags astd.clioption namedmodel,provider,key,cacheortrace, whicheckoreads for itself unless the user puts it after--.ecko --helpnow lists--.
Changed
- A failed
aicall raises{ kind: "ai" }. It used to raise a bare message with no kind, somatch get(e, "kind")sawnull. The same goes for sessions, voting, tool loops and streams.ecko checkknows anaicall can throwai, so acatcharm for it is no longer reported unreachable. Migration: acatchthat matched the message text of a provider failure keeps working; one that treated anullkind as "provider failure" should match"ai"instead. - A failed
-> streamraises instead of ending empty.for chunk in sused to finish quietly after zero chunks when the provider call failed - an empty answer indistinguishable from a model that said nothing. It now raises{ kind: "ai" }after any chunks that did arrive, as do thestd.ioread verbs; a stream refused by the budget raisesbudget. - A typed
ai[T]call that never gets valid JSON returnsnull, not the model's raw text. Against a model that answers in prose - often a small local one -ai[User]used to give back a string once its retries ran out, and the next line (u.name) failed with "Can't access field of string". It is nownull, as the documentation always said, for plain calls, sessions and tool loops alike;ai[String]andai[Json]still take the text as given, and the trace still records what the model sent. Migration: code that read the raw text back out of a failed typed call getsnullinstead; ask forai[String]if prose is what you want. --helpfor astd.cliprogram no longer prints(default: )for an option whose default is the empty string.
Fixed
- A computed
usinglist can use local variables.ai "..." using tools_for(p)inside a function failed with "Undefined variable 'p'", and so didusing tsfor a list built in a local: anything but a literal list of names was evaluated outside the function. It is now evaluated where it is written. - The request timeout now answers a handler that is waiting, not only one that is computing. With
ECKO_HTTP_REQUEST_TIMEOUT_MSset, a handler in a loop got its503on time, but one blocked insleep, a socket read, a lock or anaicall kept the client waiting until the wait ended. The client now gets its503at the timeout whatever the handler is doing. A waiting handler still holds its worker thread until its wait returns - setio.timeoutorECKO_NET_TIMEOUT_MSon what it waits for. - A deploy no longer drops the requests in progress. SIGTERM - what a container orchestrator sends before replacing a container - killed
http.serveat once, so every request in flight got no response. Ctrl-C andhttp.stop()stopped accepting but then cut off the requests still running too. All three now drain: the server stops accepting, lets each request in progress finish, closes idle keep-alive connections, and returns, so the program exits 0. The wait is bounded by the newECKO_HTTP_DRAIN_MS(default 25000, inside an orchestrator's 30 s grace period); a handler still running past it is cut off. A second SIGTERM or Ctrl-C forces the exit. - A record is the same value however you build it.
Point(1, 2)andPoint { x: 1, y: 2 }printed alike but compared unequal, socontainsmissed a match andtype_nameansweredPointfor one andstructfor the other. The two forms are now equal,type_namegives the type for both, anddebug.inspectno longer shows a hidden__type__entry on records built with a constructor.json.encodestill writes its"__type__"key. - A field declared as a
typeaccepts that type's variants. Withtype Tier = Basic | Vipandtype C = C { name: String, tier: Tier },C("a", Vip)was refused with "fieldtierofCexpects Tier, got Vip": the check compared the variant's own name to the declared one. Any variant of the declared type is now accepted - fieldless or not, positional or brace form, on assignment, insideList<Tier>andOption<Tier>, and whenai[Tier]produced it. A variant of a different type is still refused. A field declaredAnynow accepts every value; it used to refuse any record. - Filling a list or map one entry at a time is no longer quadratic. Every
xs[i] = v,m[k] = vora.b[i] = vcopied the whole collection before writing, even when nothing else held it, so 40,000 writes took about 14 seconds and a map built key by key was slower still. The write now happens in place, and the same loop takes milliseconds:
Collections still behave as values: a copy you made earlier (mut xs = map(1..=40000, |x| 0) mut i = 0 while i < 40000 { xs[i] = i # was a full copy of xs each time i = i + 1 }b = xs) is unaffected. A write that fails, such as an index out of range caught bytry, leaves the variable as it was. An element written into a typed record field (r.scores[0] = vonList<Int>) is checked against the element type instead of re-checking the whole list each time. - Lines read from a stream no longer hold on to 32 KB each. Every line from
io.read_line,io.linesorio.read_untilkept the read buffer it was cut from, so a batch job that collected 50,000 short lines used 1.6 GB of memory. Each line now takes only its own bytes, and the same job uses 15 MB. Reading also got faster, because a line no longer copies the rest of its chunk: 200,000 short lines went from seconds to milliseconds. The"" + lineworkaround is no longer needed. ecko fmtno longer moves a doc comment into the function above it. When a function ended with a string holding a few\nescapes before an interpolation, the next function's##comment was pulled up to the end of that string's line, and the function it documented lost its docs:
Runfn a(t) { "a\n\n\n{t}" } ## Doc for b. # fmt used to move this onto the "a\n\n\n{t}" line fn b() = 1ecko fmtagain after upgrading and move any stranded##comment back above its function.- Errors inside a
{...}interpolation now point at the right line. Each\nescape earlier in the string used to move the reported line one further down the file, and in a triple-quoted string or atemplatebody the line could be off in either direction. - The
std.cliandstd.rereference pages now match what the functions do. Thestd.clipage named the program withprogram:(it isname:) and read results asopts.format; they are underr.optionsandr.args, as inr.options.formatandr.args.input. Thestd.repage showedre.replace(pattern, replacement, text); the order isre.replace(pattern, text, replacement), and the same forreplace_first. Code written from the old pages failed or replaced the wrong string; nothing in the functions changed. - A typed
ai[T] ... using [tools]call no longer returnsnullin mock mode. Offline, the call tried to fit the last tool's return value intoT, so unless the tool happened to return exactly aT, you gotnull- for a tool returning text, that was every type butString- and the next line failed, whileai[Json]gave the tool's text where a provider gives a map. It now runs the named tools as before and then returns the same mock valueai[T]gives without tools, matching a live provider, where a typed call answers with the model's answer rather than a tool's:
Migration: a mock-mode test that expected a typed call to return its tool's value now gets the mock value for the type (type Evidence = Evidence { answer: String, confidence: Int } e = ai[Evidence] "use lookup" using [lookup] print(e.confidence) # 42 offline; before, e was null42forai[Int]). To test what a tool returns, call the tool directly or use an untypedai ... using [...], which still returns the last tool's result.
0.50.0
Added
ecko checktells you when acatchmisses an error kind itstrycan throw, or handles one it cannot. It works out which kinds eachtrycan raise - from what every standard-library function throws, through your own functions and the files and packages you import, and from eacherror({ kind: "...", .. })you write - and compares them with the arms of amatch get(e, "kind")in thecatch. Two new warnings, shown byecko checkand in your editor, never blocking a run:
Atry { json.decode(body) } catch (e) { match get(e, "kind") { "net" => retry() # unreachable-error-kind: nothing here throws net } # unhandled-error-kind: json.decode can throw parse }_arm (or_ => error(e)) handles the rest. The check stays quiet where it cannot be sure, such as a callback orpy(), never asks forlimit,budget,cancelledorcapability, and leaves acatchwith no kindmatchalone.
Changed
ecko --helpnow opens with "the runtime that doesn't crash" instead of "an AI-native language", matching how Ecko describes itself. Nothing else in the output moved. A script that matched the old first line needs updating.
Fixed
- Two writers on one SQLite database no longer fail with "database is locked". A second connection now waits for the first one's lock, up to 5 seconds by default, instead of failing at once, and
sql.transactiontakes the write lock when it starts, so a transaction that reads and then writes is never refused halfway through. Parallel workers writing to one database, and HTTP handlers sharing one connection, now wait their turn, in the order they asked, so a busy writer cannot starve a quiet one. Set the wait withsql.open(path, { busy_timeout_ms: 10000 })orECKO_SQL_BUSY_TIMEOUT_MS;0restores the old fail-at-once behaviour. A transaction started inside another on the same connection is still refused. - An HTTP handler, a WebSocket handler or a
pmapworker could crash the whole process on work the rest of the program handled fine. They ran with a much smaller stack than the main program, so building, encoding or releasing a value nested about twenty thousand levels deep inside one killed the process with "stack overflow" - a server took every other open connection with it - and notry/catchcould stop it. Every thread that runs your code, tool calls included, now gets the same stack as the rest of the program. - Releasing deeply nested data no longer kills the process. A value nested millions of levels deep, such as a list built with
x = [x]in a loop, died with "fatal runtime error: stack overflow" at the moment it was freed: when a variable holding it was reassigned, or when the program ended. Atryaround it could not help, because a stack overflow is not an error a program can catch. Lists, maps, structs, cells, secrets and closures are now freed in a loop, so releasing one works at any depth, and ordinary data frees as fast as before. json.encodeof deeply nested data killed the process. A value nested a few hundred thousand levels deep -v = [v]in a loop - aborted with "stack overflow" when encoded, andtry/catchcould not stop it. The same happened wherever such a value was turned into JSON:json.write, anhttp.get(url, json: v)body, or a list passed as astd.sqlparameter. JSON encoding is now exact at any depth, byte for byte the same as before.yaml.stringifyandtoml.stringifykilled the process on deeply nested data. They now refuse a value nested more than 1000 levels deep with a catchable error,{ kind: "limit", limit: 1000 }, instead of aborting. Neitheryaml.parsenortoml.parsereads back anything that deep.- A fractional
timeout:on an HTTP request timed out immediately.http.get(url, timeout: 0.5)rounded the timeout down to zero, so every request failed with "operation timed out" before it was sent. Fractions of a second are now honoured. ecko getandecko removeno longer delete the rest of yourecko.json. Both rewrote the file from the fields they knew about, so every other top-level key was dropped - and since the whole manifest is readable from the program asstd.defaults, those keys are usually an app's own configuration. A project keeping its site URL, its analytics id or its payment token there lost all of them on a singleecko get, with a success message printed and no backup. Both commands now change only the dependency they are there to change. Foreign keys come back sorted after the manifest's own fields, so the first run of either command on an existing project may reorder the file once; after that it is stable.ecko checkfinds your packages from a subdirectory. Import resolution looked forecko.jsonandvendor/beside the file being checked rather than at the project root, soecko check app/main.eckoreportedunresolved-importfor a package that runs perfectly - and reported it for every import in that file, because it could not read the manifest at all. Any project laid out withapp/,src/,tests/or anything else below the root hit it the moment it imported a package, and sinceecko checkis what CI runs, the gate failed on working code. It now walks up to the manifest, which is whatimportitself has always done.
0.40.0
Deprecated
ecko lintis nowecko check --strict. They were two commands over one analyzer, printing the same findings under different footers, and the help described them as though they did different jobs - so there was no way to tell which to reach for.checkis the one that stays: it knows the file's path, so it also reports imports that do not resolve and members a module does not export, whichlintcould never see.ecko lintkeeps working and runsecko check --strict, printing a notice first. It will be removed in a future release. Migration: replaceecko lint <file>withecko check --strict <file>. The--strictmatters:lintfailed on any finding, andcheckwithout it treats unused variables and dead code as warnings and still exits 0, so a plaincheckwould turn a failing CI step green. Two smaller differences:lintprintedno issues foundon a clean file andcheckprints nothing, and an unreadable file now exits 1 rather than 2.
Added
ecko checkreads yourecko.json. It never did, so a manifest with"capabilities": ["fs"]- invalid, the values arefs:readandfs:write- passed the check with exit 0, then stoppedecko getlater with a message that appeared to blame the package you were fetching. The checker now reports an unknown capability, an invalidmodulepath, a badeckoversion, a missing or escapingentrypoint, a dependency key nothing can import, and a dependency value that does not resolve. The manifest is found by walking up from the file being checked, so it works from a subdirectory. Anecko.jsonused only for project defaults - noentrypoint, nodependencies, just values your program reads - is not a manifest and is left alone.
Fixed
- An error that stops a run now shows you the line it is talking about. An undefined name or a wrong argument count printed a single terse line, while a syntax error in the same file printed the source and a caret under the problem - so which one you got depended on which kind of mistake you made, and the terse one is the kind a beginner hits most. They look the same now, and the rule name moved into the header where you can still see it:
error[undefined-variable]: 'totl' is not defined - did you mean 'total'? --> 2:7 | 2 | print(totl) | ^ program not started - fix the error(s) aboveecko checkis unchanged and still prints one line per finding: it reports everything it finds at once, and that form is the one editors and CI read. ecko --helpnow listsecko test. The test runner was missing from the top-level help entirely - the onlytestline describedecko test --generate, which writes tests with a model rather than running them - so the command every scaffolded project needs was invisible to anyone reading the help first. The list is also grouped now (run and write, projects and packages, understand it, with AI, editors) rather than twenty-five flat lines, and points at the language reference for the resource ceilings.ecko updatesays what is actually true. It reported itself as "not implemented yet", and the documentation described it as a working command that re-fetches and re-pins. Neither was right: a fetched package bundles its ownvendor/, so there is no dependency graph to re-resolve and there is no version of this command coming. Useecko get <path>@<version>to move a version - one package at a time, which is the only way versions move here.ecko getnow records an import alias you can use. Fetching a repo whose name contains a hyphen wrote that name straight intodependencies, andimport mysql-clientis not a name - it reads as a subtraction, and failed with'client' is not defined, which named neither the package nor the fix. The alias is now the last path segment with-mapped to_, and the command prints the line to write:$ ecko get github.com/ecko-lang/mysql-client Got github.com/ecko-lang/mysql-client - vendored and locked. import mysql_client--as <alias>chooses the name yourself. Re-getting a package keeps an alias you already picked instead of adding a second entry for it, and an alias that already points at a different package is refused instead of being repointed. Migration: existingecko.jsonfiles are left alone. If you worked around this by hand-editing a hyphenated key, nothing breaks; to move to the derived name, rename the key and the matchingimport. The vendor directory is keyed by the full package path, so renaming an alias never re-downloads anything.
0.39.0
Breaking
ecko installrefuses a project it cannot verify. Integrity checking compares each vendored package against a pin inecko.sum. With noecko.sumthere is nothing to compare against, and the command printed a warning and exited 0 - while its own help called it the command to run in CI, which installs exactly what the lockfile records or fails. It did not fail. A project that has anecko.lockwith packages in it and noecko.sumis now refused:
Nothing else changes: a project with an$ ecko install Couldn't install from ecko.lock: no ecko.sum in /app - 3 package(s) cannot be verified. Run `ecko get` to pin them, or `ecko install --allow-unverified` to install without integrity verification.ecko.sumbehaves exactly as before, and a project with no lockfile has no packages to pin and is left alone. Migration: runecko getonce for each dependency to write the pins, then commitecko.sumbesideecko.lock. If you need the old behaviour, pass--allow-unverified, which installs with the warning it printed before. Keep that flag out of CI - failing there is the point.
Added
ecko profile <file>says where a program's time went. It runs the program and reports, per function, how many times it was called, how long it spent in its own code, and how long including everything it called:function calls self ms total ms -------------------------------------------- normalise 60000 21.02 21.02 parse_row 20000 15.88 36.90 41.10 ms total, 4.20 ms outside any functionselfis the column to read first, and the report is sorted by it: it is the time the function spent on its own work. A smallselfbeside a largetotalis a wrapper, and the time is further down. Above,parse_rowaccounts for 36.90 ms, but 21 of those are the 60,000normalisecalls it makes. The report goes to stderr, so a profiled run still pipes -ecko profile app.ecko > outwrites whatecko app.ecko > outwrites - and the program keeps its own exit code, so one that fails still reports its numbers first. A run that madeaicalls gets a line for them, with tokens and estimated cost when the provider reported them. Calls are counted rather than sampled, so the counts are exact and repeat run to run. That costs a clock read entering and leaving each function, which a plainecko app.eckodoes not pay. Time in a builtin is charged to the function that called it.
Fixed
- Printing or comparing deeply nested data killed the process. A value nested about 150,000 levels deep -
x = [x]in a long enough loop - aborted with "fatal runtime error: stack overflow" when printed, and around 400,000 levels when compared with==. Atry/catcharound it changed nothing: a stack overflow is not an error a program can catch, so there was no way to recover or even report it. Rendering and comparison no longer recurse over nesting. Both are exact at any depth - nothing is elided, no limit was added, and neither is slower:
No parser could reach the old limit -mut x = [] for i in 0..1000000 { x = [x] } print(len(string(x))) # 2000002json.decode,yaml.parseandtoml.parseall refuse deep nesting - so this needed a program that built the nesting itself. Releasing a value still unwinds a level at a time, so past a few million levels the stack runs out when it is dropped.
0.38.0
Added
- Record fields can declare a default. A field with one may be left out of either construction form:
Defaulted fields come last, or the declaration is refused: otherwisetype User = User { name: String, age: Int = 0 } User("Sean") # age is 0 User { name: "Sean" } # age is 0 User("Ada", 36) # an explicit value still winsUser("Sean")is ambiguous the moment a default sits between two required fields. The default is evaluated each time a value is built, not once at declaration, so two values never share one -credits: Int = next_id()callsnext_idper instance. It is evaluated in the scope the type was declared in, which holds functions and builtins but not local variables.n: Int = abs(-1)andn: Int = seven()work; a default naming aletormutbinding fails with an undefined-variable error when a value is constructed - use a function instead.ecko checkreports a name a default gets wrong. A field with no default is still required.
0.37.0
Added
- A
markdownpackage. Parse Markdown into blocks, render it to HTML, and pull out the headings, code blocks and tables a docs pipeline or a RAG ingest actually wants:import markdown for c in markdown.code_blocks(doc) { if c.lang == "sh" { print(c.code) } } for s in markdown.sections(doc) { embed(s.heading + "\n\n" + s.body) } markdown.to_html(doc)ecko get github.com/ecko-lang/markdown. Pure - no capabilities. Rendering someone else's Markdown is safe by default: text is escaped before any tag is added, and onlyhttp,https,mailtoand schemeless targets become anhref, so ajavascript:link renders as plain text with its words kept. It is a block-level subset rather than CommonMark, and its README says where it will disagree - setext headings, nested lists and reference links are not handled.
0.36.0
Security
ECKO_NET_TOKENis no longer sent to every host a lockfile names. The token is a credential for a private repository, and it was attached to every package fetch with no host check - so a committedecko.lockpointing at someone else's host received it on the first request. It now goes only to the hostECKO_GIT_BASEnames, orgithub.comwhen that is unset.ECKO_NET_TOKEN_HOSTStakes a comma-separated list for more than one forge. Other hosts are still fetched, just without the credential, so the ordinary case keeps working: one private forge plus public dependencies from anywhere. If your private packages live somewhere other than GitHub and you have not setECKO_GIT_BASE, set it orECKO_NET_TOKEN_HOSTS, or those fetches will go out unauthenticated.
Fixed
- Three resources that could grow without limit now have ceilings. Each one sat beside neighbours that were already bounded, which is what made them easy to miss. HTTP connections (
ECKO_MAX_CONNS, default 1024). The server spawned one task per accepted connection with no cap, while WebSockets and streams both stop at 1024 - and each connection can buffer a body up toECKO_HTTP_MAX_BODYbefore a handler runs. Past the limit a connection is dropped before anything is read, since there is no request yet to answer. Queued tasks (ECKO_MAX_QUEUED_TASKS, default 4096).ECKO_MAX_TASKSbounds tasks that are running; the queue in front of it was unbounded, so a loop spawning faster than it drains grew the heap until the process was killed. Spawning past the limit now fails with an error naming the knob, rather than blocking - blocking deadlocks, because running tasks hold the permits the dispatcher needs. AI response bodies. Every buffered read from a provider was unbounded and no request setmax_tokens, so a malicious endpoint or a runaway completion was limited only by memory. Responses now answer toECKO_MAX_ALLOC, the existing limit for reading something in one piece - no new knob. Streamed responses are unaffected: they yield chunks and never buffer.
0.35.0
Security
- A project's
ecko.jsoncould send your API key to a server of its choosing. The manifest'senvironmentblock is applied before your program starts and overrides the surrounding shell, which is deliberate - it is how a project pins its provider or recursion cap. Nothing stopped it settingECKO_AI_BASE_URL. Cloning a repository and running it was enough: the firstaicall went to the address the manifest named, carrying theECKO_API_KEYexported in your shell and the full prompt, and whatever that host replied became the model's answer. No package install, no capability grant, no prompt. Ten variables are now refused from a manifest - those that decide where data goes or what is trusted:
Setting one is now an error naming the variable and the reason. Everything else still works, including the documentedECKO_AI_BASE_URL ECKO_GIT_BASE ECKO_TEMPLATES_REPO ECKO_API_KEY ECKO_LLM_API_KEY ECKO_NET_TOKEN ECKO_ALLOW_HTTP ECKO_TRACE ECKO_AI_CACHE ECKO_PY_ENVECKO_AI_PROVIDERandECKO_MAX_DEPTH- a manifest may still tune how a program behaves. If you have run an unfamiliar Ecko project with a key exported, rotate it. Package code was never able to do this: it needs theenvcapability, and even with itECKO_*has always been refused. Only the manifest path was unchecked.
- Archiving no longer reads outside a scoped
fs:readgrant. Building a zip or tar walked a directory using checks that follow symlinks, so a link planted in the tree was read straight through - and a link to a directory pulled in its whole subtree. The capability check ran on the inputs you named, never on what the walk found underneath. A package grantedfs:read:/repocould therefore archive anything reachable by a symlink in/repoand ship it out over its ownnetgrant. The grant said "read this directory"; it read outside it. Links that leave the tree are now skipped, the way extraction already drops them. A symlink that stays inside the tree is ordinary content and is still archived. - The archive size limit counts bytes written, not bytes promised.
ECKO_ARCHIVE_MAX_UNPACKEDwas checked against the uncompressed size declared in a zip's own headers, and extraction then copied without a bound. A zip that understated its sizes expanded as far as its deflate stream went: measured at 8 MiB written under a 64 KiB cap, and the ratio scales. The limit is now enforced on what is actually written, so a hostile archive is cut off at the cap. An honest archive that declares more than the limit is still refused up front, before anything is written. The tar path always counted real bytes and is unchanged.
Fixed
ECKO_AI_MAX_CALLSnow counts embeddings, soraganddbcannot spend around it. The budget is what you set before leaving a program unattended, and it counted theaikeyword and nothing else.embedis a billed call on a billed endpoint, and everything built on it -rag.index,rag.retrieve,db.add,db.search- made one per item, uncounted:
Indexing a thousand documents was a thousand billed calls under a cap the operator believed was hard. One budget covers both now, so it no longer matters which verb spends it. If you set a budget with room for embeddings to slip under it, raise it - a program that fits in its old budget may now stop, which is the point.# ECKO_AI_MAX_CALLS=2 a = embed("one") b = embed("two") c = embed("three") # before: fine. now: budget exceeded
0.34.0
Added
serialreaches the baud rates a modern device actually uses. The ceiling was 230400, so 460800, 921600 and 1000000 - ordinary for MCU links, GPS modules and LIDAR - were refused outright. Linux now reaches 4000000. macOS still stops at 230400, because going faster there needs a different mechanism entirely, andopensays so by name rather than leaving the rate missing from a list. The check and the message it prints now come from one place, so they cannot disagree about what this build supports.
Fixed
- The docs now say how to size the HTTP worker pool.
ECKO_HTTP_WORKERSis the knob that decideshttp.servethroughput, and the right value differs by about 4x depending on what handlers do - which nothing said. Measured on a 32-core box: | workers | handler that computes | handler that waits 5ms | |---|---|---| |8(default) | 570,000 req/s | 1,570 req/s | |32| 401,000 req/s | 6,214 req/s | A handler that waits on a database, another service oraiholds a slot without using a core, so throughput is roughlyworkers / handler time: at the default, a 5ms handler caps the server at 1,600 requests a second no matter how many cores the machine has. A handler that computes is fastest at the default, because extra workers only contend for CPUs. Sizing the pool covers both cases, and the Docker guide now says which one its16is for.
- Editor rename refuses to collide with a name already in scope. Renaming
totaltosumwhere asumalready existed produced this:
The result compiles, runs, prints 4 instead of 3, andlet total = 1 let sum = 1 let sum = 2 -> let sum = 2 print(total + sum) print(sum + sum)ecko checkhas nothing to say about it - because nothing is wrong with it, it is simply a different program. Rename is the one editor feature that writes to files you are not looking at, so a silent change of meaning is the worst thing it can do; a rename that breaks the build is recoverable, this is not. It is now refused, naming the conflict, like every other case rename cannot resolve exactly. A same-named binding in a scope that cannot see the one being renamed is still fine - an innersumdoes not block renaming an outertotal.
serialwrites can no longer hang forever. The port is opened non-blocking, so a full output buffer comes back as would-block - and that was answered by sleeping 2ms and trying again, with no deadline, nothing to cancel it, and the timeout you set applying only to reads. Flow control makes that reachable by design: a peer usingrts_ctsorxon_xoffholds the buffer full for as long as it likes, so flow control working correctly became a hang nothing could stop.io.timeout(p, ms)now bounds writes as well as reads, and the error says how far it got -write: timed out after 4096 of 65536 bytes- because those bytes are already on the wire and resending the whole buffer would repeat them. With no deadline set, a write waits indefinitely, exactly as a read does.- Completion offers the AI types, and the playground colours
sql. Two more hand-kept lists that had fallen behind what the language defines.ai[Sentiment],ai[Classification]andai[Entity]all work, and editor completion offered none of them - so the three types that exist because Ecko is an AI language were the ones you could not tab-complete. They are offered now, and checked against the registry that defines them. On ecko.sh/play,sql { ... }rendered as plain text while every other keyword was coloured. The playground's word list is deliberately its own - it paintsaidifferently, and paints several contextual names the lexer does not call keywords - but it is now checked to cover every real keyword.
0.33.0
Breaking
- A string that is only an interpolation is now a string.
"{n}"used to evaluate tonitself, so a lone hole was the one string literal that did not produce text:
One character of surrounding text changed the answer, and the failure it caused pointed at the wrong thing -type_of("{1}") # was "int", now "string" type_of("x{1}") # was "string", still "string"insert(m, "{n}", "value")reportedinsert needs a string, got inton a line whose argument is plainly quoted."{n}"now rendersnthe waystring(n)does, like every other interpolation. A literal with no holes is untouched:"hello"is still handed straight back. Migration: if you relied on"{expr}"yielding the value rather than text, drop the quotes and writeexpr. Code that wanted text is unaffected, and code that wanted a number out of a string wantsint(...)orfloat(...). This also closes a hole in theunwrapped-credentialcheck. Wrapping a credential read in a lone hole made the finding disappear, because the expression was indistinguishable from the bare read;"key={os.env("K")}"reported it all along. Both report it now.
Added
on_upgrade: refuse a WebSocket before the handshake.on_wsruns after the 101 has gone out, so an unwanted socket could only be accepted and then closed - a browser saw a successful connection followed by a close code, and an unrouted path got a socket rather than a 404.on_upgraderuns before the 101 with the same request map. Returnnullto accept, or a response to refuse with it:
A refused upgrade costs no connection slot, so refusing is not a way to exhausthttp.serve(8080, handler, on_upgrade: fn(r) { if r.path != "/live" { return http.not_found() } if get(r.headers, "cookie") == null { return http.response(403, "sign in") } null }, on_ws: fn(c) { ws.send(c, "welcome") })ECKO_MAX_WS_CONNS. A hook that raises refuses with a 500 rather than accepting. Existing servers are unaffected: with noon_upgrade, every upgrade is accepted exactly as before.
Fixed
- An error about a bad argument now points at the call, not at the last argument. The caret landed on the final argument whichever one was actually wrong, so the message and the caret disagreed:
A reader checks the argument under the caret, finds a perfectly good string, and concludes the error is nonsense. The caret now sits on the call itself.insert(42, "k", "v") bad 1st argument, caret under "v" insert({}, 42, "v") bad 2nd argument, caret under "v" - Editor completion knows about the whole language again. The language server kept its keyword and builtin lists by hand, and they had fallen behind: the bitwise operators (
band,bnot,bor,bxor,shl,shr) andsqlwere missing as keywords,secret,revealandis_secretas builtins, and none of the built-in variants -Ok,Err,Some,None, and the ready-madeSentiment,ClassificationandEntitysets - were offered at all. All of them complete now, with variants shown as variants rather than as plain functions. A missing completion is invisible: an editor that does not offer a name looks exactly like a language that does not have one, which is why this went unnoticed. The lists are now checked against the lexer and the builtin registry, so they cannot drift again. - The builtins reference documents every builtin, and says what the units are.
pubdocs/stdlib/builtins.mdwas missing fourteen names - every variant of the built-in structured types,OkandErr,SomeandNone, and the ready-madeSentiment,ClassificationandEntitysets - which are exactly the names you cannot guess. They now have their own section, with the fields each constructor takes. Units are stated where the language is not uniform about them.sleeptakes seconds and accepts a fraction, whilestd.bg's schedulers,io.timeoutandwatch.nexttake milliseconds, andhttp'stimeout:option - which never said - is seconds. As with the completion lists, the page is now checked against the live registry rather than kept by hand.
0.32.0
Added
str.normalizeandstr.category: Unicode text you can compare. Unicode lets one visible character be spelled more than one way - "é" is either U+00E9, or "e" followed by the combining acute U+0301 - so two strings that look identical compare unequal and report different lengths. Which spelling arrives is not your choice: macOS filenames are decomposed, most of the web is composed, and a model returns whatever its tokenizer produced.
Four forms, from UAX #15, and the name is not case-sensitive.import std.str # Same word, two spellings. Before: false. Now comparable. str.normalize(a, "NFC") == str.normalize(b, "NFC")NFCandNFDare canonical and round-trip - useNFCfor what you store.NFKCandNFKDalso fold presentation differences (the "fi" ligature becomes"fi", "②" becomes"2"), which is lossy on purpose - use those for search and matching. ASCII is unchanged by all four.str.category(ch)returns the Unicode general category of a single character as its two-letter abbreviation ("Lu","Nd","Mn"). WithNFDit is how you fold accents without writing a table of your own - decompose, drop every character whose category is"Mn", rejoin:fn not_a_mark(c) = str.category(c) != "Mn" fn strip_marks(s) { str.chars(str.normalize(s, "NFD")) |> filter(not_a_mark) |> join("") } strip_marks("café naïve") # "cafe naive"
Fixed
ecko fmtno longer mangles a closure inside a pipeline. In a pipeline of three or more stages, a stage that renders multi-line - a closure with a block body always does - was indented from the statement instead of from its own|>. The body came out level with the pipe that owned it and the closing brace a level further out again, reading as though the pipeline had already ended:
It now nests where it belongs:xs |> filter(fn(c) { c != 1 }) |> join("")
The inline two-stage form, where stages share the statement's line, is unchanged. Reformat withxs |> filter(fn(c) { c != 1 }) |> join("")ecko fmtif you have this shape in a file.
0.31.0
Added
ecko checkcatches a typo in an error kind. Dispatching on a failure means matching a string, and a misspelt one was simply an arm that never ran - no error, no warning, the failure quietly falling through to the next case:
Only where the thing being matched is plainly a kind -match get(e, "kind") { "fss" => retry() # unknown-error-kind: no failure carries the kind _ => give_up() # 'fss' - did you mean 'fs'? }get(e, "kind")ore.kind- so an ordinary string match is untouched.- Every error kind is now documented. The taxonomy lived in prose and had drifted both ways: eight kinds were being thrown that no page mentioned -
ai,archive,assert,os,proc,signal,timeout,watch- and the page listedcancelled, which is real but is built by a different route. The kinds table in the error-handling docs is now the whole set of 18, and a release fails if the code and that table disagree in either direction.
ws.request(c): what a WebSocket client asked for. A server-side socket handler received a connection id and nothing else - no path, no headers, no cookies - so a program could not route an upgrade or authenticate one. The only thing that could refuse a socket was the server's ownOrigincheck.
It returns the same map an HTTP handler receives -http.serve(8080, handler, on_ws: fn(c) { r = ws.request(c) if not session_ok(r.headers.cookie) { ws.close(c) } else { join_room(r.path, c) } })method,path,params,headers- so one piece of code can read either. A client connection answersnull, having never been upgraded from anything. A verb rather than a second argument toon_ws: arity is checked at the call site, soon_ws(c, req)would have stopped every existing handler before the program started. It cannot refuse an upgrade before the handshake - the 101 has already been sent by the time the handler runs, so an unwanted socket is accepted and then closed rather than answered with a 403.
Changed
- String interpolation allocates less. Building
"k{i}"allocated about five times per hole - a temporary for each part, copied into the result and thrown away, plus a buffer that regrew as it filled. It now writes the common values straight into a buffer sized up front. A dictionary benchmark that builds 600,000 interpolated keys runs 22% fewer instructions and about 11% faster; the time it spends in the allocator fell from a quarter to a fifth. Nothing else moved by more than 2%, in either direction. Interpolation is where a program that assembles prompts, JSON, log lines or keys spends its allocator time, so this is worth more than the benchmark name suggests. Output is unchanged for every kind of value.
0.30.0
Added
- A package can say which Ecko it needs.
"ecko": "0.30.0"in a package'secko.jsonis checked when the package is imported, so someone on an older build is told what to do:
Without it, using a package written against a newer release failed wherever the missing feature happened to be reached - a message about a verb that does not exist, nowhere near the actual cause. Compared on major and minor, so a package atpackage 'gps' needs Ecko 0.29 or newer, and this is 0.28.0. Upgrade with `curl -fsSL https://ecko.sh/install | sh`, or pin an earlier version of the package.0.30.4asks for the 0.30 line rather than that exact patch. The field is optional and absent means "any", which is right for most packages. The check lives in the binary doing the importing, so it protects people on 0.30 and later. Ecko 0.29 and earlier ignore the field, as they always did - declaring it cannot help someone who has not upgraded yet. fs.chmodandfs.mode. A program that writes a script, a git hook or a launcher can now mark it runnable:
The only way to do this before wasimport std.fs fs.write("./deploy.sh", "#!/bin/sh\necho deploying\n") fs.chmod("./deploy.sh", "755") fs.mode("./deploy.sh") # "755"os.exec("chmod", ..), which needs theexeccapability - so a package trusted to write a file had to be trusted to run any program on the machine to finish the job.chmodneedsfs:write,modeneedsfs:read. The mode is a string, not a number. Ecko has no octal literal, so"755"is the only readable spelling - and755as a number means0o1363, which is setuid and readable by nobody. A numeric mode is refused, with the string to write instead in the message. Unix only; on Windows an executable is decided by its extension.- A type from another module can be written with its module name, on both sides:
The brace form previously evaluated to the constructor function rather than building anything, andimport "./shapes.ecko" c = shapes.Circle { r: 2 } match c { shapes.Circle { r } => r * r shapes.Square { s } => s * s }ecko checkpassed it - so the mistake surfaced later, as a function turning up where a value was expected. The pattern form was a parse error. The qualifier says where the type was declared, not what it is called, soshapes.Circle { r }andCircle { r }match the same values and the two spellings are interchangeable.
Fixed
error()with no argument is refused. It was the one builtin that accepted a missing argument, so a typo threw an error with nothing in it - and the two execution tiers disagreed about what that nothing was, meaning the same mistake reported differently depending on whether it sat inside a contracted orasyncfunction. It now reads like every other one-argument builtin:
Throwing null deliberately is stillerror needs at least 1 argument(s)error(null).
0.29.0
Added
std.serial: serial ports. There was no way to open one, so nothing attached over USB or RS-485 could be talked to at all.std.fswill open/dev/ttyUSB0as a file, and that mostly does not work: the port keeps whatever baud rate, parity and line discipline it was left in, canonical mode buffers until a newline and rewrites CR and LF on the way past, and there is no read deadline short of waiting forever.
A port is a stream, so theimport std.io import std.serial p = serial.open("/dev/ttyUSB0", 115200) io.timeout(p, 2000) serial.flush(p) # drop whatever was already buffered io.write(p, "AT\r\n") serial.drain(p) # wait for it to actually go out print(io.read_line(p)) io.close(p)std.ioverbs read it:read,read_exact,read_until,read_line,lines,write,timeout,close, andfor chunk in p. No new vocabulary.serial.drain,flush,dtrandrtsare the thingsstd.iohas no word for -dtrandrtsdrive the lines that auto-reset a microcontroller board and switch an RS-485 transceiver between transmit and receive.opentakesdata_bits,stop_bits,parityandflow. Standard baud rates only, with the supported set named when one is refused, since it genuinely differs between Linux and macOS. A read deadline raisesserial: timed out; it has millisecond precision and no ceiling, unlike the termios field it would otherwise use, which saturates at 25.5 seconds. Testing without hardware:serial.open("loop://", baud)is an in-memory echo port that needs no grant, so a suite and an example run with nothing attached. It is a loopback, not a simulator - it cannot show you a baud mismatch or bad framing. Linux and macOS. On Windows a real port reports that serial needs a unix terminal;loop://works everywhere.- A sixth capability,
device. Opening a serial port needs it, and it is path-scoped like thefspair:
It is separate from"dependencies": { "gps": { "path": "github.com/me/gps", "grant": ["device:/dev/ttyUSB0"] } }fs:read/fs:writeon purpose. Granting a packagefs:writeis a common and reasonable thing to do, and it must not quietly also mean "may drive any attached hardware". Nothing existing changes: a manifest that does not mentiondevicebehaves exactly as before. - UDP datagrams in
std.net. The socket layer had no datagrams, so DNS on a non-standard resolver, NTP, syslog, statsd, mDNS and every LAN discovery protocol were out of reach without shelling out.
Seven verbs:import std.net sock = net.udp_bind("0.0.0.0", 0) # 0 asks the OS for a port net.send_to(sock, "127.0.0.1", 9000, "ping") msg = net.recv_from(sock) # one datagram print(string(msg.data)) # ping print(msg.host) # and who sent it net.udp_close(sock)udp_bind,send_to,recv_from,timeout,broadcast,udp_close, andportwhich now answers for a UDP socket as well as a listener. A UDP socket is not a stream, deliberately. Thestd.ioverbs join consecutive chunks, which is right for a file or a TCP connection and wrong for a packet:io.read_exact(s, 10)would splice two unrelated datagrams into one value and nothing could tell. So UDP does not reuse those verbs.recv_fromreturns one datagram as{ data, host, port }- with the sender, because a connectionless reply has no other way to know where to go. A deadline raisessocket: timed outrather than returningnull:nullmeans end of stream, and a datagram socket has no end. Sockets start with the deadlineECKO_NET_TIMEOUT_MSalready gives TCP. Multicast group membership is not included.net.broadcastcovers ordinary LAN discovery; joining a group needs interface selection and IPv6 scope ids.
- Rename works across files. Your editor's rename command (F2) now renames a symbol and every reference to it, including references in other files. A name a file
exports is rewritten wherever another file reaches it through an import, under whatever alias that file chose:import "./cart.ecko" as cmakes itc.total, and that is what gets updated there. It refuses rather than guesses, because renaming is the one editor action that rewrites files you are not looking at. A new name that is not a plain identifier or is a keyword, a cursor on a built-in or a standard-library module, and a file that both imports yours and declares its own binding of the same name - wherealias.thingcannot be attributed to either - all come back as a message rather than a partial edit. Both editor extensions pick this up with no change: they negotiate capabilities with the server.
Fixed
proc.killworks on Windows. It did nothing there. The function that signals the process group was an empty stub on any non-Unix platform, soproc.kill(h)terminated nothing, reported success, and then blocked in the reap waiting for a process it had not stopped. A script that killed a child and carried on instead hung, and the child outlived the program. It now ends the tree withtaskkill /PID <pid> /T /F, reaching grandchildren the way the Unix path does. The same stub also affected the cleanup that runs when a program exits with children still live. Windows has no signals, so the distinction betweentermandkillcannot be honoured there - both terminate. An unknown signal name is now refused on every platform rather than only on Unix, so a typo fails the same way wherever you run.--helpnow works on every command, and only prints help. It was recognised only as the first word, so anywhere else it was treated as an ordinary argument and the command simply ran. Asking what a command did performed it:ecko test --helpran the whole test suiteecko pack --helpwrote a zip into the working directoryecko get --helptried to fetchhttps://--help.gitecko fmt --helpandecko check --helpreported "needs at least one file" Each command now has its own help, with its options and an example or two.ecko <command> --helpor-h, and the command list points at it. A program's own--helpis still the program's:ecko app.ecko --helpandecko run app.ecko --helppass the flag through to your code, as before.
- Streamed
aicalls now count against your budget.ai "..." -> streamnever checkedECKO_AI_MAX_CALLS, so the one setting that stops an unattended run spending without limit did not apply to streaming at all. A retry loop around a streamed call could make any number of paid requests with the cap set to 1. Streamed calls are counted like every other call now. If you have been relying on a budget while streaming, it was not in force. Check the spend on anything long-running that used-> stream. - Streamed calls report what they cost. They recorded no token counts, so
ECKO_TRACElogged nulls for a streamed call and left its cost line out entirely: a run that streamed showed no spend for the part that streamed. The counts now come back from the provider and appear in the trace like any other call's. - A stream that fails before it starts is retried. A timeout, a 429 or an upstream 5xx raised immediately, with no retry, on the one call form most exposed to a dropped connection. Such a failure is now retried up to three times with backoff, matching non-streaming calls. Only before the first chunk reaches you. Once your loop has been handed a chunk it cannot be taken back, so retrying would deliver it twice and the text you assemble would be wrong with nothing able to detect it. A stream that fails part-way through still raises, as before.
0.28.0
Security
py()no longer hands your environment to Python. The worker inherited everything the parent held, soos.environ.get("ECKO_API_KEY")inside apy()call returned the real provider key, along with every other credential and setting. It now receives only what Python needs to run:PATH,HOME, the locale and thePYTHON*variables.ECKO_PY_ENV=NAME,OTHERforwards named variables deliberately.
Fixed
- A blown recursion limit no longer buries the reason under 4,000 lines. The cap is 2000 frames and every frame printed its name, location and source line, so a two-line runaway produced 4,008 lines and 123 KB on stderr. The line that mattered was the first of four thousand and had scrolled away before it could be read, and in CI it buried the real failure in a log someone then had to page through. Eight frames from each end are now shown, with the count between them labelled:
... 1984 more frames elided (the full depth is 2000) .... The same program now prints 41 lines. The cap is on what is shown, not what is recorded, soECKO_MAX_DEPTHkeeps its meaning and a short stack still prints in full. The call-stack header also said "most recent first" while printing the opposite order. It now reads "outermost call first", which is what it does. - One slow
py()call no longer parks every other one. Allpy()calls share a single worker, and the read that waits for its answer had no deadline, so a Python function that never returned held the lock for the rest of the program: every laterpy()blocked behind it, including calls from unrelated tasks and HTTP handlers, with no timeout to tune and no way to cancel. A call that does not answer withinECKO_PY_TIMEOUT_MS(default 30000) now raises kindtimeout, and the worker is restarted so later calls still work.
0.27.0
Added
hash.constant_eq(a, b)compares a secret without an early exit.==returns as soon as it finds a differing byte, so how long it takes reveals how much of a guess was right, and an attacker who can time the answer recovers a MAC or a token a byte at a time. Use it for anything secret you check for equality: a MAC, a CSRF token, a session id, an API key. It takes strings or bytes, and a string compares as its UTF-8, so a hex digest and its_bytesform both work. Values of different lengths answerfalserather than raising: content is hidden, length is not. Everything it is meant for is fixed-length. Writing this in Ecko cannot work, which is why it is native: a loop in the language allocates, bounds-checks and can be descheduled between iterations, so it promises nothing about timing.hash.passwordandhash.verifyare native for the same reason.
Fixed
- Generated package reference pages list only exported definitions. The reference pages at ecko.sh/docs/packages listed a package's internal helpers alongside its API, marked
*not exported*, inviting a reader to depend on somethingimportwould not give them. Only single-file packages were affected; a multi-file package's entrypoint re-exports just the public surface.
0.25.0
Added
- Float literals take an exponent:
1e3,2.5e-9,6.022e23,1E5. An exponent always produces afloat, so1e3is1000.0rather than the integer1000, matching Python, JavaScript, Rust and Go. At least one digit must follow theeand its optional sign, soeremains usable as an ordinary name and2 * eis still arithmetic.ecko fmtreprints the spelling you wrote instead of expanding it.
Fixed
Values a caller supplied could reach code that could not take them, crashing the runtime or returning a wrong answer. None of the crashes were catchable with try.
- Calling a native with no arguments killed the process.
json.encode(),json.decode()andllm.chat()each read a missing argument. They now return an error. os.exitturned a failure into a success. An exit code outside the 32-bit range wrapped, soos.exit(4294967296)exited0and a script reporting failure was recorded by CI as passing. Out-of-range codes now error.powreturned the wrong answer for a large exponent. The exponent wrapped, sopow(2, -3000000000)gaveinfwhere the answer is0, andpow(2.0, 3000000000)gave0where it isinf. Integer results still error on overflow rather than wrapping.fmt.fixedpanicked or returned"NaN"for a large number of decimal places. The count is now bounded at 1000, and anything above that errors.retry(4294967297, f)ran once instead of the requested number of attempts, andimage.resize(img, 4294967296, 10)produced a zero width that then failed inside the encoder. Both now error on an out-of-range count.- An internal fault now reports as a bug in Ecko, with a clear message and exit code 70, rather than dying with a raw panic that looked like a fault in your program.
ECKO_BACKTRACE=1prints the detail. - The credential lint no longer fires on an existence check. Testing whether a credential is set, as in
if api_key == null, is not a leak and no longer asks you to wrap it. ecko fmtno longer moves a comment written on a function parameter onto unrelated code below it.
Hardening
- The VM's raw pointers are now checked by miri, the native function surface is fuzzed against arbitrary arguments, and every remaining site that could crash or narrow a value is counted by a gate that may only go down. The fixes above are what the fuzzer found.
0.24.0
Breaking
- Eight more standard-library modules throw a map where they threw a sentence.
db,image,config,defaults,encoding,zlib,timeandllmhad no structured errors, soget(e, "kind")was null for a malformed file and a bad argument alike. Their operational failures are now{ kind, message, ... }like the rest of the standard library. Usage errors stay prose, so a caller's own bug still reads as one. Migration: a handler that concatenated the caught value into a string ("failed: " + e) must reade.message.print(e)anderror(e)are unchanged. - A file refused by the allocation cap is
kind: "limit", notkind: "fs". Nothing about the file failed; a configured ceiling refused it. The payload names the ceiling (limit) and the variable that raises it (env) alongside thepath. Migration: amatcharm dispatching on"fs"for a too-large file wants"limit". Every otherfsfailure is unchanged.
Added
- Server sockets:
net.listen,net.accept,net.portandnet.stop.std.netwas client-only, so nothing covered a line protocol, a proxy, or a fake server for an integration test.net.acceptanswers a connection stream, ornullwhen nothing arrived before its deadline. An accepted connection is an ordinary stream read with theioverbs.net.starttlsrefuses one, since TLS on the accepting side presents a certificate wherestarttlsverifies one. http.port()reports the port the running server bound.http.serve(0, handler)asks the OS for a free port, and there was no way to learn which one it chose. The startup banner now names the bound port, andhttp.port()reads it back from a handler or from a task spawned beforeserve, ornullwhen nothing is serving.- Two more error kinds,
ioandlimit.iocovers the stream verbs andstd.term's tty failures.limitis a configured ceiling refusing to allocate or expand, and carrieslimit(the ceiling in bytes) andenv(the variable that raises it), so a program can report both without scraping the message.
Changed
- The whole-file allocation cap covers every module that reads one.
ECKO_MAX_ALLOCboundedfs.readandfs.read_bytes, butcsv.read,json.read,db.load,config.loadand theecko.jsonloader each read files with no ceiling, so a filefs.readrefused would abort the process ifcsv.readwas asked for it instead. All five now share one reader, andhttp.serve's static handler answers500rather than holding a file past the cap.
Fixed
io.closeinterrupts a read already in flight instead of deadlocking behind it. A task parked inio.readheld the stream's lock for the whole wait, so closing from elsewhere queued behind it and the obvious way to stop a reader hung the program. A parked read on a pipe, an HTTP body or standard input now gives up with an error of kindio. A socket, a file and the console sit inside a blocking system call and still wait, which the documentation now says plainly.ecko checklooks inside function bodies. The cross-file member and arity checks stopped at the containers holding statements, so a call written inside afnwas never checked, which is where all real code lives.- A stream failure keeps its kind through
for.io.readon a closed stream raisedkind: "io", while the same failure throughfor piece in streamarrived as a bare string. - Four REPL fixes.
exit;now leaves the REPL instead of failing to parse and poisoning later history. TAB completes every keyword and everystdmodule, where thirteen keywords and ten modules were missing from hand-kept lists. A comment inside brackets no longer leaves the prompt waiting for input for ever. A line ending in an operator now waits for the right-hand side rather than erroring.
0.23.0
Breaking
std.netandstd.proclost their own read and write verbs. A socket fromnet.connect, a file from the newfs.open, a child's pipe, an HTTP body and standard input are all onestreamvalue now, read through one verb set instd.io. Nine names went with the old arrangement: | was | now | |---|---| |net.send(c, data)|io.write(c, data)| |net.recv(c, n)|io.read(c, n)| |net.recv_text(c, n)|io.read_text(c, n)| |net.recv_exact(c, n)|io.read_exact(c, n)| |net.recv_until(c, delim)|io.read_until(c, delim)| |net.close(c)|io.close(c)| |proc.write(p, data)|io.write(proc.stdin(p), data)| |proc.read_line(p, ms)|io.read_line(proc.stdout(p))| |proc.close_stdin(p)|io.close(proc.stdin(p))| Four of these change behaviour, not just spelling.read_exactandread_untilreturnnullat a clean end whererecv_exactandrecv_untilraised.read_lineraises on invalid UTF-8 whereproc.read_linedecoded it lossily.writereturns the byte count wherenet.sendreturnednull. See the migration guide for what each means for code carried over unchanged.ecko checknames the replacement for a call at the top level of a file, and the error raised at runtime names it for a call inside a function. There is deliberately no automatic rewrite:proc.read_line(p, 5000)returnednullafter five seconds andio.timeout(proc.stdout(p), 5000)raises instead, so a mechanical change would alter what a loop means.proc.read_line's timeout argument is gone with it. A deadline is nowio.timeout(stream, ms), and it raises rather than returningnull. The old shape could not tell a slow child from a finished one.- A streamed HTTP body is bytes, and has no
body_bytes.http.get(url, stream: true)returns a body read from the connection as your program reads it, sofor chunk in resp.bodyyields bytes and nothing accumulates, which is what makes a download larger than memory possible.io.read_textandio.linesdecode. Withoutstream: true, nothing changes. This also fixes a corruption: each chunk used to be decoded on its own, so any character whose bytes straddled a chunk boundary became two replacement characters, and those boundaries fall wherever the network puts them. net.starttlsreturns the upgraded stream rather than changing a handle in place, so the shape isc = net.starttls(c). It refuses the upgrade while plaintext read-ahead is still buffered, instead of silently dropping bytes that arrived before the handshake.fs.read,fs.read_bytesand a buffered HTTP body are capped byECKO_MAX_ALLOC. A whole file or non-streaming response larger than 256 MiB raises an error namingfs.openorstream: true. Previously the process aborted, whichtrycannot catch.
Added
- Streams: one value and one verb set for every byte source.
type_of(s)is"stream"for a file fromfs.open(path, mode?), a socket, a child's pipe, an HTTP body fetched withstream: true,io.stdin/stdout/stderr, andai "..." -> stream.std.iogainedread,read_text,read_line,read_exact,read_until,write,timeout,close,lines,stdin,stdoutandstderr, andforover a stream yields its unit, bytes or lines. A read that reaches the end with nothing pending returnsnull; one that reaches the end mid-answer raises rather than answering short.ECKO_MAX_ALLOCbounds each piece rather than the stream, so a program handles input larger than memory as long as it never asks for all of it at once. io.lines(s?)iterates lines without anis_nullcheck, and with no argument reads standard input.hash.sha256,hash.sha1, their_bytesforms and the message ofhash.hmac_sha256take a stream.hash.sha256(fs.open(path))digests a file of any size one chunk at a time, which is why there is nohash.file. A line stream is refused, since its terminators are gone.fs.modified,fs.is_symlink,fs.canonicalandfs.symlink. There was no way to read a file's mtime, andis_fileandis_dirboth follow a link, so a symlink was invisible from Ecko.modifiedis unix milliseconds, the same clock astime.now().canonicalrequires the path to exist. The three reads needfs:read;symlinkneedsfs:write, checked against the link it creates rather than the target it points at.fs.size(path)returns a file's size in bytes without reading it. The only way before waslen(fs.read_bytes(path)), which loads the whole file to learn one number, and fails outright pastECKO_MAX_ALLOC. A directory is refused rather than answered with the size of its entry.os.pid,os.cpu_countandos.hostname. Ungated, like the rest ofstd.os's introspection.cpu_countmatters because the runtime already defaultsECKO_MAX_PARALLELto that number, which a program could not previously read.
Fixed
- A pushed stream no longer hands out an empty piece. Every verb in
std.ioreads an empty piece as end of stream, so a stray one truncated the read.for tok in (ai "hi " -> stream)yielded a trailing empty string, andio.read_texton a live OpenAI stream answerednullbefore the first token arrived, because OpenAI's first frame carries"content": "".
0.22.0
Breaking
- A child started with
proc.spawnno longer outlives the program. Nothing ever cleaned up running children, so a script that spawned something and returned left it running, reparented to init and tracked by nothing. Children are now signalled as the program ends, including when it is stopped with Ctrl-C orSIGTERMrather than returning. A script that launched a long-lived helper and exited was relying on the old behaviour. Passdetach: trueto keep it:proc.spawn("daemon", [], detach: true) # survives the program
Fixed
- An interpolation hole could not contain a string.
"val {get(m, "k")} end"failed, because the lexer decided where the string ended before the parser saw the holes inside it, and stopped at the first unescaped quote. A{now opens a hole only when it balances, and the hole's own literals are read whole. Text with an unmatched brace is unchanged, and a balanced{...}that is not valid Ecko is still a parse error, which is why CSS still wants a raw string. - A failed
COMMITleft the connection inside the transaction.sql.transactionrolled back when the function raised, but not when the commit itself failed, which it can do for reasons the function never sees; a deferred foreign-key violation is the everyday one. The transaction stayed open, so every laterbeginon that connection was refused and one failed commit poisoned the connection for the rest of the program. It now rolls back and still reports the commit's own error. - A TUI killed by a signal left the terminal in raw mode. Restore ran at normal exit only, so Ctrl-C on a
term.raw_mode(true)program, an orchestrator'sSIGTERM, or closing the window left the shell with no echo and no line editing, recoverable only by typingresetblind. Entering raw mode now restores the terminal from signal context before the process dies. A program that subscribes to a signal itself throughstd.signalstill takes precedence. - A program that used
std.signalonce could no longer be interrupted.signal.closedropped the subscription but left the handler installed with nobody listening, so the signal was absorbed for the rest of the run and Ctrl-C did nothing. The handler is now kept while any subscription is live, and the signal returns to the operating system once the last one closes. - A signal arriving at the wrong moment could deadlock the process. The handler took locks and allocated while running in signal context, so a signal landing while another thread held one of those locks would hang the program, rarely and unreproducibly. The handler now does nothing but set a flag, and the work moved to an ordinary thread. Delivery is therefore a few milliseconds later, which
signal.nextabsorbs. ecko devleaked a process tree on every reload and every exit, and ignoredSIGTERMentirely, which is what an orchestrator, atimeoutor an IDE stopping the task actually sends, so it died outright and orphaned everything below it. It now handlesSIGTERMandSIGHUPas well asSIGINT.ecko devkept polling a dead child after a failed respawn, so the watcher went quiet with nothing running and no sign anything was wrong. It now says the program is not running and that saving again retries.
0.21.0
Security
- A WebSocket upgrade was accepted from any origin. A page on any other site could open
wss://your.app/from a visitor's browser with that visitor's cookies attached, and no same-origin policy stops a WebSocket the way it stops a fetch. There was noOrigincheck anywhere, andon_wsreceives only a connection id, so a program had no way to refuse one either. An upgrade whoseOrigindoes not match the request'sHostis now refused with 403. Browsers always sendOrigin, and a same-origin page sends a matching one, so ordinary apps are unaffected. Clients that send noOrigin, which is everything that is not a browser, are left alone, since without a browser there are no ambient cookies to ride on.origins: ["https://..."]names additional callers, andorigins: ["*"]is the explicit opt-out for a public socket. - Deleting
ecko.sumturned off package integrity checking, silently. With no sum file, every verification inecko installskipped its check without saying so, so one deleted file disabled integrity for a whole project. Projects without a sum file remain a supported escape hatch, so this stays a warning rather than an error, butecko installnow reports how many packages went in unverified. - A package was verified only after it had replaced the vendored copy. A package that failed its pin had already overwritten what was there, and was then deleted rather than restored. Verification now runs before anything is moved into place, and only a tree matching its pin is installed.
- A
secret: trueconfig field leaked its value when it failed type coercion.MY_PIN=hunter2against a field declared{ default: 0, secret: true }put the value in cleartext in the error message. The error now names the field without quoting what it held.
Breaking
re.capturesanswers a miss withnull, not an empty list. The documentation always promisednulland showedunless c == null { c[1] }as the idiom, so the documented way to use the function was broken: the guard passed and the next line indexed an empty list.re.findbeside it already returnednull. Migration: code that checkedlen(c) == 0should checkc == null.- An untyped
ai "..." using [tools]answers with the model's prose, not the last invoked tool's return value. The same call used to hand back a map offline and a string against a real provider, so identical source behaved differently in mock mode and in production, and code reading a field off the result failed only once it was live. Useai[T]when you want the tool's value.
Fixed
ecko checklet through a named argument the function does not declare. The runtime refused the call, but the analyzer only caught it when the call also exceeded the function's arity, and then reported it as an arity error, which is the wrong cause.- A bare
import <alias>only worked in files sitting besideecko.json. The manifest was looked up in the importing file's own directory with no walk up to the project root, so an alias resolved frommain.eckoat the root and failed fromapp/main.eckoone level down. - A few idle streaming clients could stop an
http.serveserver answering anything. Handler dispatch and every streaming body drain shared one worker pool sized byECKO_HTTP_WORKERS, default 8, and a drain blocks with no timeout, so a handful of idle SSE clients could occupy every worker.
Documentation
std.time's two clocks use different units, and neither was stated.time.now()is milliseconds since the epoch;time.monotonic()is seconds since an arbitrary start. The request-timing middleware shown in the web documentation mixed them.http.servebinds0.0.0.0, which was never documented, nor was thehost:option that changes it. Both are now stated, with a note that a laptop on a shared network probably wantshost: "127.0.0.1".
0.20.9
Security
ECKO_AI_BASE_URLwas used unchecked, so an API key could go out in cleartext. A plain-http://endpoint on any host receivedauthorization: Bearer sk-...readable in transit, and got to dictate the model's reply, which in a language whereaiis a primitive is the program's behaviour. The endpoint must now behttps, orhttpto a loopback host, which is the rule package fetching already applied. Ollama'shttp://localhost:11434and local dev servers are unaffected.ECKO_ALLOW_HTTP=1is the opt-out, and a malformed URL is refused by name rather than failing later as a connection error.
Breaking
std.*functions no longer ignore extra arguments.math.gcd(12, 18, "nonsense")returned 6,str.upper("ab", "junk")returned "AB", andfmt.pad_leftaccepted five. Global builtins have rejected a miscount for some time, but module functions never did, across roughly forty modules. Everystd.*native now rejects extra arguments the way the globals do:math.gcd takes at most 2 arguments, got 3. This turns previously silent mistakes into errors, so a program passing an argument a function never read will now stop. Functions with optional trailing arguments (str.trim,sql.query,zlib.gzip,http.get) and the variadic ones (fs.join,fmt.format,bg.spawn) are unaffected. Arity is checked after the capability gate, so a call you hold no capability for is still refused on that ground first, rather than having its signature revealed by an arity error.
Fixed
- A vision call skipped retries, the cache and token accounting.
ai "..." on <image>was a separate implementation that made one call and coerced whatever came back, so a typed call whose answer was prose returned a String instead of retrying, every vision call traced with zero tokens, and none were ever cached.onnow runs through the same path as every otheraicall: the same budget check, the same retry loop with the images still attached, the same cache, and a trace carrying the provider's real token counts. The cache keys on image content as well as the prompt, so the same question about a different picture is a different call. - A model could pass a tool arguments it never declared, and get back a result of any size. Arguments were matched positionally and gaps filled with
null, so a parameter with a default was passednullinstead of it, a missing argument ran the body withnullrather than reporting an error, and a name the model invented was dropped silently. Every parameter was also advertised as required, including optional ones, which is how a model gets talked into inventing a value. Arguments are now checked against the advertised schema and bound by name, so the function's own defaults and arity apply and anything wrong goes back to the model. Only parameters without a default are advertised as required. Tool results are capped atECKO_AI_TOOL_MAX_RESULTbytes (default 32768,0disables), with the cut marked in the text. A tool result stays in the message list for every remaining round, so one oversized answer was being paid for on every later request.
0.20.8
cost()could not price an OpenRouter model. The price table holds bare names and the lookup was an exact match, but OpenRouter namespaces its ids by vendor (openai/gpt-4o), so nothing matched andcost()errored for a whole provider. A singlevendor/prefix is now stripped as a fallback, so the id prices at the underlying model's rate. Erroring on a genuinely unknown model stays, since a typo must not price at zero, and a deeper path is still unknown rather than guessed at.ecko updatewas documented as working. The command is honest - it explains that re-resolving versions across a dependency graph needs minimal version selection and points atecko get <path>@<version>- but three public pages and--helpall described it as re-fetching and re-pinning, so a reader would plan an upgrade path around a command that does nothing. All four now say it is not implemented and what to use instead, and a test pins the command and its help text together.
- A brace inside a SQL string literal opened an interpolation hole. The
sql { .. }scanners counted every{and}literally, in both the lexer and the parser, so an ordinary JSON value in a query -WHERE data = '{"x":1}'- was read as a hole and the block failed to parse with "Unexpected token ':'". Both now track SQL'...'literals, treating''as the escaped quote it is, and only outside a hole, since'is not a delimiter in Ecko expressions. The matching interpolation problem - a}inside a hole's own string, as in"prefix {"}"} suffix"- is not fixed here. It needs the lexer to tell a hole from a stray brace before it decides where the string ends, because"a{b"is legal today and must stay so; a first attempt that tracked brace depth in the string reader broke exactly that, caught by the example corpus. It needs lookahead and is its own change.
Fixed
ecko testfound only the top level, while the docs promised the whole tree. Discovery read the top level oftests/and the top level of the working directory, and an explicit directory argument was non-recursive too. A suite split across subdirectories therefore lost coverage silently andecko teststill exited green - the worst way a test runner can fail, because nothing looks wrong. Both conventions now walk the whole tree, which is what the public page said and what the stdlib release workflows already assumed. Four kinds of directory are skipped:vendor/, because a dependency's tests belong to its maintainer and would fail for reasons the importing project cannot fix;target/andnode_modules/, which are build output; and anything beginning with a dot. Symlinked directories are not followed, so a loop cannot spin the walk. A file matching both conventions is counted once.
Fixed
net.connecttried only the first address a name resolved to. A dual-stack host puts::1ahead of127.0.0.1forlocalhost, so connecting by name to an IPv4-only listener - which is whathttp.serveis, since it binds0.0.0.0- failed outright with "can't connect to [::1]:PORT: Connection refused" rather than falling back. Every address is now tried in turn, which is what other clients do; the 30-second budget is divided across them (floored at five seconds each) rather than spent per address, so a name with several unreachable addresses cannot multiply the wait. Found by CI: a test added in 0.20.7 connected tolocalhostand passed on a machine that resolves IPv4 first, then failed on a runner that does not.
0.20.7
Fixed
ecko checkexhaustiveness never fired for a brace-pattern match. The checker understoodA(n)and a bare fieldlessA, and everything else fell to a catch-all that abandoned the whole match rather than skipping one arm - so a match writtenA { x: n } => .., which is the same match in the record spelling, reported nothing at all however many variants were missing. Both spellings are checked now, with the same rule about refutable sub-patterns:A { x: 0 }matches only someAs, so it abstains rather than claiming coverage or inventing a false positive.- Indexing a range worked on one engine and not the other. The VM had a
Rangearm in its index op and the AST evaluator did not, so(0..10)[3]was3on one tier and an error on the other - and the AST tier is what anything contracted orasyncruns on, so it was not a theoretical path. The spec already said a range "compares, prints, indexes, and destructures as its list"; only one engine did. The error was misleading too, since a range reports its type aslist: it read "Can't index list with int" about something that was not a list. cli.help(spec, cmd)ignored the subcommand. The second argument was accepted and dropped, so it rendered the top-level help - which left a subcommand's help unreachable even thoughcli.parsehandlesmytool add --helpand reports which command was chosen. The obvious program,if r.help { print(cli.help(spec, r.command)) }, printed the wrong thing. It now renders that subcommand, with the usage line naming the program and the command;nullmeans the top level, sor.commandcan be passed straight through, and an unknown name is an error rather than a silent fallback.
Added
--ends the interpreter's own flags.--model,--provider,--key,--cacheand--traceare recognised after the script as well as before - which is the documented form - so a program could not have flags of its own by those names:ecko app.ecko --key mine fooset the AI credential and handed the program only[foo]. Everything after--is now passed through verbatim. Additive: every existing invocation keeps working.
Security
net.starttlsverified the certificate against the resolved IP, not the host you named. It used the socket's peer address as the TLS server name, and a certificate is issued for a hostname, so the check was against the wrong thing entirely: connecting tolocalhostand upgrading reportedcertificate not valid for name "127.0.0.1"against a certificate issued for localhost, and in the other direction the name a caller actually trusted was never checked.net.connecthad the hostname all along and discarded it, while the neighbouringnet.connect_tlsused it correctly; the plain connection now keeps it andstarttlsverifies against it.std.netsockets had no read or write deadline. A 30-second connect timeout existed and nothing after it, so a peer that accepted the connection and then sent nothing maderecv,recv_exactandrecv_untilblock forever with no cancellation path - a client talking to a silent or wedged server simply stopped.ECKO_NET_TIMEOUT_MSbounds it, defaulting to 30000 to matchECKO_HTTP_READ_TIMEOUT_MSon the server side.0disables it, because a socket waiting on server push is a legitimate thing to want.
Fixed
- A CI-only failure in the
ecko testmock-mode tests. The three tests intests/test_cmd_forces_mock.rsbuilt their scratch directory from the process id alone, so, running on parallel threads in one process, they shared it, and each wiped it on the way in and out. One test's cleanup deleted the suite another test'secko testchild was about to scan, which reported "No test files found". On a fast machine the window rarely closed the wrong way; on a GitHub runner it did almost every time. Each call now gets its own directory. Test infrastructure only; no effect on the shipped binary.
0.20.6
Security
ecko testdid not force mock mode, and could reach a real provider. It strippedECKO_API_KEYandECKO_LLM_API_KEYand stopped there, but a provider is considered configured for Ollama without a key - so a machine withECKO_AI_PROVIDER=ollamaexported ran the whole suite against a real local model. Verified: a test callingaiproduced "AI request failed after 3 attempts", a live network call, where the docs andCLAUDE.mdboth promise the deterministic mock. Beyond the wrong answer, that makes a suite slow, non-deterministic, and on a paid endpoint billable per run. The provider and base URL are now cleared alongside the keys.http.servetruncated an oversized body instead of refusing it. AtECKO_HTTP_MAX_BODYthe read loop stopped and handed the prefix to the handler with an ordinary status, so the handler could not tell a complete upload from a clipped one: an HMAC check verifies a prefix and passes, and a file arrives silently corrupt. A body over the limit is now answered413 Payload Too Largeand never reaches the handler.- A dangling symlink escaped a scoped
fs:writegrant. Containment canonicalises the deepest existing ancestor and re-applies the rest, on the reasoning that the remainder has no symlinks because those components do not exist. A dangling symlink breaks that: the link itself exists, butcanonicalizefails on it because its target does not, so the walk stepped over it and its name was re-applied unresolved. A package grantedfs:write:./datacould createdata/linkpointing anywhere and write through it - verified, the write landed outside the scope. Each re-applied component is now followed withread_link, bounded at 40 links so a cycle cannot spin. A link whose target already exists was always resolved and denied, so the gap was create-only - which is precisely the case a scoped write grant exists to cover. The documented promise that "neither..nor a symlink inside an allowed directory escapes a scope" was already correct; the implementation did not keep it.
Fixed
int,round,floorandceilsaturated on huge finite floats. The guard rejected NaN and infinities, and its own comment says the intent was to avoid "a silent nonsense value" - but a huge finite float passed it andas i64then saturated, soint(1e30)returned9223372036854775807. That is the same nonsense value the guard exists to prevent, reached by another route. The Decimal arms have always range-checked; the float arms now do too, and a value that does not fit is an error.sumrefused decimals.sum([1.5m, 2.5m])was "sum needs numbers, got decimal", and summing money is the archetypal reasondecimalexists. It now mirrors+exactly, sosum([a, b])anda + bagree: decimals sum with each other and with ints, exactly, and mixing a float in gives the same refusal+gives, because a float cannot join a decimal without losing the exactness that is the point of it.fmt.pad_leftandfmt.pad_rightignored a fill argument. Both hardcoded a space, sofmt.pad_left("7", 4, "0")returned" 7"rather than"0007"- and zero padding is the obvious reason to reach for them. The third argument is now the fill character, still defaulting to a space. A longer fill is refused rather than guessed at, and a fourth argument is refused rather than ignored, which is what the global builtins already do (abs(-1, 99)is an error, not a silent1).
Changed
- Test servers that never bound failed silently. Ten poll loops across six test files waited for a server to accept TCP and then
break, proceeding regardless - so a server that lost the port race or was merely slow surfaced far downstream as a refused connection, or as another test's response to a request it never sent. Two release-check runs failed that way, in different files each time. Every one now fails where the problem is, with a ten-second budget rather than two or four. The six fake providers intests/trace.rsadditionally pick their own port and retry a lost bind, the shape already used by the HEAD tests. Test infrastructure only.
free_portdrops the+ attemptterm instead of striding around it. 0.20.5 fixed the slot aliasing by spacing the counter and the retry index far apart. That works, but it wastes the port range and hides why the bug existed:fetch_addis inside the retry loop, so it already advances on every attempt and addingattemptas well was simply double-counting. The counter alone separates callers. Applied to all 17 test files. Also corrects a claim repeated in the 0.20.2 and 0.20.4 entries and in the comment pasted into those files: cargo does not run test binaries as parallel processes. It runs test targets one at a time, and the contention is between libtest threads inside a single binary - checked by sampling the process table during a full run, which shows one test binary at a time. The pid term in the helper is therefore near-inert; the counter carries the fix. Both historical entries say so now, rather than being quietly edited.
0.20.5
Security
- A crafted
ecko.lockcould delete a directory outside the project. A lockfile key is a path:ecko installjoined it ontovendor/without checking it, and on an integrity failure - or whenecko.sumis managed and carries no pin for the package - calledremove_dir_allon the result. A key containing..therefore escaped the project entirely, so cloning a repository and runningecko installonce was enough for its lockfile to remove a directory of its choosing. Reproduced with a canary directory beside the project, which the install deleted.is_valid_module_pathalready rejected.., absolute paths and unexpected characters, and was already applied to a fetched manifest's module path and to import paths; the lockfile's own keys were the gap. They are now checked inLockfile::load, at the boundary where the untrusted file is read, so every consumer is covered rather than the one call site that was known to delete.
Fixed
- A field declared
Decimalaccepted anything at all.typecheck::matchesknewInt,Float,String,Bool,bytesandjson, and fell through to "accept" for every other name - sotype Money = Money { amount: Decimal }tookMoney("not a decimal")without complaint and stored a string, while the identical declaration withIntwas correctly refused before the program started. Silently wrong, in the one type people reach for precisely because being wrong is expensive. AnIntis accepted and widens, mirroring the existing rule forFloatand the arithmetic; aFloatis refused, since admitting binary floating point through a declaredDecimalfield would defeat the type. - A streamed
airesponse silently truncated on a provider error. A provider that fails part-way through - a rate limit, a content filter, an upstream outage - says so in a frame of its own. Both frame parsers returnedOption<String>, so an error frame parsed as valid JSON with no content, became "no token", and was dropped. The stream then ended normally and the caller got whatever text had arrived, with nothing to say it was cut short. Both shapes are now surfaced as errors: OpenAI and OpenRouter's nested{"error":{"message":..}}and Ollama's bare{"error":".."}. ecko prog | headprinted a Rust panic. Rust ignoresSIGPIPEat startup, so a closed pipe arrives as anEPIPEwrite error andprintln!panics on it - the commonest shell idiom there is produced a panic message and a backtrace note for a program that did nothing wrong. The default disposition is restored inmain, so the process now ends on the signal exactly ascatandseqdo.time.formataborted the process on a malformed format string. chrono reports a bad specifier by returning an error fromDisplay, andto_string()panics on that. A format string is usually user-supplied - from a config file or a CLI flag - so bad input killed the program with a message about Rust internals thattrycould not catch. It is an ordinary error now, andtrycan handle it.encoding.hex_decodeaborted the process on some non-ASCII input. It sliced the string by byte index (&s[i..i + 2]), which panics when the index is not a UTF-8 character boundary:hex_decode("\u{65e5}\u{672c}")died with "end byte index 2 is not a char boundary". Two-byte characters happened to land on boundaries and produced a clean error, which is exactly why this looked already-fixed on a shallow check. Hex is ASCII by definition, so a non-ASCII input is now rejected as invalid, by name.- A test-suite port race:
free_portaliased a retried slot onto the next caller. The slot wasseed + NEXT.fetch_add(1) + attempt, butfetch_addsits inside the retry loop and already advances every iteration, so a caller that retried once returned exactly the slot the next caller's first attempt produced. Two libtest threads then raced for one port: the loser'shttp.serveerror was discarded and its requests were answered by the other test's handler - the same-process collision the helper exists to prevent. Seen once as a test reading another server's response, once as a bind failure. Fixed here by striding both terms apart (64 per allocation, 4099 per retry), which removes the aliasing; see Unreleased for the simpler form that replaced it. The HEAD tests additionally pick their port inside the spawn helper, so a lost bind is retried rather than reported, and that helper surfaces the server's own error instead of discarding it. Test infrastructure only.
0.20.4
Security
- An
envgrant was a back door toexec.os.set_envis gated onenv, and running a program needs the separateexeccapability - but the environment decides whatexecruns. A package holding onlyenvcould pointPATHat a directory it controlled and wait for the root program's ownos.exec("id")to run its binary instead, or setLD_PRELOADto inject code into every process started afterwards. Arbitrary execution, borrowed from whoever did holdexec.PATH,PATHEXTand theLD_*/DYLD_*loader hooks now requireexecas well, because that is the authority they actually confer; a package that already holdsexecgains nothing from them, so nothing legitimate is affected. Separately, anyECKO_*name is refused to a package outright, with no grant combination that unlocks it: those reconfigure the interpreter, andECKO_AI_BASE_URLalone would send every model call and itsAuthorizationheader to a host of the package's choosing. Reading the environment is unchanged, and root code keeps full authority over both.
Performance
- Returning from a function costs one dispatch fewer. A function whose result is a parameter or a literal compiled to two or three ops to hand it back:
fib's leaf case wasGetLocal(0); Jump(->Return); Return, three dispatches for an argument, on roughly half of all its calls. Two peepholes now fuse that into a singleReturnLocalorReturnConst.fib-5.6%,string_join-5.9% (its lambda body is exactlyConst; Return, called two million times),dict_ops-4.9%; geometric mean against PHP across the microbenchmark suite 2.465x -> 2.363x. The peepholes only ever replace an op in place, never insert or delete one, so jump targets and the parallelspansarray are untouched.
Fixed
- A flaky server-startup wait in the test suite. Fourteen HTTP and WebSocket test files each waited up to 2 seconds (
100 x 20ms) for their server to bind. A server binds in about 25ms on an idle machine, but libtest runs the tests inside one binary on parallel threads, and under that contention 2s was not always enough -head_works_for_static_filesfailed a release gate on it. (An earlier wording of this entry said cargo runs test binaries as parallel processes. It does not: cargo runs test targets one at a time, and only the threads within a binary overlap.) The budget is now 10 seconds, which only ever extends the failure path since a healthy server is detected on the first poll. The spawn helper also reports the server program's own error: a server that failed to start was previously indistinguishable from one that was merely slow, because the error was discarded. Test infrastructure only, no effect on the shipped binary. - Integer overflow in
math.gcd,math.lcm,std.humanizeandimage.crop. Each took a magnitude with.abs()or added two counts unchecked, and neither is safe at the edge of the range:i64::MIN.abs()has no representable answer, so an optimized build returnedi64::MINunchanged while a debug build panicked. The Manifesto promises checked overflow, and these wrapped.math.gcd(i64::MIN, 6)returned -2, a negative greatest common divisor from an ordinary-looking call. Magnitudes are now computed unsigned, so every answer that fits is correct and only a result that genuinely cannot be represented (gcd(i64::MIN, i64::MIN)is 2^63) is an error.humanize.durationandhumanize.sizereturned an empty string ati64::MIN, andhumanize.relativesubtracted its two timestamps unchecked, which flipped past and future apart; those take the magnitude unsigned and saturate the subtraction, since a coarse presentational answer should not fail.image.cropaddedx + wunchecked, sox = u32::MAX, w = 1wrapped to 0 and slipped past the bounds check it was meant to fail - the check is now checked, and an overflowing region is rejected. - A streamed
airesponse corrupted any character split across two reads. Each network chunk was decoded on its own withString::from_utf8_lossy, so a character whose bytes straddled a chunk boundary produced two U+FFFD replacement characters: one for the orphaned lead byte, one for the orphaned continuation. Chunk boundaries fall wherever the network puts them, so this was routine on any non-ASCII stream rather than an edge case -hélloarriving in two pieces becameh??llo. Streaming now decodes incrementally, holding a partial character until the next chunk completes it. Bytes that are genuinely invalid, rather than merely cut short, still become one replacement character and are skipped, so a malformed stream cannot stall behind them. http.servehad no deadline on the request body.ECKO_HTTP_READ_TIMEOUT_MScovered the headers and nothing after them, so the body was read in a loop that could wait forever. A client that finished its headers, announced aContent-Lengthand then fell silent held the connection and its buffer for as long as it liked - slowloris one phase later, and cheap to run in bulk. The same deadline now applies to each body frame, and a stalled request is answered408rather than closed silently. Per frame rather than a total budget on purpose: an idle client is dropped, while a slow but steady upload of any size still completes, because a whole-body budget would cut off a large legitimate upload over a thin link. A request that carries no body at all - every GET and HEAD - skips the wait rather than arming a timer for a frame that cannot arrive.
Changed
- A variant with no fields is now the value, not a constructor.
type Sentiment = Positive | NegativeboundPositiveto a function that built the variant, so the natural spelling was silently wrong:s == Positivecompared a struct against a native function and was always false,match s { Positive => .. }fell through to_, andcontains(xs, Positive)was false. None of it raised an error, andecko checksaid nothing either - a wrong answer with no diagnostic anywhere. OnlyPositive()gave a usable value, and the old docs said so, which made the footgun documented rather than absent.Positiveis the variant now, so all four of those read correctly.Positive()returns the same value, so the constructor spelling and everything written against it still work, includingtest.eq(classify(s), Positive())in the stdlib docs and every frozen program incompat/. A variant that declares fields is unchanged and is still called:Circle(3).
0.20.3
Security
- A package with no grants could run commands, read files and reach the network. The capability context is per thread, swapped like a call frame. A package function re-enters its own set whenever it is called, on any thread, and that shape was tested. A bare native handed across a thread (
pmap([cmd], proc.run),bg.spawn(proc.run, [cmd]), orcall: proc.runin an ai tool spec) has no set of its own and ran under whatever the worker thread held, which for a fresh thread is nothing, and nothing is root authority.pmap, the task dispatcher behindbg.spawnandasync fn, and the live tool round now capture the spawning call's context and enter it on the worker before running anything.CapsSnapshotinevaluator.rsis the one helper for that; any new thread crossing that calls user values goes through it. The std HTTP and WebSocket handler threads still start empty. A handler there receives a request map or a connection id, which no gated native accepts, so nothing has been found to run through them, but they are the next place to apply the same capture. - Three
std.procnatives were outside theexecgate.write,close_stdinandread_linewere missing from the module's gate list, and a native missing from that list is simply ungated. Process handles are small sequential ints, so an ungranted package could feed a child the root program had spawned, or read what it printed. All eight natives now needexec, as the documentation always said, and a test walks the module's real members and demandsexecfrom each so the list cannot drift again.
Fixed
- A count the allocator could not satisfy killed the process.
"a" * 999999999999,str.repeat, the pad family (pad_start,pad_end,center,zfill,fmt.pad_left,fmt.pad_right,fmt.repeat),random.bytes,random.tokenandnet.recv_exactallocated straight from the number they were given. Past what the machine can hand out that is not an error but an abort: "memory allocation of N bytes failed", exit 134, nothing fortryto catch, one line of ordinary code or one HTTP request taking a server down. They now share one bound,ECKO_MAX_ALLOC(default 256 MiB), and a count past it raises an error that names the limit.net.recv_exactis the one that matters most: a length-prefixed protocol hands it the peer's own number. chunk,join,str.joinand the generic list helpers materialized a range themselves, bypassing the 10,000,000-element cap thatlist(range)already had, with the same abort on a large range. Every range-to-list conversion now goes through the one capped path.- Printing a range past that cap.
print(1..100000000000)built the whole rendering in memory before writing a byte. A range past the materialization cap can never be the list it spans, so it now prints as its literal,1..100000000000; a range within the cap prints exactly like its list, as before. - A long chain overflowed the parser or the analyzer and killed the process.
ECKO_MAX_PARSE_DEPTHbounded the parser's own recursion, and every walker downstream trusted that bound. But a chain -a + b + c,x |> f |> g,m.a.b,if .. else if .. else if ..- nests one level per link, and the parser built the first three in a loop and the last by a recursion the counter never saw. Sixty thousandelse ifarms overflowed the parser; sixty thousand+ 1terms parsed and then overflowed the analyzer, on the main thread, before the program ran. Both were SIGABRT, exit 134, onecko checkand on every run. The bound is now on the tree's real height, whatever shape produced it: a chain is refused as it is parsed, so no tree deeper than the cap is ever built (dropping a sixty-thousand-deep one recursed once per level and overflowed on its own), the parser measures each finished top-level statement with an explicit stack as the exact backstop for chains inside brackets inside chains, andelse ifarms are parsed in a loop and folded from the right. All three give the usual "nests too deeply" error. A chain of more than 128 links is now a parse error; the longest in any shipped Ecko program is 54 terms, and a generated one past the cap can raise the variable. The playground probes now include the three chain shapes, since a trap there is a dead page. zlib.gunzipandzlib.inflatehad no output bound. Deflate amplifies about 1000:1, so a 1.4 MB input expanded to 1.5 GB and onward until the OOM killer stepped in. Both now stop atECKO_ARCHIVE_MAX_UNPACKED(default 1 GiB), the boundstd.archiveextraction already had, and raise an error that names it.
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, and another test 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 counter-partitioned range and verify each candidate binds. (This entry originally attributed the race to cargo running test binaries as parallel processes. That was wrong when written: cargo runs test targets one at a time, and the contention is between libtest threads inside a single binary. The pid term in the helper is therefore near-inert and the counter is what carries the fix - which is corrected, with its own aliasing bug, in 0.20.5.) No effect on the shipped binary - test infrastructure only. Released so the tag and the tree agree.
0.20.1
Changed
shadows-builtinno longer fires on an exported function. A package that exportsget,set,pushorfindis 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. Everyshadows-builtinfinding 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, alet, 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.stringis nowstd.str. A module binds the last segment of its path, sostd.stringbound the namestringand displaced thestring()converter for the rest of the file. Every conversion then failed withCan't call module, a runtime error a long way from the import that caused it.ecko checkwarned, and the documented fix wasimport 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 modulestringwas 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.rsnow fails the build if another appears. The converter was left alone deliberately:int,float,bool,decimalandstringare one family, and renaming one of them tostr()would leave four full words and an abbreviation with no principle behind the split. Migration:import std.string->import std.str, andstring.foo(x)->str.foo(x). An unmigrated import fails immediately and by name (No module called 'std.string'), so nothing fails silently. Two frozencompat/programs were updated for this and the freeze manifest regenerated. Their golden output is unchanged - only the import moved.
Added
ecko checkcatches a regex written as a plain string (ruleregex-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 returnsfalsewith no error at all. That silent wrong answer is now a warning naming the fix, ther"..."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 eightstd.refunctions 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::truncatenow moves both together. This affected every release that shipped the fusion peepholes, and any program containingx * 2,i < nor 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
Valueis 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 anArcto get there -Struct,NativeFunction,DecimalandRange- because a single payload wider than 8 bytes forces the whole enum to 24. 16 is the floor, not a waypoint:Intis a fulli64and 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 tightm-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/crosscompute 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_sumnow runs faster than CPython (441ms against 488ms). Nothing about the language changed: same results, same errors, same bytecode. BesidesValue'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 beingVm::pop, whoseunwrap_or(Value::Null)built a default eagerly and then called drop glue to discard it on every pop. And thethrow!macro was inlined at 107 sites in the dispatch loop, giving it a 12.5 KB stack frame that pushedipout of a register on every instruction, on the path where no error is raised at all.
0.18.0
Breaking
- The
anthropicprovider was removed. Anthropic models are reachable through the newopenrouterprovider, 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.5ECKO_AI_PROVIDER=anthropicnow 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
openrouterprovider: one key, hundreds of models across vendors, withvendor/modelids (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.shdid not deny warnings in test code, so it reported ALL GREEN while CI failed.cargo buildnever compiles tests, so a test that loses its#[test]attribute becomes unreachable dead code that only CI's globalRUSTFLAGS: -D warningscatches. 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- oranthropicafter 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 fortype Urgency = Low | Highasks 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 anymatchon 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
aicall whose response matched no variant returned a raw map instead of retrying. It now yieldsNull, so the retry loop re-prompts - the contractInt/Float/Bool/Bytesalready 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. AValue::Stringwas treated as a failed coercion for every target includingString, 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 forJson,Map,Listand record targets. ai[Bool]accepts the words models actually use -yes/no/y/n, any case, with a trailing period tolerated - and a bare JSON1/0, which was inconsistent with the"1"/"0"strings it already took.ai[T] ... on imgignored 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
--traceand to any spend audit built on it. List,OptionandMaptargets leaked a raw map instead of retrying. A weaker model answers a composite schema with the schema itself (gpt-3.5-turboreturns{"type":"array","items":{...}}forList<Int>), which passed straight through as a map the caller could not use and the retry gate never saw. They now yieldNullso the retry re-prompts.Option<T>andMap<K, V>never coerced through their parameters.ai[Option<Int>]answering"7"produced a String, andMap<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 ofanyOf. Offering null as a co-equal branch invited the model to take it: llama3.2 answerednullto "How many moons does Earth have?" 4 times out of 4 underanyOfand 0 out of 4 under the nullable form. gpt-3.5-turbo failed the same way. A composite inner type keepsanyOf, 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,answerandoutputare honoured, which tookai[Bool]on llama3.2 from 6 of 10 runs to 10 of 10. sessionfailed outright on some models. An empty tool list was sent as"tools": []rather than omitted, andgpt-3.5-turboanswers 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
--tracenow reports estimated cost per call, as$0.000003in the stderr line andcost_usdin 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,cliorpackage.--listshows what is available (a bareecko scaffolddoes the same),--nameoverrides the project name,--forceoverwrites, and--refpicks a branch or tag.- Templates live in the public
ecko-lang/templatesrepo rather than in the binary, so they can be fixed and added without an ecko release. The firstecko scaffolddownloads and caches them; every run after that is offline. This is the only command that needs the network to get started.ecko scaffold --updaterefetches 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
--listmarks it rather than hiding it. ECKO_TEMPLATES_REPOpoints at another repo, an archive URL, or a local directory;ECKO_TEMPLATES_DIRmoves 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 lspnow 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-functiondeliberately gets no fix, since deleting a function is not a safe blind repair. Formatting matchesecko fmtexactly and applies to the whole document; a selection cannot be reformatted, becausefmt::format_sourceparses 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.executeCommandProvideralso gainsecko.fix.migrate, givingecko fix --migrateits first editor surface.
Changed
ecko checkandecko lintnow 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 wholestdmodules (archive,proc,watch, …) and members added since the lists were first copied in. Completion aftersql.now includestransaction; afterarchive.,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, streamingai(-> stream), the channel primitives (channel/send/recv/close), andsleep- the browser build has no threads.retrystill works: a failed attempt just skips the wait before the next one instead of erroring, since the only thing lost is the delay itself.pmapstill runs, but sequentially rather than in parallel, which produces the same result sincepmap'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_stackruns inline rather than spawning a 256 MiB-stack thread (wasm32 has no threads),aicall timing uses a monotonicWasmInstantshim (Instant::now()panics on wasm32), andprint/print_no_newlinecapture 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, andhas(map, key). Writing JSON-shaped code meanttype_of(x) == "map"in every condition, and asking whether a map had a key meantcontains(keys(m), k), which builds the whole key list to answer one question.hasalso distinguishes a key holdingnullfrom a key that is absent, which the old spelling did too but did not look like it did.sliceworks on strings. It was list-only, so slicing text meant dropping tos[a..b]for no reason a reader could see. Character indices, matchinglenand 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_fitrenders 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 andecko fmt --checkwould fail on text the other build had produced. That happened in practice while writing themcppackage. 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 runningecko fmt --checkin CI should expect a diff on upgrade;ecko fmtresolves it.
0.12.0
Added
std.proccan drive a long-lived child.proc.write,proc.read_lineandproc.close_stdin. Children were spawned with stdin closed, sorun- 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)spawnopens stdin;rundeliberately does not, because a child reading a pipe nobody writes to would block forever.read_linereturnsnullfor 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 ... usingaccepts 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 theusinglist 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,descriptionandcallare required;paramsdefaults 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()andio.read_all()- Ecko can read standard input. It could not before:std.ioexported onlyprint, andstd.term'sread_keyis a raw-mode TUI primitive, socat data.txt | ecko script.eckowas impossible. Ecko could write a pipeline stage and not read one.
End of input isimport std.io loop { line = io.read_line() if is_null(line) { break } print(line) }null, not""- an empty line is a real line, so the null is what a loop terminates on. A trailing\ris stripped with the\nso a CRLF file compares equal to what you expect, and a final line with no terminator is still returned. Ungated, likestd.clireading 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 stillstd.fsand still needsfs:read. There is deliberately noio.lines()- it is expressible overread_line, and ecko-std is for what cannot be.
Changed
x = x OP ybetween two locals is one instruction. It compiled toGetLocal(y)+AddLocal(x): aValueclone, a 32-byte push, a 32-byte pop, and drop glue - to add twoi64s. The newBinLocalLocalStorereads both slots in place. This is the variable-variable counterpart of the existingBinLocalConstStore; 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 forValueis a branch over every variant. On a pure-integer loop that was 13% of runtime, spent on values that own nothing.IncLocaland the fused store now overwrite the payload. - A bytecode instruction is 16 bytes, down from 32.
Op's index fields wereusize; a chunk cannot hold more thanu32::MAXinstructions (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,MatchPatandCallNativeCachedeach carried three of them. The conversions panic rather than truncate: 157 sites usetry_into().unwrap(), and the single remainingascast widens au8. A silently truncated jump target would be a miscompile, which is not a thing to leave to a code review. Measured onbenchmark/crossagainst 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 theloop_sumresult; the narrower instruction is a broad 3-6% on everything that is dispatch-bound, and nothing on the workloads dominated by allocation. IPC onloop_sumrose from 3.28 to 4.31. Found by profiling rather than by reading the compiler:perfput 24% ofloop_sumindrop_glue::<Value>and 7% inValue::cloneon a loop that allocates nothing, and the instruction count (4.09G against PHP's 0.61G onprimes) 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.servecorrectly refuses to put internal error text in a 5xx response body - and then dropped it on the floor, logging onlyhandler 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:
The client still receives exactly the generic# before ecko http: 500 internal error: handler returned status 500 # after ecko http: 500 internal error: handler failed (500): Runtime error: connection refusedInternal Server Errorbody 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 andcatch (e)binds it - but only if the throw did not cross a native callback boundary. A throw from inside amap,filter,pmap,reduceorsort_bycallback was flattened to its message string, so the dispatch idiom the spec documents silently stopped working:
Natives have anfn boom(x) = error({ kind: "not_found", id: x }) try { map(ids, |id| boom(id)) } catch (e) { get(e, "kind") # was null; now "not_found" }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, andpmap, whose worker threads cannot see the main thread's slot at all. All three now carry it, andpmaphands 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 apmap, soe.capabilityread 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 installsummed each entry's declared uncompressed size againstECKO_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 buildbundles read their embedded payload the same way, and also no longer size an allocation from that field.
0.10.1
Fixed
ecko checkno longer fails on warnings. It exits non-zero on error-severity findings only - which is whatdocs/cli/check.mdhas always documented ("Warnings alone do not change the exit status"). The implementation treated every finding as fatal, so a warning gated any build usingecko checkas a CI step. This was not theoretical:shadows-builtinfires on a package that exportsget,setorpush- its own message calls the shadowing deliberate - and there is no way to silence one finding.cache,heap,htmlandxmlpassed 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 --strictrestores "any finding fails" for projects that want it. The pre-run gate onecko file.eckois unchanged: it has always refused to start only on error-severity findings.
Added
ecko fix --migraterewrites deprecated syntax. Deprecated forms already warned (deprecated-syntax) and were canonicalised byecko fmt; now there is a migrator that touches only what is deprecated, so the diff is the migration rather than a whole-file reflow:
Registered today: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/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=bytesand--only=exportsbelong 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 correctbase64_decode, which wants bytes, becomesbase64_decode_text, which does not. The older--migrate-bytesand--migrate-exportsspellings still work and now select their codemod from the same registry. A bareecko fix <file>is unchanged - it is still the AI error fixer.- A removal gate for deprecated syntax.
crates/ecko-core/src/edition.rscarriesACCEPT_DEPRECATED_SYNTAXandREMOVED_IN_MAJOR; each deprecated parse path is gated on the first, so dropping the old grammar at the flagged major is one line plus acompat/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:readandfs:writemay now name the subtree they are confined to, so a grant can say "may write its own log directory" instead of "may write anywhere":
A bare{ "dependencies": { "logger": { "path": "github.com/acme/logger", "version": "v1.2.0", "grant": ["fs:write:./logs"] } } }fs:writestill means anywhere, so nothing that worked before changes. A relative scope resolves against the directory holding theecko.jsonthat 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/datadoes 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,envandexectake no scope, andnet:example.comis 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/exportto drive a relay needed exactly the samefs:writeas rotating a log file. Seedocs/design/path-scoped-capabilities.md. - A capability denial caused by a scope now carries a
pathfield alongsidecapabilityandpackage, so it says which path was refused. sql.opennow checks a file database's path against the scope too;sql.open(":memory:")stays pure and ungated.std.watch- filesystem events.inotifyon Linux, FSEvents on macOS andReadDirectoryChangesWon Windows, behind one surface. Layer 2 by the syscall bar: the only way to do this over existing primitives is to pollfs.list_dirin a loop, which is slow, misses fast changes, and is the thing being avoided.
Gated onw = watch.open("./inbox", { recursive: true }) e = watch.next(w, 5000) # { kind, path }, or null once the deadline passes watch.close(w)fs:readrather 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 sidestepfs:readby watching instead of listing.w.eventsis a real channel, so a watch composes withrecv, a spawned task or an SSE response rather than imposing its own loop shape.watch.nexttakes 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. CatchSIGTERMandSIGINTso 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.
Layer 2 by the syscall bar - catching a signal iss = signal.on() # default set: ["term", "int"] name = signal.next(s, 5000) # "term", or null once the deadline passes signal.close(s)sigaction(2), and there is no way to approximate it over existing primitives. No new dependency:signal-hook-registrywas already in the tree via tokio. Gated onexec. 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 authorityos.exitneeds. Names are lowercase without theSIGprefix (int,term,hup,quit,usr1,usr2);signal.names()reports what the platform delivers. Windows has no POSIX signals, so onlyintexists there and the rest are refused by name rather than accepted and never fired.SIGKILLis 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 calledsignal.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,.tarand.tar.gz;std.zlibonly did raw gzip/deflate.
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 overarchive.zip_create("out.zip", ["src/", "README.md"]) archive.zip_extract("out.zip", "dest/") archive.tar_create("out.tar.gz", ["src/"]) # gzip follows the filenamestd.zlib. What is not worth reimplementing per package is path containment: an entry named../../../.ssh/authorized_keyswrites 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).zipwas already compiled in forecko packandecko build, so onlytaris new. Gatedfs:readfor the archive and its inputs,fs:writefor what is written.zip_create/tar_createtake 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.execruns to completion with no deadline, and killing a process kills only that process, so a timeout aroundsh -c "ffmpeg ..."kills the shell and leaves ffmpeg running. Measured on a dev box before the change: killing one shell orphaned foursleepprocesses.
A child is started in its own process group (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 childprocess_group(0)) and the deadline signals the group withSIGTERM, thenSIGKILLafter a short grace - a process ignoring TERM is the case a timeout exists for.timed_outcomes 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 onexec. Unix only for the tree guarantee - Windows has no process groups in this sense, sokillthere reaps the direct child and grandchildren survive.
Changed
Valueis 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.NativeFunctionwas 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 toArc<String>,u32and a thinArc<Box<dyn Fn>>brings the payload to 24 andValueto 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 elevenbench-gateworkloads improved and none regressed.countnow beats CPython (1.035x -> 0.947x) andlistmapwent 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 didstack.split_off(len - argc)to build aVec<Value>for the native signature, andsplit_offalways allocates. Measured on a loop callingabs(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 nowFn(&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, andstrjoinwent 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_sum0.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 ownedVec: 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 awhile 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_arithhad 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 twoInts. Profiling collatz put that check alone at 3.7%. Division by zero andi64::MIN / -1still reach the slow path that words their errors, becausechecked_divreturnsNonefor both. x = x OP kfor a constantkcompiles to one op. It wasBinLocalConstfollowed bySetLocal- computing into the stack and immediately popping back into the same slot.IncLocalalready 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 overConst; Popwould have been wrong: both arms of anifjump 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 tonull, 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::Stringheld a plainString, 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_mutcopies only when the buffer is shared, soacc = acc + xstill 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 theArcbox as well as the buffer, which shows on code that creates many short-lived strings and never clones them.dict_opsregressed 6.8% on that; routing map-key insertion through a move that unwraps theArcwhen 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::Stringremains for pattern matching. The perf baseline was deliberately rebaselined for this.scripts/bench-gate.shflaggedstrjoin(200kpushof"s" + string(i), thenjoin) going 1.256x -> 1.5x against CPython: 20% of that workload is in the allocator, and anArc<String>costs one allocation more to build than a bareString. 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::Concatdoes one allocation instead of three. It copied the left side withto_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..30000000andsum(0..30000000)peak at 9 MB, while onlylist(0..10000000)actually builds anything (466 MB). So a 30M-iterationforloop simply could not be written, and the workaround was hand-rolling awhilewith its own counter. The cap now lives where the allocation is, and says what to do instead:
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.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
Fixed
proc.runcould lose a fast child's output.try_waitreporting 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 fmtnow breaks three constructs it used to leave at any width. Sweeping every stdlib package, 44 lines over the 100-column budget survivedecko 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_fitjudged 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. Anelse ifchain 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, becauseexpr_brokendeclines and the flat form stands.- A lambda never broke.
expr_brokenhad noLambdaarm, sofn(v) if .. { .. } else { .. }stayed on one line however wide - 17 such lines instdlib/validate/validators.eckoalone. - A
forheader rendered its iterable flat. A list that breaks happily asxs = [..](22 chars) stayed at 118 insidefor x in [..]. No file in this repo changes shape:examples/still passesfmt --checkuntouched, the compat corpus is byte-identical, and all 30 stdlib packages still pass their own tests.
fs.copynever required read access to its source. It was gated onfs:writealone, so a package granted only write access could read any file the process could reach by copying it somewhere writable. It now requiresfs:readon the source as well asfs:writeon the target. A package that copies files and holds onlyfs:writewill needfs:readadded to its grant. Found while enumerating which arguments carry paths for path scoping.ecko fmtno 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,sliceandsort_withbuiltins.set(coll, key, value)replaces an element, whereinsertsplices a new one in and grows the list - reaching forinsertwhen you meantsetwas silently changing the length.drop(list, n)is the complement oftake, sotake(n) + drop(n)rebuilds the input, andslice(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,floorandceilaccept a decimal. They return anIntexactly, without routing through a float.roundtakes 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 reasondecimal(aFloat)is.fmt.fixed(value, places)renders a number to a set number of decimal places. A decimal is rounded in decimal, sofmt.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 withname :, so the brace is unambiguous. The|x| { ... }form always worked; onlyfn(...)did not.fn(x) ({ ... })is still valid.
Fixed
ecko checknow warns when a declaration shadows a builtin. Definingfn split(m, n)takes over everysplit(...)call in the file, including the ones that meant the builtin - which failed asCan't compare string and intfrom a line that looked unrelated. The warning fires only when the name is actually called, matching the existing rule for shadowing imports, and points atcore.splitas 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.servenow 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_WORKERSkeeps 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 duplicaterustls 0.20.9,ring 0.16.20andwebpki 0.22.4that 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-Encodingasks for it, gzip preferred, addingContent-EncodingandVary: Accept-Encoding. Already compressed content types are skipped, as is any response whose handler set its ownContent-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 fnno 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 anasync fnis 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 sharedcell, 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 -kno longer hangs against an Ecko server. An HTTP/1.0 request carryingConnection: Keep-Alivewas 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, soab -kcompleted zero requests even at concurrency 1. The header could not be sent at all under the old server, which filteredConnectionout of every response with no public API to bypass it.ab -k -n 3000 -c 10now 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_MSonly 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]. Atypedeclaration'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 forInt,FloatandBoolfields. Declaringage: Intand callingai[User]told the modelagewas 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 plainjson_to_value, ignoring the field's own declared type - unlike a bareai[Int], which parses a stringy"34"into34. A provider that ignored the schema hint and sent"34"for anage: Intfield left it a String, son = n + r.agestill concatenated. It now coerces each field the same wayai[Int]does; a field that cannot coerce becomesNull, same as a failed top-level typed call - this is coercion, not enforcement, and does not itself trigger the retry loop. Second: a field typedOption<T>fell through the schema/mock generator's catch-all to the same permissive schema used forjson, sotype U = U { age: Option<Int> }mockedageas a string. The schema now sends{"anyOf": [<schema for T>, {"type": "null"}]}, and mock mode produces a schema-validTvalue rather than null.Result<T, E>is deliberately left out of both changes: it is anOk/ErrADT, not a single value shape, and far less common as a field type thanOption<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
../docsrepo. Exempting the file also stopped checking its real in-repo paths, anddocs/reference/architecture.mdalone cites 26 of those. Only three span occurrences actually needed an exemption, all indocs-ia.md, so they are now listed by exact path. Checked spans go from 101 to 138, and a renamedcrates/ortests/file cited inarchitecture.mdorlang-spec.mdnow fails the gate instead of passing it.
Added
- The
std.stringtoolkit is frozen in the compat corpus (string_module, taking it from 22 to 23 programs).partition,rpartition,zfill,centerandswapcaseall shipped after the corpus was seeded, so the promise that old code keeps running did not cover them. The existingstringsprogram 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_boundedfreezes the bounded-channel surface added after the original seed -channel(n)backpressure, asendthat blocks while the buffer is full, and the non-blockingtry_recv- none of which any prior frozen program exercised.importsfreezes two things no prior frozen program pinned:std.io'sio.printwriting without a trailing newline (no other program importsstd.io), and a fulljson.encodethenjson.decoderound trip on the success path (errors.eckoonly callsjson.decodedown an error path).doc_commentsfreezes the##documentation-comment surface, previously uncovered by the corpus. Coverage only grows, percompat/README.md's own rule; nothing existing was edited. The corpus is also now hash-enforced: every file undercompat/programs/has a recorded hash incompat/SHA256SUMS, checked byscripts/compat-freeze.sh, so an unnoticed edit or an addition made without reblessing the manifest fails instead of passing silently.
Fixed
ecko fmtno 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_panicwalked the examples withread_dirand 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, withcorpus_is_collected_in_a_fixed_orderguarding 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 anasyncfn 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:
The one asymmetry: afield `age` of `User` expects Int, got stringFloatfield accepts anInt(widening is lossless, the same reason1 + 2.0is a float), but anIntfield rejects aFloat(narrowing loses data). A field left out of the call is checked the same waynullwould be, so a missing required field now throws instead of silently building the struct with that fieldnull- unless the field's declared type already acceptsnull(no annotation, orOption<T>).Result<T, E>fields stay unmatched, consistent with the schema gap above.ecko checkreports 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_pathis 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: giventype Server = Server { port: Int }andtype Config = Config { server: Server },c.server.port = "nope"reports:
A plain map has no declared field types and stays permissive -field `port` of `Server` expects Int, got stringm.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 aList<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. Seedocs/specs/2026-08-01-type-record-enforcement-design.mdfor the full design record. No frozen program incompat/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.eckofile type, ships the same TextMate grammar the VS Code extension uses, and wires upecko lspthrough 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 - everylet,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
aiclauses (using,with,on,-> stream),##documentation comments, orstd.defaults, and it highlighted a+=operator the language does not have. Module names inimport std.<name>position are now scoped as modules rather than being unhighlighted - or, forstd.string, mis-scoped as thestring()builtin. is_blankwas documented as a global builtin. It isstring.is_blank.ecko docdocumented a multi-file package as an empty page. A package that curates its public surface inmain.eckoand keeps the implementation in siblings got a title and nothing else, because extraction read one file andexport * from "./impl.ecko"is not a declaration. It now followsexport ... frominto those files with the same selectivity an importer gets:export *takes the whole exported surface,export { a }takesaand leaves its siblings private,asdocuments 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) andcache(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>.mdinstead 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 docsproduced a file calleddocs.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.mdheaded# maintold the reader nothing;ecko docnow readsmodule(orname) from anecko.jsonbeside 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-tlsmeans 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 newvendored-tlscargo feature statically links OpenSSL - without it the binary needslibssl.so.3at 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.shis thecurl -fsSL https://ecko.sh/install | shtarget: POSIX sh (Debian's/bin/shis 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 fromecko.sh/dl/latest/, so installing is a single request with no version lookup and nothing to rate-limit. Artifacts publish to Cloudflare R2, served fromecko.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 immutabledl/<tag>/; only full releases are copied todl/latest/, so a pre-release is installable on purpose and never by accident. Publishing is refused unless every planned platform built. A-testtag builds Linux x86-64 only, so the pipeline can be exercised cheaply;-rc.Nand 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: themacos-13runner 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 andecko doc. A doubled comment marker is documentation:## Fetch a user.above a declaration documents it, andecko 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. Anexample: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## xto# # 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.jsonloads automatically, andstd.defaultsexposes it. The manifest sitting next to the file being run is now read before the program starts: its optionalenvironmentobject is applied to the process environment, and every other top-level key becomes a member ofstd.defaults(defaults.version), keeping its JSON type. Every key must be a valid identifier - JSON allows"api-url", butdefaults.api-urlis a subtraction, so such a file is refused when it loads rather than supplying values nothing can name; the same rule coversenvironmentnames.environmentdeliberately 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 underenvironment; 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 asmodule.name, in both engines - the form that reaches a member whose name is only known at runtime, asstd.defaultskeys often are. os.arch()andos.family()complete host detection alongside the existingos.platform(): the CPU architecture (x86_64,aarch64, ...) and the OS family (unixorwindows, 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 - unlikeos.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 nestedinsert(empty_map(), ...)calls. Bare and quoted forms name the same key; a quoted key is taken verbatim (no{expr}interpolation in key position), andecko fmtrenders 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.stringgainsrsplit(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.stringpartition/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,partitionkeeps the string in the head andrpartitionin the tail (Python behavior); an empty separator is an error. (The globalpartition(list, pred)HOF is unchanged - the string form is module-namespaced.)std.stringcharacter-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'schar::is_*).is_upper/is_loweruse the cased-char refinement ("A1!"is upper;"123"is neither). The empty string is uniformlyfalse, including foris_ascii(a deliberate divergence from Python's"".isascii() == True).std.stringcasing + 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").@untrustedmarker (onletbindings and function parameters) plus theuntrusted-in-promptanalyzer warning: flags a tainted value interpolated into anaiprompt 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 localfn time()free),import "./helpers.ecko" as h. Mutually exclusive with the selective{ … }form (which already had per-nameas). Works on both engines, round-trips throughecko 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 gainunless/template; snippets forsql/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) = pairandmut (x, y) = pointbind new variables, and(a, b) = pairreassigns 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 fmtround-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 / unknownstd.*),unknown-member(a member the imported module doesn't export, with did-you-mean), andarity-mismatchon calls into an imported user module. Std-module arity is intentionally not checked (its recorded arity omits optional args - no false positives). Newanalysis::lint_at(source, path);ecko checkuses it. The LSP gains workspace symbols and cross-file go-to-definition (jump frommodule.memberto 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 newonclause attaches image input to anaicall -ai "describe" on img, oron [a, b]for several - where the image is astd.imagehandle (so aresize/croppipeline flows straight in). Composes with typed output (ai[Kind] "..." on img). Live, the image serializes into the provider message content (OpenAIimage_urldata-URLs, Anthropic base64imageblocks); 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. Combiningonwith tools / session / voting / streaming is a follow-up (a clear parse error for now). KernelImageResolverhook (mirrorsLiveLlm, installed by ecko-std).tests/multimodal.rs,examples/vision.ecko, LANG_SPEC (### Vision), design indocs/design/multimodal-ai-design.md. std.sqltransactions (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 manualsql.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 returnsfalseon a malformed hash. Native - a memory-hard KDF can't be done safely in pure Ecko. Crateargon2.tests/password.rs.std.webstatic files + middleware (card e729fe03):web.static(prefix, dir)serves files (content type by extension, traversal/symlink-escape rejected as 404), andweb.router(routes, [middleware])runs a chain of|req, next|functions - auth gates, CORS, logging, rate limits compose by callingnext(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.eckofile 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.sumonly 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, andECKO_ALLOW_HTTP=1opts 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]replacesecko add. Packages are fetched by source path, Go-style:ecko get github.com/ecko-sh/validate@v1.2.0lists the repo's git tags, picks the requested tag (or the highest semver tag when no@versionis given), vendors the package, and pins its file-tree hash inecko.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 - anecko add <name>index client andecko 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.sumfile) must pin every package it imports: importing - orecko installing - a package with noecko.sumentry is a hard error (runecko getto pin it), and a tamperedvendor/tree is caught at import, not just at install. A project with noecko.sumis unmanaged and skips verification, so hand-placedvendor/and quick scripts are unaffected. A project becomes sum-managed the firstecko getthat writes anecko.sum.ecko.sumis now the sole integrity source (the redundantecko.lockhash was dropped). constis deprecated (syntax consolidation). It was always identical tolet(both immutable - they compile the same), so it added a keyword without adding meaning.ecko checkwarns (deprecated-syntax) andecko fmtrewritesconsttolet; a future major removes the keyword.- The
|params| bodylambda form is deprecated (syntax consolidation).fn(params) bodyis canonical - it reads plainly as a function. The|...|pipe form still parses and runs, butecko checknow warns (deprecated-syntax) andecko fmtrewrites it tofn(...); a future major removes it. Named functions (fn name(params) = body) are unaffected. - BREAKING: modules are private by default;
exportis a per-definition modifier. Mark a definition public inline (export fn,export type,export x = …) instead of the separateexport { … }list (removed). A top-level definition is now private to its file unlessexported. 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", andexport 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 packageget); reach the shadowed builtin ascore.<name>, andecko checkwarns on the shadow. Migrate old code withecko fix --migrate-exports. - reqwest now uses native-tls (OpenSSL) instead of rustls/aws-lc-rs. Its default TLS backend pulled the heavy
aws-lc-sysC 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/inflateover bytes and strings, viaflate2(already in the tree throughzip, 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 withstd.httpcontent-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 theimagecrate - the groundwork for multimodalai(image inputs to vision models). Images are opaque integer handles (a process registry, likestd.net/std.sql- the kernel is untouched);image.encodereturnsbytes, pairing with the bytes type andstd.encodingfor a base64 round trip.load/saveare 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 buildbundles run fully in memory (card b314c692): the embedded payload is served straight to the import machinery (a virtual-filesystem overlay ininterpreter::vfsthat 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 ofpair[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 -sendblocks cooperatively while it's full (real backpressure for producer/consumer pipelines), is cancellation-aware likerecv, and wakes as receivers drain or the channel closes.try_recv(ch)is the non-blocking receive: the next value, ornullimmediately (the same null-on-close convention asrecv).channel()stays unbounded and unchanged.tests/bounded_channels.rs; lang-spec Channels section;examples/channels.ecko. bytestype + word bitwise operators (card b930fbd4, designdocs/design/bytes-bitwise-design.mdD1-D5):Value::Byteswithb"..."literals (\xNNescapes, 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(), strictstring(),string.from_utf8[_lossy],list()), binary-safecontains/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-digestsha256_bytes/hmac_sha256_bytes),net.send, andfs.writeaccept bytes (strings keep meaning their UTF-8 bytes); newfs.read_bytes.tests/bytes_std.rs.
Changed (BREAKING)
std.sqlBLOB columns returnbytes: a SQLiteBLOBvalue now maps toValue::Bytes(exact binary) instead of a lossyfrom_utf8_lossystring that corrupted non-UTF-8 data - the same silent-corruption class the bytes migration fixed elsewhere. Binding abytesparameter now stores aBLOB(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 instring().tests/sql.rs.- Decoders and
net.recvreturnbytes(stage 3, card 8ac94272; decided pre-1.0 so v1.0 ships without the silent-corruption behavior):encoding.base64_decode/hex_decodeandnet.recvnow 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 withecko fix --migrate-bytes [--check] <paths>(lexer-based: rewrites*_decode->*_decode_textandnet.recv->net.recv_text, leaving strings/comments/channelrecvuntouched). - New reserved words:
band,bor,bxor,shl,shr,bnotare now keywords (likeand/or/not) and can no longer be used as identifiers; they remain usable as field names after..
Fixed
ecko checkno longer rebuilds the builtin surface per file. The resolver constructed anEnvironmentand ran all ~95 native registrations (anArcand 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.eckofiles in the workspace.load_builtinsregisters 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 andupper("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, andecko checkreports it statically with the samearity-mismatchmessage 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, andcost'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: aweb.getroute now answers HEAD.web.getmatched 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 -Isends 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 thecontent-lengthGET would have sent - are the real ones. An explicitweb.head(path, handler)is available and takes precedence over the fallback;web.staticserves HEAD on the same terms.ecko checknow warns when a module import shadows a builtin.import std.stringbindsstring, displacing the builtinstring()conversion; the program then failed at runtime with a bareCan't call modulethat never mentioned the import, andchecksaid nothing. It now reportsshadows-builtinwith the fix in the message (import std.string as str, orcore.string). Only fires when the file actually calls the displaced name.ecko fmtno 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 fmtaccounts for theexportprefix. 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.trimcharset andsplitlimit are honored instead of silently ignored.trim(s, chars)/string.trim_start/string.trim_endnow strip any character in the given set (trim("xxhixx", "x")->"hi"), andsplit(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 outundefinedat the use site. It now binds the chosen names directly (with clean errors for names the module doesn't export), matchingimport std.x { … }. - Template directives in a plain string now error clearly.
{input …}and{for …}aretemplate-body directives; used in a plain interpolated string they used to silently misparse ({input x}read a variableinput, erroringundefined-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 singleerror_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 unparseableecko.json(e.g. a dependency entry missing the requiredsource/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 malformedecko.jsonand 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 devnow 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 devshuts 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.servebinds withSO_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 aftermap/filter/sql.transaction/any native callback now reattaches its payload instead of flattening to the message string -call_functionre-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 asreq.body_bytes(the oldfrom_utf8_lossycorrupted uploads); client responses carrybody_bytes.tests/http_bytes.rs. - Form + multipart parsing:
req.formholds url-encoded or multipart text fields;req.filesholds 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.tokenreturns url-safe base64.tests/random_secure.rs.- Streaming / SSE responses: return
{ status, headers, stream: ch }from anhttp.servehandler 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.webrouter (the recommended app shape) - uploads reach handlers asreq.files, bytes/stream responses pass back out unchanged.tests/web_router_p0.rs.
- Binary bodies both ways: served responses may be
- 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 sameDiagnosticrenderer 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 fmtwraps 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 inlineif/trybranches 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" }andmatch m { { from: f } => ... }now parse (previously only dot accessm.fromworked, per T-P.1, so such maps could only be built withinsert(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, anddns.lookup(host, type)for A/AAAA/MX/TXT/CNAME (MX as{priority, host}maps sorted by priority), viahickory-resolver(net feature,netcapability). Deterministic offline tier: the reserved.testTLD andlocalhost(plus their loopback / TEST-NET-1 /2001:db8::/32addresses) 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 programdns.- 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 everywherefetchis used (get/install/update).resolve_sourceinsrc/pkgcmd.rs. hash.sha1/hash.sha1_bytes(std.hash): SHA-1, for legacy wire protocols that need it (e.g. MySQL/MariaDBmysql_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.netraw-socket TLS + framing (card d6fa668e, DB-connectivity Phase 0):net.connect_tls(host, port, {verify})(TLS from the first byte) andnet.starttls(conn, {verify})(upgrade a live plaintext socket in place - the STARTTLS shape), plusnet.recv_exact(conn, n)(read exactly n bytes, errors on early close) andnet.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/reduceworkload took longer on 8 threads than on 1 (442ms -> 1018ms).executenow takes them from theVmfor the duration instead of cloning, andVmCallSessionholds 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:fold0.796x -> ~0.72x,listmap1.292x -> ~1.20x,count1.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-callstd::env::varreads (no measurable change). std.sqlno 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.sqlcaches prepared statements. Everysql.query/sql.execcall 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'ssqlite3on all three, and ahead of Node'snode:sqliteon aggregates.http.servereadsECKO_HTTP_MAX_BODYandECKO_HTTP_REQUEST_TIMEOUT_MSonce 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, andtakeeach built the entire list a range denotes before answering - 48 bytes per element, socount(1..=1000000, pred)allocated ~48 MB to return a single integer, andfirst(1..=2000000)allocated ~99 MB to returnstart.min/max/first/lastnow answer from the range's bounds in constant time,takereads only the prefix it returns, andcountstreams likesum/reducealready did. Measured:bench/report/count.ecko92.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_rangeproduced a materialized list where the VM produced aValue::Range, so contracted andasyncfunctions paid the full allocation. Constructing1..=10000000on 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, andreverseunwrapped to a bareVecand re-wrapped, freeing and reallocating the control block on every call; they now mutate throughArc::make_mut, which is in-place when uniquely owned (the linear-update path) and copy-on-write when shared.uniqueandflattentake ownership of their input instead of cloning it. Measured:bench/report/strjoin.ecko56.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 anecko.jsonis present).ecko build main.ecko -o appthen./appruns with nothing else installed; arguments become the program'sos.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 wiringstd.rag,@tool+using, typedai[Urgency],@requires/@ensures,session,retry, and a simulatedstd.webPOST /askhandler. Fully deterministic in mock mode (corpus + fmt gate). - Anonymous async lambdas (card 00cab2d1):
async |args| bodynow creates a first-class async closure with the same eager task, share-nothing snapshot, explicit-cell sharing, cancellation, and error semantics as namedasync fn.async fn(args) bodyis accepted as an alias and formatted to the canonical pipe form.mapreturns 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
asyncand 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 explicitcellsharing, matching the language's share-nothing task model.
Fixed
- Lexical AI tool resolution (card 2a8c70ad): VM
ai ... using [...]calls now resolve local@toolfunctions 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_sessionglobal, preventing hidden state leakage, user-binding corruption, and cross-call races. Live session calls now count againstECKO_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, channelrecv/selectand 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 reserveECKO_MAX_WS_CONNSslots 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.servegained ahost:option for local-only binding; WebSocket upgrades are capped (ECKO_MAX_WS_CONNS, default 1024) so they can't spawn unbounded threads; a genericServerheader 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 undercargo 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:@tooldescriptions used Rust Debug escaping (\u{b}),aiwith a bracket-leading prompt re-read as the typedai[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-featureskernel 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-featuresbuilds 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,aieven 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"withcapabilityandpackagefields). 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 likeLambda {(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)(aliasgroup) witheq/ok/err/failasserts that throw structured{ kind: "assert" }errors; failing asserts end their case, later cases still run.ecko testdiscovers 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, withformat/path),fs(file IO, withpath),net(HTTP/socket/ws, withurl/host),sql,closed(closed channels),budget(ECKO_AI_MAX_CALLS, withcalls/max), plus the existingcancelled. Programmer mistakes (wrong types/arity) stay prose-string panics;get(e, "kind")is now TOTAL (null on any non-hit, never an error), so onematch get(e, "kind")handles every caught value;Ok/Errare 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 forcontains(e, ...)dispatch - which is the point). - Resolver v2 + pre-run gate:
ecko checkgrew 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
Valueare 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 thex = 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), andterm.poll()(input ready, non-blocking). The escape-sequence parser is pure and unit-tested; interactive demo indemos/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" + secretis a type error pointing atreveal. 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 newos.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 boundnulland extras were silently dropped. fnbindings are immutable, likelet. Bare reassignment errors; explicitletredeclaration remains legal.- Builtins are shadowable, not assignable.
sum = 0declares 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) 99used to clobberlenfor 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 returnednulland 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 byecko check/ the LSP. - Float equality is exact. The epsilon-fuzzy
==(non-transitive) is replaced by IEEE equality; the newapprox(a, b, eps = 1e-9)builtin is the blessed tolerance comparison.
Nothing past v0.7 has a release tag yet (
Cargo.tomlis still0.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, internalrun_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-callArc<Mutex<Environment>>. Result: a naive VM (no specialization yet) runsfib(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 behindrun_via_vm, phase by phase, until the Phase 4 cutover.tests/vm.rs(10), including byte-parity with the tree-walker onfib. Reuses theValueenum andapply_binopunchanged.
std.rag - first-class retrieval-augmented generation
- New
std.ragmodule turns Ecko's existing AI primitives (embed,cosine, theaikeyword) 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), andrag.answer- RAG in one line: retrieve context, ground anaicall in it, return the answer. Fully deterministic and offline in mock mode (mock embeddings + mockai), 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 lspsubcommand: a Language Server Protocol server (built ontower-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 everyecko::analysis::lintfinding (dead code, unused imports/vars, unreachable code) as aWarning, 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 reusableanalysis::symbolspass; 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 lspas a language client (vscode-languageclient) instead of shipping a static, non-scope-aware completion provider. It finds the binary onPATHor via theecko.server.pathsetting, 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 newecko::diagnosticsmodule 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 futureecko lspsurfaces. The parser keeps itsStringerror contract - the CLI adapts messages into structured diagnostics at the boundary - so embedders and the one-lineeval_fileare unchanged; the neweval_file_prettydrives the rich CLI rendering.
ecko lint - static analysis linter
- New
ecko lint <file>command, built on the sameecko::analysismodule asecko 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 unconditionalreturn/break/continue, found by a complete block walk - a conditionalreturninside anifdoes 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-friendlyfile: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 newecko::analysismodule, reusable by a futureecko lint): the program's structure - top-level functions and their arities, imported modules, whether it usesai- 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.termfor building CLI tools). A CLI is a plain spec map;cli.parse(spec, argv)returns{ options, args, rest, command, help }andcli.help(spec)renders usage text. Supports long/short options (--name v,--name=v,-n v,-nv),flag: truebooleans, a value type inferred from each option'sdefault(Int/Float/String), the--options terminator, positional args +rest, subcommands (via acommandslist), 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_*,rgbtruecolor,color256, attributes, and a combinedstyle(...)), 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(), visiblewidth(), and OSC-8link()). 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 orNO_COLORis set, andCLICOLOR_FORCEforces them on - so the same program is colorful in a terminal and clean in a pipe. New dependency:terminal_size(forsize()).
Internal: spawn_task helper for governed background tasks
- The four permit-governed spawn sites (
std.bg'sspawn/after/everyand theasync fntask spawn) hand-rolled the same "acquire a task permit, run withcatch_unwind, release, complete the handle" boilerplate. Extracted a singlespawn_task(handle, body)helper (plus a sharedpanic_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 bytests/task_permit_release.rs.
Friendlier, on-brand user-facing text
- Reworded the CLI,
--help, and REPL in Ecko's voice (lowercaseecko, warmer banner/prompts,:help/:exitcolon 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,
needsinstead ofexpects,couldn'tinstead offailed 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?, andThis 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) returnsnullwhen absent; reductions that need an element (min/max) error on empty; identity-value operations (sum,filter,frequencies, ...) return their neutral value. Enforced bytests/empty_values.rs. Resolves code-review finding #15 - the flaggedmin/maxvsfirst/last"inconsistency" is two different kinds of operation, each internally consistent.
Performance: unique is O(n) for scalars
uniquewas O(n^2) (avalues_equalscan 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) wherevalues_equalsemantics matter. Behavior is unchanged. From code-review finding #19.
sort / sort_by are deterministic (behavior change)
sortandsort_bynow 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_modulemonolith inevaluator.rsinto onesrc/interpreter/stdlib/<module>.rsfile perstd.*module, each exposing aregister(module)thatimport_std_modulenow dispatches to. Behavior is unchanged (same 502 tests, corpus gate green);evaluator.rsshrank 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.stringfor 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, andfrom(convert any value). Note: importing bindsstring, shadowing the globalstring()constructor in scope - usestring.from(x)instead.
Money-safe decimals (P3.3)
- New first-class
decimaltype: an exact base-10 number (~28 significant digits, backed byrust_decimal) for money and any value where0.1 + 0.2must equal exactly0.3. Write a literal with a trailingm(19.99m,3m) or build one withdecimal(int|string). - Arithmetic is checked (overflow and divide-by-zero are runtime errors, like
int).decimalmixes withintand staysdecimal; mixing withfloatis a hard error, anddecimal(aFloat)is rejected - a float has already lost precision, so pass a string.+/-/*preserve scale (19.99m + 0.01mis20.00);//%normalize (decimal(10) / 4is2.5).fmtround-trips themsuffix;json_encodeemits a decimal as a plain JSON number.intwas 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, thennet.send(c, data)/net.recv(c, n)(one read up to n bytes, default 64 KiB;nullon 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://andwss://-ws.connect(url)returns a connection handle, thenws.send(c, text)/ws.recv(c)(blocks;nullon close) /ws.close(c). Connections are opaque integer handles in a registry (likestd.sql), each behind its own mutex. Cratetungstenite(native-tls). - Server-side:
http.serveaccepts anon_ws: |c| { ... }callback. An incoming WebSocket upgrade is handshaked (viatungstenite::derive_accept_key+ tiny_http's connection upgrade) and its handler runs on a dedicated thread, using the samews.send/ws.recv/ws.closeon the handle. Withouton_ws, an upgrade is refused with 501.
HTTPS/TLS for http.serve
http.serveacceptscert:andkey:(PEM file paths) to serve over TLS:http.serve(8443, handler, cert: "cert.pem", key: "key.pem"). Enablestiny_http'sssl-rustlsfeature (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. acceptstream: true, returning a response whosebodyis a stream of text chunks (for chunk in resp.body) drained in the background instead of a buffered string. Usingresp.bodyas 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, andfactorial(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.datatostd.csvand dropped the redundant_csvsuffix:csv.parse/csv.stringify/csv.read/csv.write, matching thestd.toml/std.yamlshape. Breaking:import std.datano longer resolves. - Moved
data.load_jsonintostd.jsonasjson.read(path), and addedjson.write(path, value)for symmetry withencode/decodeand the config modules. - Moved the global
uuid()into astd.uuidmodule withuuid.v4()(random) anduuid.v7()(time-ordered, a better database key). Breaking: the bareuuid()global is gone;import std.uuidand calluuid.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(notstd.*, not"./path") resolves to./vendor/<name>/via itsecko.jsonentrypoint, binding exports under<name>.ecko initscaffolds a manifest; validation enforces name↔directory match, a relative in-package entrypoint, and reservedstd. ecko add <url|path>: fetches a package zip (http or local), verifies and unpacks it tovendor/<name>/, pins its sha256 inecko.lock, and records the dependency inecko.json.ecko install: rebuildsvendor/from the lockfile, re-verifying each pinned hash (a mismatch is a hard error).ecko remove <name>: deletes the vendored package and prunes it fromecko.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 (cratezip, deflate-only). Capability sandboxing (thecapabilitiesmanifest 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/writefor each, routed through serde_json with sorted keys for deterministic output. Cratestoml,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 anasync fnspawns its body on a task thread and returns aValue::Task;awaitjoins it (identity on a non-task). Errors propagate atawaitas first-class values. - Channels:
channel/send/recv/close(unbounded MPMC) withfor v in chdraining 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 onawaitreleases 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 ssends prior turns as a role-separated message array, not a flattened transcript. cost/tokenstruthfulness:cost(in, out, in_per_1m, out_per_1m)prices any model absent from the built-in table;tokensis 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, thenservereturns 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: osenv/env_or/set_env/args/exit/exec(→{code, stdout, stderr}); fsexists/is_dir/is_file/list_dir/mkdir/remove/read/write/join.std.sql: embedded SQLite -open/exec/query/query_one/close,?parameter binding (craterusqlite, bundled).std.webrouter:web.router([...])overhttp.serve-get/post/put/delete/patch,:paramextraction, 404 fallthrough.
Tier 2 (productivity):
std.random/uuid/std.hash/std.encoding:uuid(); randomseed/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/errorwith sorted field maps;ECKO_LOGverbosity.
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 toT. A round's tool calls run concurrently;ECKO_AI_MAX_TOOL_ROUNDS(default 8) bounds it. - Embeddings:
embed/embed_all/cosineglobals; deterministic mock vectors offline. - Token counting & cost:
tokens(cl100k_base) andcost(per-model table). - Retry & memory:
retry(n, f)with exponential backoff (ECKO_RETRY_BASE_MS);session()+ai "..." with sfor 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::Mutexfor the interpreter's locks: perf-neutral (std::Mutexis 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 movesxout 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-elementpushloop went 8.77s → 0.01s (~900x). Value semantics are preserved exactly - the move fires only when the variable is mentioned once, sox = x + xand 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 acrosspmap/http.serveworkers, 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
aicalls viaECKO_AI_CACHE=<dir>or--cache. Hits replay through normal coercion, are budget-free, and trace ascached: true. Collision-safe (entries verify the full prompt). - Persistent
pyworker: 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;printgoes to stderr. Crash → respawn. sleep(seconds)builtin.- (in progress: interpreter speed pass)
- Error-provenance and streaming hardening (external review): failed streaming
aicalls 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 feedaidirectly. - 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>,--checkfor 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:
templatekeyword, 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_htmlbuiltin.- Raw strings:
r"..."/r"""..."""- no interpolation, no escapes. - Vector store:
std.db-add/search(query, limit: n)/remove/count/clear/save/load; provider embeddings viaECKO_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 onaicalls.- 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:colspans and a call stack. - AI-verified contracts: natural-language
@requires/@ensureschecked by the LLM; self-correcting retry with failure feedback forai-bodied functions (ECKO_AI_MAX_RETRIES). - Tracing: every
aicall logged with call site, provider/model, latency, retries, token usage, and prompt content hash (--trace,ECKO_TRACEfor stderr one-liners or JSONL files); process-wide acrosspmapworkers 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-circuitand/or, enforcedlet/const/mut, field/index assignment, ranges,|x|lambdas, statement termination rules, UTF-8 lexer with spans,line:colparse errors, ~60 builtins,Result/Optionbuilt in, full-language integration suite, CI. - User modules:
import "./file"withexport { ... }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
pmapbatching (ECKO_MAX_PARALLEL),-> stream.
v0.3 - "Data & Verification"
- Boolean
@requires/@ensurescontracts;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,
aikeyword with typed output, provider CLI flags,ecko fix.