myxo-lang 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CONCURRENCY.md +207 -0
  2. package/HOW_IT_WORKS.md +235 -0
  3. package/INDEPENDENCE.md +24 -0
  4. package/MYXO_PROMPT.md +139 -0
  5. package/README.md +494 -0
  6. package/ROADMAP.md +200 -0
  7. package/SPEC.md +181 -0
  8. package/VISION.md +249 -0
  9. package/builtins.js +361 -0
  10. package/command-fence.js +75 -0
  11. package/errors.js +45 -0
  12. package/examples/agent.myx +31 -0
  13. package/examples/fenced-agent.js +78 -0
  14. package/examples/fib.myx +11 -0
  15. package/examples/fibers.myx +57 -0
  16. package/examples/flow-routing.myx +12 -0
  17. package/examples/geo.myx +12 -0
  18. package/examples/hello.myx +2 -0
  19. package/examples/host.js +30 -0
  20. package/examples/james-myxo-demo.js +60 -0
  21. package/examples/living-mesh.myx +22 -0
  22. package/examples/match.myx +18 -0
  23. package/examples/mathlib.js +8 -0
  24. package/examples/mathlib.pl +11 -0
  25. package/examples/mathlib.py +23 -0
  26. package/examples/mcp-host.js +37 -0
  27. package/examples/nexus-mesh.myx +22 -0
  28. package/examples/nexus.myx +29 -0
  29. package/examples/ouroboros.myx +2 -0
  30. package/examples/outward-gate.myx +23 -0
  31. package/examples/physarum.myx +75 -0
  32. package/examples/polyglot-host.js +17 -0
  33. package/examples/polyglot.myx +11 -0
  34. package/examples/resilient.myx +28 -0
  35. package/examples/scheduler.myx +46 -0
  36. package/examples/the-law.myx +27 -0
  37. package/examples/use-geo.myx +9 -0
  38. package/format.js +206 -0
  39. package/interpreter.js +1092 -0
  40. package/lexer.js +173 -0
  41. package/mcp-bridge.js +77 -0
  42. package/mcp-framing.js +34 -0
  43. package/mcp-server.js +57 -0
  44. package/myxo-concurrent.js +110 -0
  45. package/myxo-live-worker.js +30 -0
  46. package/myxo-live.js +83 -0
  47. package/myxo-lsp.js +226 -0
  48. package/myxo-par-worker.js +39 -0
  49. package/myxo-plan.js +207 -0
  50. package/myxo-run.js +63 -0
  51. package/myxo.js +296 -0
  52. package/package.json +27 -0
  53. package/parser.js +662 -0
  54. package/polyglot-host.js +39 -0
  55. package/polyglot.js +191 -0
  56. package/receipt.js +71 -0
  57. package/std.myx +88 -0
  58. package/tools/memo-fuzz.js +224 -0
package/interpreter.js ADDED
@@ -0,0 +1,1092 @@
1
+ 'use strict';
2
+ // interpreter.js — walks the AST and makes it happen.
3
+ // Holds the Environment (where the Nexus law lives) and the host bridge.
4
+
5
+ const { MyxoError, NxAssertError } = require('./errors');
6
+
7
+ // The single void value. Anything missing or unreported is this.
8
+ const VOID = Symbol('void');
9
+
10
+ // `report` unwinds an agent call by throwing this.
11
+ class ReportSignal { constructor(value) { this.value = value; } }
12
+
13
+ // Structural equality for `expect ... is ...` — numbers/strings/bools/void by value,
14
+ // lists element-wise, meshes by key/value; agents/natives by identity.
15
+ function deepEqual(a, b) {
16
+ if (a === b) return true;
17
+ if (Array.isArray(a) && Array.isArray(b))
18
+ return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
19
+ if (a instanceof Map && b instanceof Map) {
20
+ if (a.size !== b.size) return false;
21
+ for (const [k, v] of a) if (!b.has(k) || !deepEqual(v, b.get(k))) return false;
22
+ return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ // Type-tagged value renderer for assertion messages, so `1` vs `"1"` and `void`/`live`
28
+ // are distinguishable — a test runner's diagnostics must never read like a bug.
29
+ function showv(v) {
30
+ if (typeof v === 'string') return JSON.stringify(v);
31
+ if (v === VOID) return 'void';
32
+ if (v === true) return 'live';
33
+ if (v === false) return 'dead';
34
+ return stringify(v);
35
+ }
36
+
37
+ // Gradual types: does a value satisfy a type annotation? `any` always does. Enforced only in strict mode.
38
+ function typeMatches(type, v) {
39
+ switch (type) {
40
+ case 'any': return true;
41
+ case 'number': return typeof v === 'number';
42
+ case 'string': return typeof v === 'string';
43
+ case 'bool': return typeof v === 'boolean';
44
+ case 'list': return Array.isArray(v);
45
+ case 'mesh': return v instanceof Map;
46
+ case 'agent': return !!(v && (v.__agent || v.__native));
47
+ case 'void': return v === VOID;
48
+ default: return true;
49
+ }
50
+ }
51
+
52
+ // A pathway is a named binding carrying a strength. Reading it reinforces it
53
+ // (strength++). Decaying removes it. `system` marks builtins/std so they don't
54
+ // clutter the mesh view. Scopes chain through `parent` for lexical lookup.
55
+ class Environment {
56
+ constructor(parent = null) { this.vars = new Map(); this.parent = parent; }
57
+
58
+ define(name, value, system = false, type = null) { this.vars.set(name, { value, strength: 1, system, type }); }
59
+
60
+ lookup(name) { // resolve WITHOUT reinforcing -> { entry, env } or null
61
+ for (let env = this; env; env = env.parent) {
62
+ const e = env.vars.get(name);
63
+ if (e) return { entry: e, env };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ get(name, line) {
69
+ const f = this.lookup(name);
70
+ if (!f) throw new MyxoError(`unknown pathway '${name}'`, line);
71
+ f.entry.strength++; // useful pathways reinforce
72
+ return f.entry.value;
73
+ }
74
+
75
+ set(name, value, line) {
76
+ for (let env = this; env; env = env.parent) {
77
+ if (env.vars.has(name)) { env.vars.get(name).value = value; return; }
78
+ }
79
+ throw new MyxoError(`cannot assign to unknown pathway '${name}' — seed it first`, line);
80
+ }
81
+
82
+ remove(name, line) {
83
+ for (let env = this; env; env = env.parent) {
84
+ if (env.vars.has(name)) { env.vars.delete(name); return; }
85
+ }
86
+ throw new MyxoError(`cannot decay unknown pathway '${name}'`, line);
87
+ }
88
+ }
89
+
90
+ // ---- value helpers --------------------------------------------------------
91
+
92
+ function typeName(v) {
93
+ if (v === VOID) return 'void';
94
+ if (typeof v === 'number') return 'number';
95
+ if (typeof v === 'string') return 'string';
96
+ if (typeof v === 'boolean') return 'bool';
97
+ if (Array.isArray(v)) return 'list';
98
+ if (v instanceof Map) return 'mesh';
99
+ if (v && v.__agent) return 'agent';
100
+ if (v && v.__native) return 'agent';
101
+ if (v && v.__channel) return 'channel';
102
+ if (v && v.__fiber) return 'fiber';
103
+ return 'unknown';
104
+ }
105
+
106
+ // a value safe to use as a memo key / cached result (immutable, compares by value)
107
+ function isPrimitive(v) { return v === VOID || typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean'; }
108
+
109
+ // builtins that read live mutable state or are nondeterministic/side-effecting -> a caller of them is NOT pure
110
+ const IMPURE_BUILTINS = new Set(['random', 'prune', 'metabolize', 'mesh', 'strength', 'schedule', 'flows', 'channel', 'drain', 'await', 'route']);
111
+
112
+ // a type-tagged, collision-free memo key (VOID / NaN / Infinity stay distinct — JSON.stringify collapses them to null).
113
+ // Each arg is tagged to an injective string, then LENGTH-PREFIXED (<len>:<part>) rather than separator-joined.
114
+ // A bare join is UNSOUND: a string arg that itself contains the separator byte can forge a boundary —
115
+ // ["a\x1fs:b","c"] and ["a","b\x1fs:c"] both join to the same key. Length framing makes the sequence injective:
116
+ // the decoder reads <digits> ':' then exactly that many code units, so no content can fake a boundary.
117
+ function keyOf(args) {
118
+ let key = '';
119
+ for (const a of args) {
120
+ let part;
121
+ if (a === VOID) part = 'v';
122
+ else {
123
+ const t = typeof a;
124
+ if (t === 'number') part = Number.isNaN(a) ? 'n:NaN' : a === Infinity ? 'n:Inf' : a === -Infinity ? 'n:-Inf' : Object.is(a, -0) ? 'n:-0' : 'n:' + a;
125
+ else if (t === 'boolean') part = 'b:' + a;
126
+ else part = 's:' + a;
127
+ }
128
+ key += part.length + ':' + part;
129
+ }
130
+ return key;
131
+ }
132
+
133
+ function truthy(v) {
134
+ if (v === VOID) return false;
135
+ if (typeof v === 'boolean') return v;
136
+ if (typeof v === 'number') return v !== 0;
137
+ if (typeof v === 'string') return v.length > 0;
138
+ if (Array.isArray(v)) return v.length > 0;
139
+ if (v instanceof Map) return v.size > 0;
140
+ return true; // agents/natives are always live
141
+ }
142
+
143
+ // Render a value as text. `quoted` adds quotes to strings inside collections.
144
+ function stringify(v, quoted = false) {
145
+ if (v === VOID) return 'void';
146
+ if (typeof v === 'boolean') return v ? 'live' : 'dead';
147
+ if (typeof v === 'number') return String(v);
148
+ if (typeof v === 'string') return quoted ? `"${v}"` : v;
149
+ if (Array.isArray(v)) return '[' + v.map(e => stringify(e, true)).join(', ') + ']';
150
+ if (v instanceof Map) {
151
+ return '{' + [...v.entries()].map(([k, val]) => `${k}: ${stringify(val, true)}`).join(', ') + '}';
152
+ }
153
+ if (v && v.__agent) return `<agent ${v.name || 'anon'}>`;
154
+ if (v && v.__native) return `<native ${v.name}>`;
155
+ if (v && v.__channel) return `<channel${v.cap === Infinity ? '' : ' cap ' + v.cap}>`;
156
+ if (v && v.__fiber) return `<fiber ${v.name || 'anon'}>`;
157
+ return String(v);
158
+ }
159
+
160
+ // ---- the interpreter ------------------------------------------------------
161
+
162
+ class Interpreter {
163
+ constructor(opts = {}) {
164
+ this.globals = new Environment();
165
+ this.output = opts.output || (s => process.stdout.write(s));
166
+ this.baseDir = opts.dir || '.';
167
+ this.moduleLoader = null; // host grants this; without it, `weave` is fenced
168
+ this.moduleCache = new Map();
169
+ this.moduleInProgress = new Set();
170
+ this.exportStack = [];
171
+ this.dirStack = [];
172
+ this.manifest = null; // null = no `needs` declared
173
+ this.requireManifest = !!opts.requireManifest; // production hosts can require `needs` before any capability call
174
+ this.audit = []; // a ledger of every host-capability call
175
+ this.callStack = []; // live agent frames, for honest stack traces
176
+ this.maxDepth = opts.maxDepth || 500; // runaway-recursion backstop (well under the JS stack)
177
+ this.maxSteps = Number.isFinite(opts.maxSteps) && opts.maxSteps > 0 ? opts.maxSteps : Infinity; // fuel for runaway loops
178
+ this.steps = 0;
179
+ this.capSpent = new Map(); // cumulative numeric arg-0 per VALUE-metered capability
180
+ this.capCalls = new Map(); // cumulative CALL COUNT per capability (the default metering)
181
+ this.valueCaps = new Set(opts.valueCaps || []); // caps the HOST meters by value (e.g. spend); everything else = count.
182
+ // The metering kind is host-fixed, NOT chooseable by the (untrusted) script's manifest.
183
+ this.frames = []; // per-agent-call purity frames — the living mesh tracks taint here
184
+ this.memo = new Map(); // agent -> { calls, impure, hits, cache } — hot-promotion (memoization)
185
+ this.promoteAt = opts.promoteAt || 2; // calls before a provably-pure agent's pathway promotes (memoizes)
186
+ this.epoch = 0; // bumped on ANY global write — invalidates memo caches that could depend on it
187
+ this.routes = new Map(); // name -> { providers, cond[], credit[] } — automatic flow-routing (the slime mold)
188
+ this.pools = new Map(); // name -> { workers, cond[] } — the Physarum scheduler (flow-routing lifted to parallel batches)
189
+ this.fibers = []; // all spawned fibers (for drain/deadlock detection) — cooperative concurrency
190
+ this.ready = []; // the cooperative scheduler's run queue: { fiber, val? }
191
+ this.strict = !!opts.strict; // `--strict` enforces gradual-type annotations as runtime contracts
192
+ this.testMode = !!opts.testMode; // `myxo test` flips this on; otherwise `test` blocks are inert
193
+ this.testResults = []; // collected { name, ok, error } from test blocks
194
+ this.inTest = null; // the active test record while a test body runs
195
+ }
196
+
197
+ // Install a native (host) function callable from Myxo. The bridge to the Nexus.
198
+ registerNative(name, fn, capability = false, meter = 'count') {
199
+ if (meter === 'value') this.valueCaps.add(name); // host declares this cap is metered by value, not call-count
200
+ this.globals.define(name, { __native: true, name, fn, capability }, true);
201
+ }
202
+
203
+ run(ast) {
204
+ this.dirStack.push(this.baseDir);
205
+ this.exportStack.push(new Set());
206
+ try {
207
+ let result = VOID;
208
+ try {
209
+ for (const stmt of ast.body) this.exec(stmt, this.globals);
210
+ } catch (e) {
211
+ if (e instanceof ReportSignal) result = e.value; // top-level report ends the body...
212
+ else throw e;
213
+ }
214
+ // ...but spawned fibers ALWAYS run to completion at program end, on EITHER exit path, and their held errors
215
+ // and deadlocks ALWAYS surface — a background fiber's failure must never vanish silently. (`if fibers.length`
216
+ // not `if some-not-done`: an errored fiber is marked done, so checking !done would let its error fall through.)
217
+ if (this.fibers.length) { this.pump(); this.checkAllFibersDone(); }
218
+ return result;
219
+ } catch (e) {
220
+ // never let a raw JS stack overflow reach the user — make it Myxo's own error.
221
+ if (e instanceof RangeError && /call stack/i.test(e.message)) {
222
+ throw new MyxoError('call stack went too deep — runaway recursion?');
223
+ }
224
+ throw e;
225
+ } finally {
226
+ this.dirStack.pop();
227
+ this.exportStack.pop();
228
+ }
229
+ }
230
+
231
+ execBlock(stmts, env) { for (const s of stmts) this.exec(s, env); }
232
+
233
+ step(line) {
234
+ if (this.maxSteps === Infinity) return;
235
+ this.steps++;
236
+ if (this.steps > this.maxSteps) {
237
+ throw new MyxoError(`execution fuel exhausted after ${this.maxSteps} steps`, line);
238
+ }
239
+ }
240
+
241
+ // ---- statements ---------------------------------------------------------
242
+
243
+ exec(node, env) {
244
+ this.step(node.line);
245
+ switch (node.type) {
246
+ case 'Seed': {
247
+ const sv = this.eval(node.value, env);
248
+ if (this.strict && node.declType && !typeMatches(node.declType, sv))
249
+ throw new MyxoError(`'${node.name}' expects ${node.declType}, got ${typeName(sv)}`, node.line);
250
+ if (env === this.globals && env.vars.has(node.name)) this.epoch++; // redefining a global -> invalidate dependent caches
251
+ env.define(node.name, sv, false, node.declType); // carry the type so a later reassignment is also contract-checked
252
+ return;
253
+ }
254
+ case 'SeedDestructure': {
255
+ const value = this.eval(node.value, env);
256
+ const binds = this.matchPattern(node.pattern, value, env);
257
+ if (!binds) throw new MyxoError('destructuring pattern did not match the value', node.line);
258
+ if (env === this.globals) this.epoch++; // global binding(s) changed -> invalidate dependent caches
259
+ for (const k of Object.keys(binds)) env.define(k, binds[k]);
260
+ return;
261
+ }
262
+ case 'Assign': return this.execAssign(node, env);
263
+ case 'Decay': return this.execDecay(node, env);
264
+ case 'Emit': {
265
+ const text = node.args.map(a => stringify(this.eval(a, env))).join(' ');
266
+ if (this.frames.length) this.markImpure(); // output is an observable side effect -> never memoize it away
267
+ this.output(text + '\n');
268
+ return;
269
+ }
270
+ case 'When': {
271
+ if (truthy(this.eval(node.cond, env))) this.execBlock(node.thenBlock, new Environment(env));
272
+ else if (node.elseBlock) this.execBlock(node.elseBlock, new Environment(env));
273
+ return;
274
+ }
275
+ case 'Reinforce':
276
+ while (truthy(this.eval(node.cond, env))) this.execBlock(node.body, new Environment(env));
277
+ return;
278
+ case 'ReinforceTimes': {
279
+ const n = this.eval(node.count, env);
280
+ if (typeof n !== 'number') throw new MyxoError(`reinforce ... times needs a number, got ${typeName(n)}`, node.line);
281
+ for (let k = 0; k < n; k++) this.execBlock(node.body, new Environment(env));
282
+ return;
283
+ }
284
+ case 'ForEach': return this.execForEach(node, env);
285
+ case 'Give': case 'Take': case 'Yield': {
286
+ // Suspension points only mean something while a fiber is being stepped. Reaching the synchronous path means
287
+ // either we're outside a fiber entirely, or we're inside one but on a non-suspendable sub-path (a CALLED
288
+ // agent's body / an expression) — the scheduler can't suspend across that boundary. Be honest about which.
289
+ const kw = node.type.toLowerCase();
290
+ throw new MyxoError(this.steppingFiber
291
+ ? `'${kw}' can't be used inside a called agent — only in the fiber's own body (and inside when/match/for each/reinforce/attempt)`
292
+ : `'${kw}' is only valid inside a spawned fiber`, node.line);
293
+ }
294
+ case 'Agent':
295
+ if (env === this.globals && env.vars.has(node.name)) this.epoch++; // redefining a global agent -> invalidate (stale free-callee reads)
296
+ env.define(node.name, { __agent: true, name: node.name, params: node.params, body: node.body, closure: env, returnType: node.returnType });
297
+ return;
298
+ case 'Report':
299
+ throw new ReportSignal(this.eval(node.value, env));
300
+ case 'Attempt': {
301
+ try { this.execBlock(node.tryBlock, new Environment(env)); }
302
+ catch (e) {
303
+ if (e instanceof ReportSignal) throw e; // `report` still unwinds
304
+ if (!(e instanceof MyxoError)) throw e; // real JS bugs propagate
305
+ const m = new Map();
306
+ m.set('message', e.message);
307
+ m.set('line', typeof e.line === 'number' ? e.line : VOID);
308
+ m.set('value', e.nxValue !== undefined ? e.nxValue : VOID);
309
+ const child = new Environment(env);
310
+ child.define(node.errName, m);
311
+ this.execBlock(node.catchBlock, child);
312
+ }
313
+ return;
314
+ }
315
+ case 'Fail': {
316
+ const v = this.eval(node.value, env);
317
+ const err = new MyxoError(typeof v === 'string' ? v : stringify(v), node.line);
318
+ err.nxFail = true;
319
+ err.nxValue = v;
320
+ throw err;
321
+ }
322
+ case 'Weave': {
323
+ if (this.frames.length) this.markImpure(); // module load can have side effects -> taint the calling agent
324
+ const p = this.eval(node.path, env);
325
+ if (typeof p !== 'string') throw new MyxoError(`weave needs a string path, got ${typeName(p)}`, node.line);
326
+ const exports = this.weaveModule(p, node.line);
327
+ if (node.alias) env.define(node.alias, new Map(exports)); // namespaced
328
+ else for (const [k, v] of exports) env.define(k, v); // flat
329
+ return;
330
+ }
331
+ case 'Expose': {
332
+ const top = this.exportStack[this.exportStack.length - 1];
333
+ if (top) top.add(node.name);
334
+ return;
335
+ }
336
+ case 'Needs': {
337
+ if (!this.manifest) this.manifest = new Map(); // name -> limit | null
338
+ for (const it of node.items) this.manifest.set(it.name, it.limit || null);
339
+ return;
340
+ }
341
+ case 'Match': {
342
+ const subject = this.eval(node.subject, env);
343
+ for (const arm of node.arms) {
344
+ const binds = this.matchPattern(arm.pattern, subject, env);
345
+ if (binds) {
346
+ const child = new Environment(env);
347
+ for (const k of Object.keys(binds)) child.define(k, binds[k]);
348
+ this.execBlock(arm.body, child);
349
+ return;
350
+ }
351
+ }
352
+ return; // no arm matched -> nothing runs (use `_` for a catch-all)
353
+ }
354
+ case 'Test': {
355
+ if (!this.testMode) return; // test blocks are inert unless run via `myxo test`
356
+ const rec = { name: node.name, ok: true, error: null, asserts: 0, line: node.line };
357
+ const prev = this.inTest;
358
+ this.inTest = rec;
359
+ try {
360
+ this.execBlock(node.body, new Environment(env));
361
+ if (rec.asserts === 0) { rec.ok = false; rec.error = 'no expectations ran (empty test)'; }
362
+ } catch (e) {
363
+ if (e instanceof ReportSignal) { rec.ok = false; rec.error = "a test body cannot 'report' — it ends the test before its assertions"; }
364
+ else if (e instanceof NxAssertError) { rec.ok = false; rec.error = e.message; }
365
+ else if (e instanceof MyxoError) { rec.ok = false; rec.error = 'errored: ' + e.message; }
366
+ else throw e; // a real JS bug aborts the run
367
+ } finally {
368
+ this.inTest = prev;
369
+ }
370
+ this.testResults.push(rec);
371
+ return;
372
+ }
373
+ case 'Expect': {
374
+ if (!this.inTest) throw new MyxoError("'expect' is only valid inside a test block", node.line);
375
+ this.inTest.asserts++;
376
+ const m = node.matcher;
377
+ if (m.kind === 'fail' || m.kind === 'failwith') {
378
+ let threw = null;
379
+ try { this.eval(node.actual, env); }
380
+ catch (e) { if (e instanceof MyxoError) threw = e; else throw e; } // report/JS-bug propagate
381
+ if (!threw) throw new NxAssertError('expected the expression to fail, but it succeeded', node.line);
382
+ // Only an INTENTIONAL failure satisfies `to fail`: an explicit `fail`, or a fence/budget denial.
383
+ // An incidental error (unknown pathway, bad arity, type error) re-throws -> the test ERRORS, never
384
+ // silently passes for the wrong reason. (Use the rescue form to assert an arbitrary runtime error.)
385
+ if (!threw.nxFail && !threw.nxFence) throw threw;
386
+ if (m.kind === 'failwith') {
387
+ const want = this.eval(m.expected, env);
388
+ if (typeof want !== 'string') throw new MyxoError("'to fail with' needs a string", node.line);
389
+ if (!String(threw.message).includes(want))
390
+ throw new NxAssertError(`expected failure containing ${showv(want)}, got ${showv(threw.message)}`, node.line);
391
+ }
392
+ return;
393
+ }
394
+ const actual = this.eval(node.actual, env);
395
+ if (m.kind === 'truthy') {
396
+ if (actual && (actual.__agent || actual.__native))
397
+ throw new NxAssertError(`expected a value, but got ${showv(actual)} — did you forget to call it?`, node.line);
398
+ if (!truthy(actual)) throw new NxAssertError(`expected a live value, got ${showv(actual)}`, node.line);
399
+ return;
400
+ }
401
+ const expected = this.eval(m.expected, env);
402
+ const eq = deepEqual(actual, expected);
403
+ if (m.kind === 'is' && !eq) throw new NxAssertError(`expected ${showv(expected)}, got ${showv(actual)}`, node.line);
404
+ if (m.kind === 'isnot' && eq) throw new NxAssertError(`expected not ${showv(expected)}, but got it`, node.line);
405
+ return;
406
+ }
407
+ case 'ExpressionStatement':
408
+ this.eval(node.expr, env);
409
+ return;
410
+ default:
411
+ throw new MyxoError(`cannot execute ${node.type}`, node.line);
412
+ }
413
+ }
414
+
415
+ // Try a pattern against a value. Returns a bindings object on match ({} = matched, no binds),
416
+ // or null on no match. Used by `match`.
417
+ matchPattern(pat, value, env) {
418
+ switch (pat.type) {
419
+ case 'PWild': return {};
420
+ case 'PBind': return { [pat.name]: value };
421
+ case 'PLit': return deepEqual(this.eval(pat.expr, env), value) ? {} : null;
422
+ case 'PList': {
423
+ if (!Array.isArray(value)) return null;
424
+ const n = pat.elements.length;
425
+ if (pat.rest == null ? value.length !== n : value.length < n) return null;
426
+ const binds = {};
427
+ for (let i = 0; i < n; i++) {
428
+ const b = this.matchPattern(pat.elements[i], value[i], env);
429
+ if (!b) return null;
430
+ Object.assign(binds, b);
431
+ }
432
+ if (pat.rest != null) binds[pat.rest] = value.slice(n);
433
+ return binds;
434
+ }
435
+ case 'PMesh': {
436
+ if (!(value instanceof Map)) return null;
437
+ const binds = {};
438
+ for (const pr of pat.pairs) {
439
+ if (!value.has(pr.key)) return null;
440
+ const b = this.matchPattern(pr.pattern, value.get(pr.key), env);
441
+ if (!b) return null;
442
+ Object.assign(binds, b);
443
+ }
444
+ return binds;
445
+ }
446
+ default: throw new MyxoError(`unknown pattern type ${pat.type}`);
447
+ }
448
+ }
449
+
450
+ execAssign(node, env) {
451
+ const value = this.eval(node.value, env);
452
+ const t = node.target;
453
+ if (t.type === 'Identifier') {
454
+ const f = env.lookup(t.name);
455
+ const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
456
+ if (fr && fr.pure && f && !this.isLocalEnv(f.env, fr.root)) this.markImpure(); // outer write taints the agent
457
+ // global write, OR rebinding an agent value (a memoized caller may close over it via the recursion
458
+ // exemption, and that read is taint-free) -> invalidate dependent caches. Agent rebinds are rare.
459
+ if (f && (f.env === this.globals
460
+ || (f.entry.value && (f.entry.value.__agent || f.entry.value.__native))
461
+ || (value && (value.__agent || value.__native)))) this.epoch++;
462
+ if (this.strict && f && f.entry.type && !typeMatches(f.entry.type, value)) // a typed binding stays typed on reassignment
463
+ throw new MyxoError(`'${t.name}' expects ${f.entry.type}, got ${typeName(value)}`, node.line);
464
+ env.set(t.name, value, t.line); return;
465
+ }
466
+ // Index target: list[i] = v or mesh[k] = v
467
+ const obj = this.eval(t.object, env);
468
+ const idx = this.eval(t.index, env);
469
+ if (Array.isArray(obj)) {
470
+ const i = this.asIndex(idx, obj.length, node.line, true);
471
+ obj[i] = value;
472
+ } else if (obj instanceof Map) {
473
+ obj.set(this.asKey(idx, node.line), value);
474
+ } else {
475
+ throw new MyxoError(`cannot index a ${typeName(obj)}`, node.line);
476
+ }
477
+ }
478
+
479
+ execDecay(node, env) {
480
+ const t = node.target;
481
+ if (t.type === 'Identifier') {
482
+ const f = env.lookup(t.name);
483
+ const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
484
+ if (fr && fr.pure && f && !this.isLocalEnv(f.env, fr.root)) this.markImpure(); // outer decay taints the agent
485
+ if (f && (f.env === this.globals
486
+ || (f.entry.value && (f.entry.value.__agent || f.entry.value.__native)))) this.epoch++; // global/agent decay -> invalidate
487
+ env.remove(t.name, t.line); return;
488
+ }
489
+ const obj = this.eval(t.object, env);
490
+ const idx = this.eval(t.index, env);
491
+ if (Array.isArray(obj)) {
492
+ const i = this.asIndex(idx, obj.length, node.line, false);
493
+ obj.splice(i, 1);
494
+ } else if (obj instanceof Map) {
495
+ obj.delete(this.asKey(idx, node.line));
496
+ } else {
497
+ throw new MyxoError(`cannot decay a slot of ${typeName(obj)}`, node.line);
498
+ }
499
+ }
500
+
501
+ execForEach(node, env) {
502
+ const it = this.eval(node.iterable, env);
503
+ let items;
504
+ if (Array.isArray(it)) items = it.slice();
505
+ else if (it instanceof Map) items = [...it.keys()];
506
+ else if (typeof it === 'string') items = [...it];
507
+ else throw new MyxoError(`cannot walk a ${typeName(it)} with 'for each'`, node.line);
508
+ for (const item of items) {
509
+ const child = new Environment(env);
510
+ child.define(node.varName, item);
511
+ this.execBlock(node.body, child);
512
+ }
513
+ }
514
+
515
+ // ---- expressions --------------------------------------------------------
516
+
517
+ eval(node, env) {
518
+ this.step(node.line);
519
+ switch (node.type) {
520
+ case 'Number': return node.value;
521
+ case 'String': return node.value;
522
+ case 'Interp': return node.parts.map(p => stringify(this.eval(p, env))).join('');
523
+ case 'Bool': return node.value;
524
+ case 'Void': return VOID;
525
+ case 'Identifier': {
526
+ const f = env.lookup(node.name);
527
+ if (!f) throw new MyxoError(`unknown pathway '${node.name}'`, node.line);
528
+ f.entry.strength++; // the law: reads reinforce
529
+ const fr = this.frames.length ? this.frames[this.frames.length - 1] : null;
530
+ if (fr && fr.pure && !f.entry.system && !this.isLocalEnv(f.env, fr.root)) {
531
+ const val = f.entry.value; // reading mutable DATA from outside taints purity;
532
+ if (!(val && (val.__agent || val.__native))) this.markImpure(); // reading code (agents/natives) is fine
533
+ }
534
+ return f.entry.value;
535
+ }
536
+ case 'List': return node.elements.map(e => this.eval(e, env));
537
+ case 'Mesh': {
538
+ const m = new Map();
539
+ for (const [k, v] of node.pairs) m.set(this.asKey(this.eval(k, env), node.line), this.eval(v, env));
540
+ return m;
541
+ }
542
+ case 'AgentExpr':
543
+ return { __agent: true, name: 'anon', params: node.params, body: node.body, closure: env, returnType: node.returnType };
544
+ case 'Unary': return this.evalUnary(node, env);
545
+ case 'Binary': return this.evalBinary(node, env);
546
+ case 'Index': return this.evalIndex(node, env);
547
+ case 'Call': return this.evalCall(node, env);
548
+ case 'Pipe': return this.evalPipe(node, env);
549
+ case 'Dispatch': return this.evalDispatch(node, env);
550
+ case 'Gather': return this.evalGather(node, env);
551
+ case 'Spawn': return this.evalSpawn(node, env);
552
+ default:
553
+ throw new MyxoError(`cannot evaluate ${node.type}`, node.line);
554
+ }
555
+ }
556
+
557
+ evalUnary(node, env) {
558
+ const v = this.eval(node.operand, env);
559
+ if (node.op === 'not') return !truthy(v);
560
+ if (node.op === '-') {
561
+ if (typeof v !== 'number') throw new MyxoError(`cannot negate a ${typeName(v)}`, node.line);
562
+ return -v;
563
+ }
564
+ }
565
+
566
+ evalBinary(node, env) {
567
+ // Short-circuit logic.
568
+ // Value-returning short-circuit: `a and b` -> b if a is live, else a;
569
+ // `a or b` -> a if a is live, else b. Unlocks `x or default`.
570
+ if (node.op === 'and') { const l = this.eval(node.left, env); return truthy(l) ? this.eval(node.right, env) : l; }
571
+ if (node.op === 'or') { const l = this.eval(node.left, env); return truthy(l) ? l : this.eval(node.right, env); }
572
+
573
+ const a = this.eval(node.left, env);
574
+ const b = this.eval(node.right, env);
575
+ switch (node.op) {
576
+ case '==': return this.equals(a, b);
577
+ case '!=': return !this.equals(a, b);
578
+ case '+':
579
+ if (typeof a === 'string' || typeof b === 'string') return stringify(a) + stringify(b);
580
+ if (Array.isArray(a) && Array.isArray(b)) return a.concat(b);
581
+ this.bothNumbers(a, b, node);
582
+ return a + b;
583
+ case '-': this.bothNumbers(a, b, node); return a - b;
584
+ case '*': this.bothNumbers(a, b, node); return a * b;
585
+ case '/': this.bothNumbers(a, b, node); if (b === 0) throw new MyxoError('division by zero', node.line); return a / b;
586
+ case '%': this.bothNumbers(a, b, node); if (b === 0) throw new MyxoError('modulo by zero', node.line); return a % b;
587
+ case '>': return this.compare(a, b, node) > 0;
588
+ case '<': return this.compare(a, b, node) < 0;
589
+ case '>=': return this.compare(a, b, node) >= 0;
590
+ case '<=': return this.compare(a, b, node) <= 0;
591
+ default: throw new MyxoError(`unknown operator ${node.op}`, node.line);
592
+ }
593
+ }
594
+
595
+ evalIndex(node, env) {
596
+ const obj = this.eval(node.object, env);
597
+ const idx = this.eval(node.index, env);
598
+ if (Array.isArray(obj)) {
599
+ const i = this.asIndex(idx, obj.length, node.line, false);
600
+ return obj[i];
601
+ }
602
+ if (obj instanceof Map) {
603
+ const key = this.asKey(idx, node.line);
604
+ return obj.has(key) ? obj.get(key) : VOID; // missing key is void, not an error
605
+ }
606
+ if (typeof obj === 'string') {
607
+ const i = this.asIndex(idx, obj.length, node.line, false);
608
+ return obj[i];
609
+ }
610
+ throw new MyxoError(`cannot index a ${typeName(obj)}`, node.line);
611
+ }
612
+
613
+ evalCall(node, env) {
614
+ const callee = this.eval(node.callee, env);
615
+ const args = node.args.map(a => this.eval(a, env));
616
+ return this.callValue(callee, args, node.line);
617
+ }
618
+
619
+ // `x | f` -> f(x); `x | f(a, b)` -> f(x, a, b). The piped value is the FIRST argument.
620
+ evalPipe(node, env) {
621
+ const leftVal = this.eval(node.left, env);
622
+ const r = node.right;
623
+ if (r.type === 'Call') {
624
+ const callee = this.eval(r.callee, env);
625
+ const args = [leftVal, ...r.args.map(a => this.eval(a, env))];
626
+ return this.callValue(callee, args, r.line);
627
+ }
628
+ return this.callValue(this.eval(r, env), [leftVal], node.line);
629
+ }
630
+
631
+ // `dispatch f(x)` builds an UNSTARTED task: it captures the agent's code + the evaluated args, but runs
632
+ // nothing yet. The args must be plain data — a task crosses a thread boundary, where a closure can't follow.
633
+ evalDispatch(node, env) {
634
+ const agent = this.eval(node.call.callee, env);
635
+ if (!(agent && agent.__agent)) throw new MyxoError('dispatch needs an agent, e.g. dispatch work(x)', node.line);
636
+ const args = node.call.args.map(a => this.eval(a, env));
637
+ const { assertSerializable } = require('./myxo-concurrent');
638
+ args.forEach((a) => { try { assertSerializable(a, 'a dispatch argument'); } catch (e) { throw new MyxoError(e.message, node.line); } });
639
+ return { __task: true, name: agent.name, params: agent.params, body: agent.body, args };
640
+ }
641
+
642
+ // `gather [t1, t2, ...]` runs every dispatched task on its own worker thread, in parallel, and blocks until
643
+ // all finish — then returns their results in order. Each task runs ISOLATED (stdlib + its args + itself): it
644
+ // cannot see or mutate the parent's pathways, which is exactly what makes parallel execution race-free.
645
+ evalGather(node, env) {
646
+ const list = this.eval(node.expr, env);
647
+ if (!Array.isArray(list)) throw new MyxoError('gather expects a list of dispatched tasks', node.line);
648
+ if (this.frames.length) this.markImpure(); // spawning threads is an effect -> never memoize a gather away
649
+ const { nxToJs, jsToNx } = require('./polyglot');
650
+ const tasks = list.map((h, i) => {
651
+ if (!(h && h.__task)) throw new MyxoError(`gather: item ${i} is not a dispatched task (use 'dispatch f(x)')`, node.line);
652
+ return { name: h.name, params: h.params, body: h.body, args: h.args.map(a => nxToJs(a)) };
653
+ });
654
+ const { runParallel } = require('./myxo-concurrent');
655
+ let raw;
656
+ try { raw = runParallel(tasks); }
657
+ catch (e) { throw new MyxoError('gather: ' + (e && e.message ? e.message : String(e)), node.line); }
658
+ return raw.map(jsToNx);
659
+ }
660
+
661
+ // ---- cooperative concurrency: fibers + channels ----------------------------------------------------------
662
+ // `spawn f(x)` starts a fiber: a lightweight task that runs COOPERATIVELY (interleaved on this one thread —
663
+ // not in parallel; that's what `gather` is for). Fibers communicate through channels, parking when a channel
664
+ // blocks so another fiber can run. No shared mutable state — the channel value passes hand to hand — so it's
665
+ // race-free by construction, just like the law says. `drain()`/`join()` run the scheduler to completion.
666
+
667
+ evalSpawn(node, env) {
668
+ const agent = this.eval(node.call.callee, env);
669
+ if (!(agent && agent.__agent)) throw new MyxoError('spawn needs an agent, e.g. spawn worker(ch)', node.line);
670
+ const args = node.call.args.map(a => this.eval(a, env));
671
+ if (this.frames.length) this.markImpure(); // spawning a fiber is an effect -> never memoize it away
672
+ return this.spawnFiber(agent, args, node.line);
673
+ }
674
+
675
+ spawnFiber(agent, args, line) {
676
+ const params = agent.params;
677
+ const hasRest = params.length > 0 && params[params.length - 1].rest;
678
+ const required = params.filter(p => !p.rest && p.def == null).length;
679
+ if (args.length < required) throw new MyxoError(`agent ${agent.name} needs at least ${required} argument(s), got ${args.length}`, line);
680
+ if (!hasRest && args.length > params.length) throw new MyxoError(`agent ${agent.name} takes at most ${params.length} argument(s), got ${args.length}`, line);
681
+ const local = new Environment(agent.closure); // bind params exactly as callValue does
682
+ let ai = 0;
683
+ for (const p of params) {
684
+ if (p.rest) { local.define(p.name, args.slice(ai)); ai = args.length; break; }
685
+ if (ai < args.length) local.define(p.name, args[ai++], false, p.paramType);
686
+ else local.define(p.name, p.def != null ? this.eval(p.def, local) : VOID, false, p.paramType);
687
+ }
688
+ if (this.strict) {
689
+ for (const p of params) {
690
+ if (p.paramType && !p.rest) {
691
+ const pv = local.vars.get(p.name).value;
692
+ if (!typeMatches(p.paramType, pv)) throw new MyxoError(`agent ${agent.name || 'anon'} param '${p.name}' expects ${p.paramType}, got ${typeName(pv)}`, line);
693
+ }
694
+ }
695
+ }
696
+ const fiber = { __fiber: true, name: agent.name || 'fiber', done: false, result: VOID, error: null };
697
+ fiber.gen = this.stepBlock(agent.body, local);
698
+ this.fibers.push(fiber);
699
+ this.ready.push({ fiber });
700
+ return fiber;
701
+ }
702
+
703
+ // The generator stepper: runs a fiber's STATEMENTS, yielding a park-request at each suspension point. Leaf
704
+ // statements (and ALL expressions) run on the normal synchronous path — only the block-bearing statements and
705
+ // the channel ops need to be generator-aware, so the rest of the interpreter is untouched.
706
+ *stepBlock(stmts, env) {
707
+ for (const s of stmts) yield* this.stepStmt(s, env);
708
+ }
709
+
710
+ *stepStmt(node, env) {
711
+ this.step(node.line);
712
+ switch (node.type) {
713
+ case 'Give': {
714
+ const ch = this.eval(node.channel, env);
715
+ if (!(ch && ch.__channel)) throw new MyxoError(`give needs a channel, got ${typeName(ch)}`, node.line);
716
+ const value = this.eval(node.value, env);
717
+ if (ch.recvW.length) { this.ready.push({ fiber: ch.recvW.shift(), val: value }); } // hand straight to a waiting receiver
718
+ else if (ch.buf.length < ch.cap) { ch.buf.push(value); } // room to buffer
719
+ else { yield { t: 'send', ch, value }; } // bounded + full -> park (waker buffers it)
720
+ return;
721
+ }
722
+ case 'Take': {
723
+ const ch = this.eval(node.channel, env);
724
+ if (!(ch && ch.__channel)) throw new MyxoError(`take needs a channel, got ${typeName(ch)}`, node.line);
725
+ let value;
726
+ if (ch.buf.length) {
727
+ value = ch.buf.shift();
728
+ if (ch.sendW.length) { const s = ch.sendW.shift(); ch.buf.push(s.value); this.ready.push({ fiber: s.fiber }); } // freed a slot -> wake a blocked sender
729
+ } else {
730
+ value = yield { t: 'recv', ch }; // empty -> park; resumed with the taken value
731
+ }
732
+ env.define(node.name, value);
733
+ return;
734
+ }
735
+ case 'Yield': yield { t: 'yield' }; return;
736
+ case 'When':
737
+ if (truthy(this.eval(node.cond, env))) yield* this.stepBlock(node.thenBlock, new Environment(env));
738
+ else if (node.elseBlock) yield* this.stepBlock(node.elseBlock, new Environment(env));
739
+ return;
740
+ case 'Reinforce':
741
+ while (truthy(this.eval(node.cond, env))) yield* this.stepBlock(node.body, new Environment(env));
742
+ return;
743
+ case 'ReinforceTimes': {
744
+ const n = this.eval(node.count, env);
745
+ if (typeof n !== 'number') throw new MyxoError(`reinforce ... times needs a number, got ${typeName(n)}`, node.line);
746
+ for (let k = 0; k < n; k++) yield* this.stepBlock(node.body, new Environment(env));
747
+ return;
748
+ }
749
+ case 'ForEach': {
750
+ const it = this.eval(node.iterable, env);
751
+ let items;
752
+ if (Array.isArray(it)) items = it.slice();
753
+ else if (it instanceof Map) items = [...it.keys()];
754
+ else if (typeof it === 'string') items = [...it];
755
+ else throw new MyxoError(`cannot walk a ${typeName(it)} with 'for each'`, node.line);
756
+ for (const item of items) {
757
+ const child = new Environment(env);
758
+ child.define(node.varName, item);
759
+ yield* this.stepBlock(node.body, child);
760
+ }
761
+ return;
762
+ }
763
+ case 'Match': { // generator-aware so a channel op inside an arm still suspends (mirrors exec's Match)
764
+ const subject = this.eval(node.subject, env);
765
+ for (const arm of node.arms) {
766
+ const binds = this.matchPattern(arm.pattern, subject, env);
767
+ if (binds) {
768
+ const child = new Environment(env);
769
+ for (const k of Object.keys(binds)) child.define(k, binds[k]);
770
+ yield* this.stepBlock(arm.body, child);
771
+ return;
772
+ }
773
+ }
774
+ return;
775
+ }
776
+ case 'Attempt': { // generator-aware so a channel op in the try/rescue suspends — NOT swallowed as a fake failure (mirrors exec's Attempt)
777
+ try { yield* this.stepBlock(node.tryBlock, new Environment(env)); }
778
+ catch (e) {
779
+ if (e instanceof ReportSignal) throw e;
780
+ if (!(e instanceof MyxoError)) throw e;
781
+ const m = new Map();
782
+ m.set('message', e.message);
783
+ m.set('line', typeof e.line === 'number' ? e.line : VOID);
784
+ m.set('value', e.nxValue !== undefined ? e.nxValue : VOID);
785
+ const child = new Environment(env);
786
+ child.define(node.errName, m);
787
+ yield* this.stepBlock(node.catchBlock, child);
788
+ }
789
+ return;
790
+ }
791
+ default:
792
+ this.exec(node, env); // a non-suspending leaf runs synchronously; a channel op reached here (e.g. in a CALLED agent) hits exec's guard
793
+ return;
794
+ }
795
+ }
796
+
797
+ // pump(): run the ready queue until no fiber can make progress. Does NOT itself decide deadlock — the CALLER
798
+ // does (drain/program-end want "everyone finished"; await wants only its target). Re-entrancy is forbidden:
799
+ // calling drain/await from inside a fiber would re-enter here and miscount the in-flight fiber as deadlocked.
800
+ pump() {
801
+ if (this.inScheduler) {
802
+ throw new MyxoError("the fiber scheduler is already running — 'await'/'drain' can't be called from inside a fiber (coordinate with channels or 'yield' instead)");
803
+ }
804
+ this.inScheduler = true;
805
+ const cap = Number.isFinite(this.maxSteps) ? this.maxSteps : 10000000; // bound a runaway (e.g. yield-forever) so it errors, never hangs
806
+ let steps = 0;
807
+ try {
808
+ while (this.ready.length) {
809
+ if (++steps > cap) throw new MyxoError('fiber scheduler exceeded its step budget (runaway fibers?)');
810
+ const { fiber, val } = this.ready.shift();
811
+ if (fiber.done) continue;
812
+ let r;
813
+ this.steppingFiber = true; // a channel op reaching exec's guard now knows it's inside a fiber (honest message)
814
+ try { r = fiber.gen.next(val); }
815
+ catch (e) {
816
+ if (e instanceof ReportSignal) { fiber.done = true; fiber.result = e.value; continue; }
817
+ fiber.done = true; fiber.error = e; continue; // hold the error; the caller surfaces it
818
+ } finally { this.steppingFiber = false; }
819
+ if (r.done) { fiber.done = true; continue; } // body fell off the end -> result stays void
820
+ const sig = r.value;
821
+ if (sig.t === 'yield') this.ready.push({ fiber });
822
+ else if (sig.t === 'recv') sig.ch.recvW.push(fiber);
823
+ else if (sig.t === 'send') sig.ch.sendW.push({ fiber, value: sig.value });
824
+ }
825
+ } finally { this.inScheduler = false; }
826
+ }
827
+
828
+ // After a full drain (drain()/program end), EVERYONE must have finished — surface the first error, then any deadlock.
829
+ checkAllFibersDone() {
830
+ const failed = this.fibers.find(f => f.error);
831
+ if (failed) throw failed.error;
832
+ const stuck = this.fibers.filter(f => !f.done);
833
+ if (stuck.length) throw new MyxoError(`${stuck.length} fiber(s) deadlocked — blocked on a channel with no one to unblock them`);
834
+ }
835
+
836
+ callValue(callee, args, line) {
837
+ if (callee && callee.__native) {
838
+ if (!callee.capability) {
839
+ if (IMPURE_BUILTINS.has(callee.name) || callee.impure) this.markImpure(); // nondeterministic / state-reading / global-mutating builtin (incl. routers) -> taint the caller
840
+ return callee.fn(args, this); // a language builtin — free
841
+ }
842
+ this.markImpure(); // any host capability call taints purity (side effects)
843
+ // a host capability: enforce the manifest, then record the call in the audit ledger.
844
+ const refuse = (error, msg) => {
845
+ this.audit.push({ cap: callee.name, args: args.map(a => stringify(a)), ok: false, error });
846
+ const err = new MyxoError(msg, line);
847
+ err.nxFence = true; throw err; // a policy denial, not a transient failure — a router must NOT route around it
848
+ };
849
+ if (!this.manifest && this.requireManifest) {
850
+ refuse('missing needs manifest',
851
+ `script must declare a 'needs' manifest before calling capability '${callee.name}'`);
852
+ }
853
+ if (this.manifest && !this.manifest.has(callee.name)) {
854
+ // overreach is logged too — you want to know an agent TRIED.
855
+ refuse('not declared in needs',
856
+ `capability '${callee.name}' is not declared in this script's 'needs'`);
857
+ }
858
+ // a declared `(max N)` / `(total N)` cap is the script's OWN ceiling — enforced here, so even a host that
859
+ // grants unlimited power is bounded by what the script said it may do. CRUCIALLY, whether a cap is metered
860
+ // by CALL COUNT or by VALUE is fixed by the HOST (valueCaps), never by the untrusted script — otherwise a
861
+ // hostile script would declare a count cap as a value budget and pay 0 per call. Default = count.
862
+ const limit = this.manifest ? this.manifest.get(callee.name) : null;
863
+ const isValue = this.valueCaps.has(callee.name);
864
+ if (limit) {
865
+ if (isValue) {
866
+ // VALUE metering: the first arg IS the amount. Must be a finite, non-negative number.
867
+ const v = args[0];
868
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
869
+ refuse('invalid value-budget argument',
870
+ `capability '${callee.name}' has a value budget and needs a finite non-negative numeric first argument (got ${stringify(v)})`);
871
+ }
872
+ if (limit.max != null && v > limit.max) {
873
+ refuse(`over the per-call max of ${limit.max}`,
874
+ `capability '${callee.name}' exceeds its per-call max of ${limit.max} (got ${v})`);
875
+ }
876
+ if (limit.total != null) {
877
+ const prior = this.capSpent.get(callee.name) || 0;
878
+ if (prior + v > limit.total) {
879
+ refuse(`over the total budget of ${limit.total}`,
880
+ `capability '${callee.name}' would exceed its total value budget of ${limit.total} (${prior} already used)`);
881
+ }
882
+ }
883
+ } else {
884
+ // COUNT metering (default): every call costs exactly 1, whatever the arguments. Both `max` and `total`
885
+ // bound the cumulative call count; the tighter one binds. The script CANNOT cheapen a call to 0.
886
+ const cap = Math.min(limit.total != null ? limit.total : Infinity,
887
+ limit.max != null ? limit.max : Infinity);
888
+ if (Number.isFinite(cap)) {
889
+ const prior = this.capCalls.get(callee.name) || 0;
890
+ if (prior + 1 > cap) {
891
+ refuse(`over the call budget of ${cap}`,
892
+ `capability '${callee.name}' would exceed its budget of ${cap} call(s) (${prior} already used)`);
893
+ }
894
+ }
895
+ }
896
+ }
897
+ const entry = { cap: callee.name, args: args.map(a => stringify(a)) };
898
+ try {
899
+ const r = callee.fn(args, this);
900
+ entry.ok = true; entry.result = stringify(r);
901
+ if (limit) {
902
+ if (isValue) this.capSpent.set(callee.name, (this.capSpent.get(callee.name) || 0) + args[0]);
903
+ else this.capCalls.set(callee.name, (this.capCalls.get(callee.name) || 0) + 1);
904
+ }
905
+ this.audit.push(entry);
906
+ return r;
907
+ } catch (e) {
908
+ // Compute the message DEFENSIVELY first — a native may `throw null` / `throw "str"` / `throw {}`,
909
+ // and reading `.message` off a non-object would itself throw a TypeError that escapes rescue
910
+ // (the gate's exact repro). Do this before touching the ledger so the audit entry always records.
911
+ const msg = (e && e.message != null) ? String(e.message) : String(e);
912
+ entry.ok = false; entry.error = msg;
913
+ this.audit.push(entry);
914
+ // A FAILING host capability must be rescuable in-script, exactly like a fence denial —
915
+ // `attempt { lookup(x) } rescue e { ... }` is the documented pattern (§15 catches MyxoError only).
916
+ // A raw JS error from the host (network refused, timeout, host bug) previously leaked through
917
+ // attempt/rescue and killed the whole run — the lichen-sentry dogfood caught it: a DOWN brain
918
+ // crashed the monitor instead of producing its ALERT verdict.
919
+ if (e instanceof MyxoError) throw e;
920
+ const wrapped = new MyxoError(`capability '${callee.name}' failed: ${msg}`, line);
921
+ // PRESERVE policy flags: command-fence's policyError throws a PLAIN Error with nxFence=true (a
922
+ // denial a flow-router must NOT route around) — and nxFail marks an intentional failure. Stripping
923
+ // them would let a router bypass a policy refusal and would break `expect ... to fail`. Carry them.
924
+ if (e && e.nxFence) wrapped.nxFence = true;
925
+ if (e && e.nxFail) wrapped.nxFail = true;
926
+ throw wrapped;
927
+ }
928
+ }
929
+ if (callee && callee.__agent) {
930
+ const params = callee.params;
931
+ const hasRest = params.length > 0 && params[params.length - 1].rest;
932
+ const required = params.filter(p => !p.rest && p.def == null).length;
933
+ if (args.length < required) {
934
+ throw new MyxoError(`agent ${callee.name} needs at least ${required} argument(s), got ${args.length}`, line);
935
+ }
936
+ if (!hasRest && args.length > params.length) {
937
+ throw new MyxoError(`agent ${callee.name} takes at most ${params.length} argument(s), got ${args.length}`, line);
938
+ }
939
+ // --- THE LIVING MESH: a hot agent's pathway PROMOTES (memoizes) ONLY where the runtime can prove it safe. ---
940
+ // SOUND conditions, all required: (a) plain params only -> args fully determine the call (no default-expr to track
941
+ // or omit from the key); (b) the call was never tainted -> no capability / random / emit / weave / state-reading
942
+ // builtin, no outer write, no free DATA read (reading a free AGENT is allowed, for recursion); (c) args AND result
943
+ // are primitive; (d) the global EPOCH is unchanged -> any global write since caching invalidates. Any taint, ever,
944
+ // permanently disables the agent. (Memoization is an optimization; it is NOT claimed invisible to mesh strengths.)
945
+ const memoEligible = params.every(p => !p.rest && p.def == null);
946
+ const argsPrim = memoEligible && args.every(isPrimitive);
947
+ let rec = this.memo.get(callee);
948
+ if (!rec) { rec = { calls: 0, impure: false, hits: 0, cache: new Map(), epoch: this.epoch }; this.memo.set(callee, rec); }
949
+ rec.calls++;
950
+ if (rec.epoch !== this.epoch) { rec.cache.clear(); rec.epoch = this.epoch; } // a global changed -> drop possibly-stale entries
951
+ if (argsPrim && !rec.impure && rec.calls > this.promoteAt) {
952
+ const k = keyOf(args);
953
+ if (rec.cache.has(k)) { rec.hits++; return rec.cache.get(k); } // promoted pathway short-circuits
954
+ }
955
+ const local = new Environment(callee.closure);
956
+ let ai = 0;
957
+ for (const p of params) {
958
+ if (p.rest) { local.define(p.name, args.slice(ai)); ai = args.length; break; }
959
+ if (ai < args.length) local.define(p.name, args[ai++], false, p.paramType);
960
+ else local.define(p.name, p.def != null ? this.eval(p.def, local) : VOID, false, p.paramType); // default in call scope
961
+ }
962
+ if (this.strict) { // gradual-type contracts on the parameters (read-only; never taints purity)
963
+ for (const p of params) {
964
+ if (p.paramType && !p.rest) {
965
+ const pv = local.vars.get(p.name).value;
966
+ if (!typeMatches(p.paramType, pv))
967
+ throw new MyxoError(`agent ${callee.name || 'anon'} param '${p.name}' expects ${p.paramType}, got ${typeName(pv)}`, line);
968
+ }
969
+ }
970
+ }
971
+ if (this.callStack.length >= this.maxDepth) {
972
+ throw new MyxoError(`call stack went too deep (over ${this.maxDepth}) — runaway recursion?`, line);
973
+ }
974
+ this.callStack.push({ name: callee.name || 'anon', line });
975
+ const frame = { root: local, pure: true };
976
+ this.frames.push(frame);
977
+ let result = VOID, completed = false;
978
+ try {
979
+ this.execBlock(callee.body, local);
980
+ completed = true;
981
+ } catch (e) {
982
+ if (e instanceof ReportSignal) { result = e.value; completed = true; }
983
+ else {
984
+ // stamp the trace at the deepest agent the failure passes through, while every outer frame is still live.
985
+ if (e instanceof MyxoError && !e.nxStack) e.nxStack = this.callStack.slice();
986
+ throw e;
987
+ }
988
+ } finally {
989
+ this.callStack.pop();
990
+ this.frames.pop();
991
+ if (!frame.pure) { rec.impure = true; rec.cache.clear(); } // taint sticks even if the call threw
992
+ }
993
+ // cache only a clean, hot, primitive-result call, at the current epoch
994
+ if (this.strict && callee.returnType && !typeMatches(callee.returnType, result))
995
+ throw new MyxoError(`agent ${callee.name || 'anon'} should return ${callee.returnType}, got ${typeName(result)}`, line);
996
+ // cache only AFTER the return contract passes -> a violating result is never cached and every call errors
997
+ if (completed && !rec.impure && argsPrim && isPrimitive(result) && rec.calls >= this.promoteAt && rec.cache.size < 50000) {
998
+ rec.cache.set(keyOf(args), result);
999
+ }
1000
+ return result;
1001
+ }
1002
+ throw new MyxoError(`${typeName(callee)} is not an agent — cannot call it`, line);
1003
+ }
1004
+
1005
+ // ---- small typed helpers ------------------------------------------------
1006
+
1007
+ bothNumbers(a, b, node) {
1008
+ if (typeof a !== 'number' || typeof b !== 'number') {
1009
+ throw new MyxoError(`'${node.op}' needs two numbers, got ${typeName(a)} and ${typeName(b)}`, node.line);
1010
+ }
1011
+ }
1012
+
1013
+ compare(a, b, node) {
1014
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
1015
+ if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0;
1016
+ throw new MyxoError(`cannot compare ${typeName(a)} and ${typeName(b)}`, node.line);
1017
+ }
1018
+
1019
+ equals(a, b) {
1020
+ if (a === VOID || b === VOID) return a === b;
1021
+ if (typeof a !== typeof b) return false;
1022
+ return a === b; // primitives by value; collections/agents by identity
1023
+ }
1024
+
1025
+ asIndex(idx, length, line, allowAppend) {
1026
+ if (typeof idx !== 'number' || !Number.isInteger(idx)) {
1027
+ throw new MyxoError(`index must be a whole number, got ${typeName(idx)}`, line);
1028
+ }
1029
+ const i = idx < 0 ? length + idx : idx; // negative indexes from the end
1030
+ if (i < 0 || i > length || (!allowAppend && i >= length)) {
1031
+ throw new MyxoError(`index ${idx} is outside the list (length ${length})`, line);
1032
+ }
1033
+ return i;
1034
+ }
1035
+
1036
+ asKey(idx, line) {
1037
+ if (typeof idx === 'string') return idx;
1038
+ if (typeof idx === 'number') return String(idx);
1039
+ throw new MyxoError(`mesh keys must be strings or numbers, got ${typeName(idx)}`, line);
1040
+ }
1041
+
1042
+ // weave a strand: load its AST via the host loader, run it in its OWN scope,
1043
+ // and return a mesh of the names it `expose`d. Loading is a granted capability
1044
+ // (no loader -> weaving is fenced). Modules are cached; cycles are caught.
1045
+ weaveModule(reqPath, line) {
1046
+ if (!this.moduleLoader) throw new MyxoError(`weaving '${reqPath}' is not granted in this context`, line);
1047
+ const fromDir = this.dirStack[this.dirStack.length - 1] || this.baseDir;
1048
+ let loaded;
1049
+ try { loaded = this.moduleLoader(reqPath, fromDir); }
1050
+ catch (e) { throw new MyxoError(`cannot weave '${reqPath}': ${e.message}`, line); }
1051
+ if (this.moduleCache.has(loaded.key)) return this.moduleCache.get(loaded.key);
1052
+ if (this.moduleInProgress.has(loaded.key)) throw new MyxoError(`circular weave of '${reqPath}'`, line);
1053
+ this.moduleInProgress.add(loaded.key);
1054
+ const modEnv = new Environment(this.globals);
1055
+ const exportSet = new Set();
1056
+ this.exportStack.push(exportSet);
1057
+ this.dirStack.push(loaded.dir);
1058
+ try {
1059
+ this.execBlock(loaded.ast.body, modEnv);
1060
+ } finally {
1061
+ this.exportStack.pop();
1062
+ this.dirStack.pop();
1063
+ this.moduleInProgress.delete(loaded.key);
1064
+ }
1065
+ const exports = new Map();
1066
+ for (const name of exportSet) {
1067
+ if (!modEnv.vars.has(name)) throw new MyxoError(`'${name}' was exposed but never seeded in '${reqPath}'`, line);
1068
+ exports.set(name, modEnv.vars.get(name).value);
1069
+ }
1070
+ this.moduleCache.set(loaded.key, exports);
1071
+ return exports;
1072
+ }
1073
+
1074
+ // a side-effect / free-read taints the current agent call AND every caller above it
1075
+ // (a caller's result depends on the tainted callee), so it can never be memoized.
1076
+ markImpure() { for (const f of this.frames) f.pure = false; }
1077
+
1078
+ // is a resolved binding inside THIS call's own scope (local), or a free/outer var?
1079
+ isLocalEnv(foundEnv, root) {
1080
+ for (let a = root.parent; a; a = a.parent) if (a === foundEnv) return false; // resolved above the call root -> free
1081
+ return true; // root itself or a descendant -> local
1082
+ }
1083
+
1084
+ // A snapshot of the living mesh: user pathways and their strengths.
1085
+ meshSnapshot() {
1086
+ const rows = [];
1087
+ for (const [name, e] of this.globals.vars) if (!e.system) rows.push({ name, strength: e.strength });
1088
+ return rows.sort((x, y) => y.strength - x.strength);
1089
+ }
1090
+ }
1091
+
1092
+ module.exports = { Interpreter, Environment, VOID, ReportSignal, stringify, truthy, typeName };