agent-sh 0.14.9 → 0.14.11

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 (68) hide show
  1. package/README.md +47 -20
  2. package/dist/agent/agent-loop.js +20 -15
  3. package/dist/agent/events.d.ts +2 -1
  4. package/dist/agent/index.js +44 -7
  5. package/dist/agent/live-view.d.ts +3 -3
  6. package/dist/agent/live-view.js +15 -7
  7. package/dist/agent/providers/ollama.d.ts +11 -0
  8. package/dist/agent/providers/ollama.js +72 -0
  9. package/dist/agent/providers/opencode.d.ts +10 -0
  10. package/dist/agent/providers/opencode.js +112 -0
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
  13. package/dist/agent/providers/zai-coding-plan.js +26 -0
  14. package/dist/agent/subagent.js +1 -1
  15. package/dist/cli/args.js +2 -2
  16. package/dist/cli/install.js +10 -1
  17. package/dist/shell/events.d.ts +3 -0
  18. package/dist/shell/shell.js +3 -0
  19. package/dist/utils/diff-renderer.d.ts +4 -0
  20. package/dist/utils/diff-renderer.js +15 -20
  21. package/examples/extensions/ads/SKILL.md +170 -0
  22. package/examples/extensions/ads/index.ts +695 -0
  23. package/examples/extensions/ash-scheme/index.ts +339 -605
  24. package/examples/extensions/ash-scheme/package.json +1 -1
  25. package/examples/extensions/ashi/EXTENDING.md +116 -0
  26. package/examples/extensions/ashi/README.md +10 -54
  27. package/examples/extensions/ashi/package.json +6 -2
  28. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  29. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  30. package/examples/extensions/ashi/src/capture.ts +9 -3
  31. package/examples/extensions/ashi/src/chat/assistant.ts +87 -0
  32. package/examples/extensions/ashi/src/chat/lines.ts +20 -0
  33. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  34. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  35. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  36. package/examples/extensions/ashi/src/cli.ts +58 -12
  37. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  38. package/examples/extensions/ashi/src/commands.ts +11 -1
  39. package/examples/extensions/ashi/src/display-config.ts +9 -1
  40. package/examples/extensions/ashi/src/frontend.ts +340 -259
  41. package/examples/extensions/ashi/src/hooks.ts +33 -40
  42. package/examples/extensions/ashi/src/renderer.ts +222 -0
  43. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +23 -0
  45. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +133 -0
  46. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +193 -0
  47. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  48. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  49. package/examples/extensions/ashi/src/schema.ts +43 -205
  50. package/examples/extensions/ashi/src/status-footer.ts +15 -23
  51. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  52. package/examples/extensions/ashi/src/theme.ts +1 -47
  53. package/examples/extensions/ashi-ink/README.md +59 -0
  54. package/examples/extensions/ashi-ink/package.json +30 -0
  55. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  56. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  57. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  58. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  59. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  60. package/examples/extensions/ashi-scheme-render.ts +4 -10
  61. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  62. package/examples/extensions/latex-images.ts +22 -19
  63. package/examples/extensions/terminal-buffer.ts +4 -2
  64. package/package.json +3 -9
  65. package/examples/extensions/ashi/src/components.ts +0 -238
  66. package/examples/extensions/ollama.ts +0 -108
  67. package/examples/extensions/opencode-provider.ts +0 -251
  68. package/examples/extensions/zai-coding-plan.ts +0 -35
@@ -5,9 +5,13 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { createRequire } from "node:module";
8
9
  import type { AgentContext } from "agent-sh/types";
9
10
  import { getSettings } from "agent-sh/settings";
10
- import lips from "@jcubic/lips";
11
+ // LIPS 1.0's ESM build exposes named exports with no default; older builds (and
12
+ // the CJS interop path) put everything under `default`. Accept both.
13
+ import * as lipsNs from "@jcubic/lips";
14
+ const lips: any = (lipsNs as any).default ?? lipsNs;
11
15
 
12
16
  type ToolResult = {
13
17
  content: string; exitCode: number | null; isError: boolean;
@@ -26,6 +30,8 @@ async function withDisplay(
26
30
  title: toolName, toolCallId, kind, rawInput, displayDetail,
27
31
  });
28
32
  const result = await run();
33
+ // Stream the result so the TUI's tracked `output` holds it, not just the summary.
34
+ if (result.content) bus.emit("agent:tool-output-chunk", { toolCallId, chunk: result.content });
29
35
  bus.emit("agent:tool-completed", {
30
36
  toolCallId,
31
37
  exitCode: result.exitCode,
@@ -41,64 +47,16 @@ async function withDisplay(
41
47
  // the TUI still renders diffs.
42
48
  const HIDDEN_IN_SCHEME_ONLY = ["bash", "pwsh", "read_file", "write_file", "edit_file", "ls", "glob", "grep"];
43
49
 
44
- const { Pair, nil, LSymbol, LNumber, Macro, evaluate: lipsEvaluate } = lips as any;
45
-
46
- // LIPS' `define` discards the promise returned by async host bindings.
47
- // With `(define x (read-file …)) x` the exec() advances before `env.set`
48
- // fires, so `x` is reported unbound. Reinstall to return the promise.
49
- function installFixedDefine(env: any): void {
50
- const fixed = Macro.defmacro("define", function (this: any, code: any, eval_args: any) {
51
- const target = this;
52
- if (code.car instanceof Pair && code.car.car instanceof LSymbol) {
53
- return new Pair(
54
- new LSymbol("define"),
55
- new Pair(
56
- code.car.car,
57
- new Pair(
58
- new Pair(new LSymbol("lambda"), new Pair(code.car.cdr, code.cdr)),
59
- nil,
60
- ),
61
- ),
62
- );
63
- } else if (eval_args.macro_expand) {
64
- return;
65
- }
66
- if (eval_args.dynamic_scope) eval_args.dynamic_scope = target;
67
- eval_args.env = target;
68
- let value = code.cdr.car;
69
- if (value instanceof Pair) {
70
- value = lipsEvaluate(value, eval_args);
71
- } else if (value instanceof LSymbol) {
72
- value = target.get(value);
73
- }
74
- if (code.car instanceof LSymbol) {
75
- const name = code.car;
76
- if (value && typeof value.then === "function") {
77
- return value.then((v: any) => { target.set(name, v); });
78
- }
79
- target.set(name, value);
80
- }
81
- });
82
- env.set("define", fixed);
83
- }
50
+ const { Pair, nil, LSymbol, LNumber, Macro, bootstrap, LString } = lips as any;
84
51
 
85
- // LIPS' `if` is strict-boolean: `(if "hello" …)` errors. R7RS, Racket, Chicken
86
- // essentially every Scheme model is trained on treat any non-#f as true.
87
- // Reinstall a lenient `if`.
88
- function installLenientIf(env: any): void {
89
- const lenient = new Macro("if", function (this: any, code: any, opts: any) {
90
- const target = this;
91
- const dynScope = opts.dynamic_scope ? target : undefined;
92
- const choose = (cond: any) => {
93
- const branch = cond !== false ? code.cdr.car : code.cdr.cdr.car;
94
- return lipsEvaluate(branch, { env: target, dynamic_scope: dynScope, error: opts.error });
95
- };
96
- const condVal = lipsEvaluate(code.car, { env: target, dynamic_scope: dynScope, error: opts.error });
97
- if (condVal && typeof condVal.then === "function") return condVal.then(choose);
98
- return choose(condVal);
99
- });
100
- env.set("if", lenient);
101
- }
52
+ // LIPS 1.0 boxes string literals as LString; unbox to a JS primitive so the gap
53
+ // shims and host bridge (which assume JS strings) operate on them correctly.
54
+ const toJsStr = (x: any): any => (x instanceof LString ? x.toString() : x);
55
+
56
+ // LIPS 1.0 stores a symbol's name in `__name__`; `.name` is undefined (the 0.x
57
+ // build used `.name`). Read every symbol name through this so both builds work.
58
+ const symName = (x: any): string | undefined =>
59
+ x instanceof LSymbol ? ((x as any).__name__ ?? (x as any).name) : undefined;
102
60
 
103
61
  const LOG_PATH = path.join(os.homedir(), ".agent-sh", "scheme-eval.log");
104
62
  const SCHEME_DEFINE_DIR = path.join(os.homedir(), ".agent-sh", "scheme-define");
@@ -123,7 +81,7 @@ function installSchemeDefine(
123
81
  throw new Error("scheme-define: expected (scheme-define name (args …) \"doc\" body …)");
124
82
  }
125
83
  const nameSym = code.car;
126
- const name = nameSym.name;
84
+ const name = symName(nameSym)!;
127
85
  const argsForm = code.cdr instanceof Pair ? code.cdr.car : nil;
128
86
  let rest = code.cdr instanceof Pair ? code.cdr.cdr : nil;
129
87
  let doc = "";
@@ -175,7 +133,7 @@ async function loadPersistedDefines(
175
133
  const fp = path.join(SCHEME_DEFINE_DIR, f);
176
134
  try {
177
135
  const src = fs.readFileSync(fp, "utf-8");
178
- await (lips as any).exec(src, env);
136
+ await (lips as any).exec(src, { env });
179
137
  } catch (e) {
180
138
  logErr("scheme-define load", e, { file: fp });
181
139
  }
@@ -266,7 +224,7 @@ function lookup(result: unknown, key: string): unknown {
266
224
  let node: any = result;
267
225
  while (node && node instanceof Pair) {
268
226
  const entry = node.car;
269
- if (entry && entry.car && entry.car.name === key) return entry.cdr;
227
+ if (entry && entry.car && symName(entry.car) === key) return entry.cdr;
270
228
  node = node.cdr;
271
229
  }
272
230
  return undefined;
@@ -310,8 +268,11 @@ function stripPagination(raw: string): string[] {
310
268
 
311
269
  function format(v: unknown): string {
312
270
  if (v === undefined || v === null) return "";
271
+ if (v instanceof LString) v = v.toString();
313
272
  if (typeof v === "string") return JSON.stringify(v);
314
- if (typeof v === "number" || typeof v === "boolean") return String(v);
273
+ // Render JS booleans Scheme-style, matching how #t/#f print inside records.
274
+ if (typeof v === "boolean") return v ? "#t" : "#f";
275
+ if (typeof v === "number") return String(v);
315
276
  if (v && typeof (v as any).toString === "function") {
316
277
  try { return (v as any).toString(); } catch {}
317
278
  }
@@ -319,10 +280,9 @@ function format(v: unknown): string {
319
280
  }
320
281
 
321
282
  // ── evaluator ─────────────────────────────────────────────────────
322
- // LIPS implements string literals via JSON.parse, which rejects backslash
323
- // escapes outside JSON's tiny set (\" \\ \/ \b \f \n \r \t \uXXXX). Models
324
- // routinely write \s \w \d etc. in regex strings. Pre-process: promote any
325
- // invalid \X to \\X so LIPS parses it as a literal backslash + X.
283
+ // LIPS' string lexer accepts only JSON-style escapes (\" \\ \/ \b \f \n \r \t
284
+ // \uXXXX), but models routinely write \s \w \d etc. in regex strings. Promote
285
+ // any other \X to \\X so it parses as a literal backslash + X.
326
286
  function preprocessSchemeSource(source: string): string {
327
287
  const JSON_ESC = new Set(["\\", "/", '"', "b", "f", "n", "r", "t"]);
328
288
  let out = "";
@@ -355,16 +315,16 @@ function preprocessSchemeSource(source: string): string {
355
315
  i++;
356
316
  continue;
357
317
  }
358
- // Any other \X — promote so JSON.parse sees \\X literal
318
+ // Any other \X — promote to \\X so it parses as a literal backslash
359
319
  out += "\\\\" + next;
360
320
  i++;
361
321
  }
362
322
  return out;
363
323
  }
364
324
 
365
- // If LIPS reports a JSON-parse failure, localize the invalid escapes so the
366
- // agent gets actionable line/col info instead of a raw offset. Only triggers
367
- // when preprocessing didn't catch everything.
325
+ // If LIPS rejects a string literal, localize the invalid escapes so the agent
326
+ // gets actionable line/col info instead of a raw offset. Only triggers when
327
+ // preprocessing didn't catch everything.
368
328
  function formatStringEscapeDiagnostic(source: string, baseMsg: string): string {
369
329
  const JSON_ESC = new Set(["\\", "/", '"', "b", "f", "n", "r", "t"]);
370
330
  let line = 1, col = 1;
@@ -474,30 +434,29 @@ function formatParenDiagnostic(source: string, baseMsg: string): string {
474
434
 
475
435
  async function evaluate(env: any, source: string, timeoutMs: number) {
476
436
  const preprocessed = preprocessSchemeSource(source);
477
- // Install a per-eval stdout buffer so (display …) output is captured into
478
- // the result instead of vanishing to console.log. Also override `display`
479
- // to drop LIPS' string-quoting (its default writes `"hello"` with literal
480
- // quote marks; R7RS display should be raw).
481
- const prevStdout = (env as any).get("stdout", { throwError: false });
482
- const prevDisplay = (env as any).get("display", { throwError: false });
437
+ // Capture output into the result instead of letting it vanish to console.log.
438
+ // LIPS 1.0's native display/write resolve the *functions* from the env (the
439
+ // stdout-port override alone misses them), so shadow each output procedure.
440
+ const OUTPUT_PROCS = ["stdout", "display", "write", "write-string", "write-char"];
441
+ const prev: Record<string, any> = {};
442
+ for (const name of OUTPUT_PROCS) prev[name] = (env as any).get(name, { throwError: false });
483
443
  const buf: string[] = [];
484
- (env as any).set("stdout", {
485
- write: (...args: any[]) => {
486
- for (const a of args) buf.push(typeof a === "string" ? a : String(a));
487
- },
488
- });
489
- (env as any).set("display", (...args: any[]) => {
490
- const out = args.map((a) => {
491
- if (a === null || a === undefined) return "";
492
- if (typeof a === "string") return a;
493
- if (a && typeof (a as any).toString === "function") return (a as any).toString();
494
- return String(a);
495
- }).join("");
496
- buf.push(out);
444
+ const raw = (a: any): string => {
445
+ if (a === null || a === undefined) return "";
446
+ if (typeof a === "string") return a;
447
+ if (a && typeof (a as any).toString === "function") return (a as any).toString();
448
+ return String(a);
449
+ };
450
+ (env as any).set("stdout", { write: (...args: any[]) => { for (const a of args) buf.push(raw(a)); } });
451
+ (env as any).set("display", (...args: any[]) => { buf.push(args.map(raw).join("")); });
452
+ (env as any).set("write", (...args: any[]) => {
453
+ buf.push(args.map((a) => (typeof toJsStr(a) === "string" ? JSON.stringify(toJsStr(a)) : raw(a))).join(""));
497
454
  });
455
+ (env as any).set("write-string", (...args: any[]) => { buf.push(raw(args[0])); });
456
+ (env as any).set("write-char", (...args: any[]) => { buf.push(raw(args[0])); });
498
457
  try {
499
458
  const results = await Promise.race<any>([
500
- (lips as any).exec(preprocessed, env),
459
+ (lips as any).exec(preprocessed, { env }),
501
460
  new Promise((_, reject) =>
502
461
  setTimeout(() => reject(new Error(`scheme_eval timed out after ${timeoutMs}ms`)), timeoutMs),
503
462
  ),
@@ -516,22 +475,25 @@ async function evaluate(env: any, source: string, timeoutMs: number) {
516
475
  let msg = e?.message ?? String(e);
517
476
  if (/[Uu]nbalanced parenthes/.test(msg)) {
518
477
  msg = formatParenDiagnostic(source, msg);
519
- } else if (/Bad escaped character in JSON|Unexpected.*JSON|JSON at position/.test(msg)) {
478
+ } else if (/Invalid string literal|Bad escaped character|Unexpected.*JSON|JSON at position/.test(msg)) {
520
479
  msg = formatStringEscapeDiagnostic(source, msg);
480
+ } else if (msg.includes("Unbound variable `#\\")) {
481
+ msg += "\n Unknown character literal. Supported: #\\newline #\\space #\\tab" +
482
+ " #\\return #\\null #\\delete #\\escape, #\\xNN, and #\\<char>.";
521
483
  }
522
484
  return { ok: false as const, error: msg };
523
485
  } finally {
524
- if (prevStdout !== undefined) (env as any).set("stdout", prevStdout);
525
- if (prevDisplay !== undefined) (env as any).set("display", prevDisplay);
486
+ for (const name of OUTPUT_PROCS) {
487
+ if (prev[name] !== undefined) (env as any).set(name, prev[name]);
488
+ }
526
489
  }
527
490
  }
528
491
 
529
492
  // ── standard-library shims ───────────────────────────────────────
530
- // LIPS ships a small subset of R7RS and almost no SRFI-1. Models trained on
531
- // Racket/Chicken/Guile reach for the canonical names (equal?, member, take,
532
- // iota, etc.) and hit "Unbound variable" costing a retry round trip per
533
- // gap. We pre-populate the most common ones so the model's first attempt
534
- // works regardless of which Scheme dialect it learned from.
493
+ // Fill the gaps the bootstrapped std library leaves: SRFI-1 helpers, Racket
494
+ // spellings, and cross-dialect aliases. std covers R7RS base, but a model
495
+ // trained on Racket/Chicken/Guile still reaches for names std doesn't bind.
496
+ // defineIfMissing so anything std already provides wins.
535
497
  function installStdShims(env: any): void {
536
498
  const defineIfMissing = (name: string, fn: any) => {
537
499
  if ((env as any).get(name, { throwError: false }) === undefined) env.set(name, fn);
@@ -543,7 +505,6 @@ function installStdShims(env: any): void {
543
505
  };
544
506
  const truthy = (v: any) => v !== false;
545
507
 
546
- // ── R7RS equality ─────────────────────────────────────────
547
508
  // LIPS wraps numbers as LNumber instances, so `===` fails on equal-valued
548
509
  // numbers from different sources. Handle the wrapper types before recursing.
549
510
  const atomEqual = (a: any, b: any): boolean => {
@@ -551,7 +512,7 @@ function installStdShims(env: any): void {
551
512
  if (a instanceof LNumber && b instanceof LNumber) return a.cmp(b) === 0;
552
513
  if (typeof a === "number" && b instanceof LNumber) return LNumber(a).cmp(b) === 0;
553
514
  if (typeof b === "number" && a instanceof LNumber) return LNumber(b).cmp(a) === 0;
554
- if (a instanceof LSymbol && b instanceof LSymbol) return a.name === b.name;
515
+ if (a instanceof LSymbol && b instanceof LSymbol) return symName(a) === symName(b);
555
516
  return false;
556
517
  };
557
518
  const lipsEqual = (a: any, b: any): boolean => {
@@ -567,41 +528,13 @@ function installStdShims(env: any): void {
567
528
  }
568
529
  return false;
569
530
  };
570
- defineIfMissing("equal?", lipsEqual);
571
- defineIfMissing("eqv?", atomEqual);
572
-
573
- // ── R7RS list/member ─────────────────────────────────────
574
- defineIfMissing("list?", (v: any) => v === nil || v instanceof Pair);
575
- const memberLike = (eq: (a: any, b: any) => boolean) => (item: any, lst: any) => {
576
- let p = lst;
577
- while (p instanceof Pair) {
578
- if (eq(p.car, item)) return p;
579
- p = p.cdr;
580
- }
581
- return false;
582
- };
583
- defineIfMissing("member", memberLike(lipsEqual));
584
- defineIfMissing("memq", memberLike((a, b) => a === b));
585
- defineIfMissing("memv", memberLike((a, b) => a === b));
586
- defineIfMissing("assv", (item: any, lst: any) => {
587
- let p = lst;
588
- while (p instanceof Pair) {
589
- const e = p.car;
590
- if (e instanceof Pair && e.car === item) return e;
591
- p = p.cdr;
592
- }
593
- return false;
594
- });
595
531
 
596
- // ── SRFI-1 list helpers ──────────────────────────────────
597
532
  defineIfMissing("first", (lst: any) => pairToArray(lst)[0]);
598
533
  defineIfMissing("second", (lst: any) => pairToArray(lst)[1]);
599
534
  defineIfMissing("third", (lst: any) => pairToArray(lst)[2]);
600
535
  defineIfMissing("fourth", (lst: any) => pairToArray(lst)[3]);
601
536
  defineIfMissing("fifth", (lst: any) => pairToArray(lst)[4]);
602
537
  defineIfMissing("last", (lst: any) => { const a = pairToArray(lst); return a[a.length - 1]; });
603
- defineIfMissing("take", (lst: any, n: any) => toSchemeList(pairToArray(lst).slice(0, Number(n))));
604
- defineIfMissing("drop", (lst: any, n: any) => toSchemeList(pairToArray(lst).slice(Number(n))));
605
538
  defineIfMissing("take-while", (pred: any, lst: any) => {
606
539
  const out: any[] = [];
607
540
  for (const x of pairToArray(lst)) { if (!truthy(pred(x))) break; out.push(x); }
@@ -619,7 +552,6 @@ function installStdShims(env: any): void {
619
552
  return toSchemeList(Array.from({ length: n }, (_, i) => s + i * k));
620
553
  });
621
554
  defineIfMissing("any", (pred: any, lst: any) => pairToArray(lst).some((x) => truthy(pred(x))));
622
- defineIfMissing("every", (pred: any, lst: any) => pairToArray(lst).every((x) => truthy(pred(x))));
623
555
  defineIfMissing("count", (pred: any, lst: any) => pairToArray(lst).filter((x) => truthy(pred(x))).length);
624
556
  defineIfMissing("filter-map", (f: any, lst: any) => {
625
557
  const out: any[] = [];
@@ -645,44 +577,8 @@ function installStdShims(env: any): void {
645
577
  for (const x of pairToArray(lst)) (truthy(pred(x)) ? t : f).push(x);
646
578
  return new Pair(toSchemeList(t), toSchemeList(f));
647
579
  });
648
- defineIfMissing("fold-right", (f: any, init: any, lst: any) => {
649
- const a = pairToArray(lst); let acc = init;
650
- for (let i = a.length - 1; i >= 0; i--) acc = f(a[i], acc);
651
- return acc;
652
- });
653
- defineIfMissing("zip", (a: any, b: any) => {
654
- const xs = pairToArray(a); const ys = pairToArray(b);
655
- const n = Math.min(xs.length, ys.length);
656
- return toSchemeList(Array.from({ length: n }, (_, i) => toSchemeList([xs[i], ys[i]])));
657
- });
658
-
659
- // ── R7RS numeric predicates / ops ────────────────────────
660
- defineIfMissing("zero?", (n: any) => Number(n) === 0);
661
- defineIfMissing("positive?", (n: any) => Number(n) > 0);
662
- defineIfMissing("negative?", (n: any) => Number(n) < 0);
663
- defineIfMissing("odd?", (n: any) => Math.abs(Number(n)) % 2 === 1);
664
- defineIfMissing("even?", (n: any) => Number(n) % 2 === 0);
665
- defineIfMissing("modulo", (a: any, b: any) => { const m = Number(a) % Number(b); return (m < 0) === (Number(b) < 0) ? m : m + Number(b); });
666
- defineIfMissing("quotient", (a: any, b: any) => Math.trunc(Number(a) / Number(b)));
667
- defineIfMissing("remainder", (a: any, b: any) => Number(a) % Number(b));
668
- defineIfMissing("expt", (a: any, b: any) => Math.pow(Number(a), Number(b)));
669
- defineIfMissing("ceiling", (n: any) => Math.ceil(Number(n)));
670
-
671
- // ── R7RS string ops ──────────────────────────────────────
672
- defineIfMissing("string=?", (a: any, b: any) => String(a) === String(b));
673
- defineIfMissing("string<?", (a: any, b: any) => String(a) < String(b));
674
- defineIfMissing("string>?", (a: any, b: any) => String(a) > String(b));
675
- defineIfMissing("string-upcase", (s: any) => String(s).toUpperCase());
676
- defineIfMissing("string-downcase", (s: any) => String(s).toLowerCase());
677
- defineIfMissing("string->list", (s: any) => toSchemeList(Array.from(String(s))));
678
- defineIfMissing("list->string", (lst: any) => pairToArray(lst).map(String).join(""));
679
- defineIfMissing("string-ref", (s: any, i: any) => {
680
- const str = String(s);
681
- const idx = Math.floor(Number(i));
682
- return idx >= 0 && idx < str.length ? str[idx] : false;
683
- });
580
+
684
581
  defineIfMissing("string-trim-both", (s: any) => String(s).trim());
685
- defineIfMissing("identity", (x: any) => x);
686
582
  // Pattern can be string or (regexp "pat"). Racket (?i:…) / (?m:…) inline
687
583
  // flag groups are translated to JS RegExp flags.
688
584
  const compileRegex = (pat: any): RegExp => {
@@ -698,6 +594,7 @@ function installStdShims(env: any): void {
698
594
  };
699
595
  defineIfMissing("regexp", (pat: any) => compileRegex(pat));
700
596
  const regexpMatch = (pat: any, s: any) => {
597
+ s = toJsStr(s);
701
598
  if (typeof s !== "string") return false;
702
599
  const m = s.match(compileRegex(pat));
703
600
  return m ? toSchemeList(Array.from(m)) : false;
@@ -720,6 +617,7 @@ function installStdShims(env: any): void {
720
617
  return false;
721
618
  });
722
619
  defineIfMissing("regexp-match-positions", (pat: any, s: any) => {
620
+ s = toJsStr(s);
723
621
  if (typeof s !== "string") return false;
724
622
  const m = s.match(compileRegex(pat));
725
623
  if (!m) return false;
@@ -728,101 +626,25 @@ function installStdShims(env: any): void {
728
626
  return new Pair(full, nil);
729
627
  });
730
628
 
731
- // ── Racket spellings ─────────────────────────────────────
732
- defineIfMissing("string-split", (s: any, sep: any) => {
733
- if (typeof s !== "string") return nil;
734
- if (sep === undefined) return toSchemeList(s.split(/\s+/).filter(Boolean));
735
- return toSchemeList(s.split(String(sep)));
736
- });
737
- defineIfMissing("string-join", (lst: any, sep: any) =>
738
- pairToArray(lst).map(String).join(sep === undefined ? " " : String(sep)));
739
629
  defineIfMissing("displayln", function (this: any, x: any) {
740
630
  const display = (env as any).get("display", { throwError: false });
741
631
  if (display) { display(x); display("\n"); }
742
632
  });
743
633
 
744
- // ── R7RS error/exit ──────────────────────────────────────
745
634
  defineIfMissing("error", (...msgs: any[]) => {
746
635
  throw new Error(msgs.map((m) => (typeof m === "string" ? m : String(m))).join(" "));
747
636
  });
748
637
  defineIfMissing("void", () => undefined);
749
638
 
750
- // ── R7RS write (LIPS' display is good enough; write quotes strings) ──
751
639
  defineIfMissing("write", function (this: any, x: any) {
752
640
  const display = (env as any).get("display", { throwError: false });
753
641
  if (display) display(typeof x === "string" ? JSON.stringify(x) : x);
754
642
  });
755
643
 
756
- // ── R7RS numbers (gaps) ────────────────────────────────────
757
- defineIfMissing("abs", (n: any) => Math.abs(Number(n)));
758
- defineIfMissing("floor", (n: any) => Math.floor(Number(n)));
759
- defineIfMissing("round", (n: any) => Math.round(Number(n)));
760
- defineIfMissing("truncate", (n: any) => Math.trunc(Number(n)));
761
- defineIfMissing("sqrt", (n: any) => Math.sqrt(Number(n)));
762
- defineIfMissing("log", (n: any, base: any) =>
763
- base === undefined ? Math.log(Number(n)) : Math.log(Number(n)) / Math.log(Number(base)));
764
- defineIfMissing("exp", (n: any) => Math.exp(Number(n)));
765
- defineIfMissing("sin", (n: any) => Math.sin(Number(n)));
766
- defineIfMissing("cos", (n: any) => Math.cos(Number(n)));
767
- defineIfMissing("tan", (n: any) => Math.tan(Number(n)));
768
- defineIfMissing("asin", (n: any) => Math.asin(Number(n)));
769
- defineIfMissing("acos", (n: any) => Math.acos(Number(n)));
770
- defineIfMissing("atan", (a: any, b: any) =>
771
- b === undefined ? Math.atan(Number(a)) : Math.atan2(Number(a), Number(b)));
772
- defineIfMissing("gcd", (...args: any[]) => {
773
- const gcd2 = (a: number, b: number): number => b === 0 ? Math.abs(a) : gcd2(b, a % b);
774
- return args.length === 0 ? 0 : args.map(Number).reduce(gcd2);
775
- });
776
- defineIfMissing("lcm", (...args: any[]) => {
777
- const gcd2 = (a: number, b: number): number => b === 0 ? Math.abs(a) : gcd2(b, a % b);
778
- const lcm2 = (a: number, b: number): number => a && b ? Math.abs(a * b) / gcd2(a, b) : 0;
779
- return args.length === 0 ? 1 : args.map(Number).reduce(lcm2);
780
- });
781
- defineIfMissing("exact", (n: any) => Math.round(Number(n)));
782
- defineIfMissing("inexact", (n: any) => Number(n));
783
- defineIfMissing("exact->inexact", (n: any) => Number(n));
784
- defineIfMissing("inexact->exact", (n: any) => Math.round(Number(n)));
785
- defineIfMissing("exact-integer?", (n: any) => Number.isInteger(Number(n)));
786
- defineIfMissing("exact?", (n: any) => Number.isInteger(Number(n)));
787
- defineIfMissing("inexact?", (n: any) => !Number.isInteger(Number(n)));
788
- defineIfMissing("=", (...args: any[]) => {
789
- if (args.length < 2) return true;
790
- const first = Number(args[0]);
791
- for (let i = 1; i < args.length; i++) if (Number(args[i]) !== first) return false;
792
- return true;
793
- });
794
- defineIfMissing("finite?", (n: any) => Number.isFinite(Number(n)));
795
- defineIfMissing("infinite?", (n: any) => !Number.isFinite(Number(n)) && !Number.isNaN(Number(n)));
796
- defineIfMissing("nan?", (n: any) => Number.isNaN(Number(n)));
797
644
  defineIfMissing("add1", (n: any) => Number(n) + 1);
798
645
  defineIfMissing("sub1", (n: any) => Number(n) - 1);
799
646
  defineIfMissing("sqr", (n: any) => Number(n) * Number(n));
800
647
 
801
- // ── R7RS predicates ────────────────────────────────────────
802
- defineIfMissing("boolean?", (x: any) => x === true || x === false);
803
- defineIfMissing("boolean=?", (a: any, b: any) => a === b && (a === true || a === false));
804
- defineIfMissing("procedure?", (x: any) => typeof x === "function");
805
- defineIfMissing("symbol?", (x: any) => x instanceof LSymbol);
806
- defineIfMissing("symbol=?", (a: any, b: any) =>
807
- a instanceof LSymbol && b instanceof LSymbol && (a as any).name === (b as any).name);
808
- defineIfMissing("string->symbol", (s: any) => new LSymbol(String(s)));
809
- defineIfMissing("integer?", (x: any) => typeof x === "number" ? Number.isInteger(x)
810
- : (x && typeof x.valueOf === "function" && Number.isInteger(Number(x.valueOf()))));
811
-
812
- // ── R7RS strings (gaps) ────────────────────────────────────
813
- defineIfMissing("substring", (s: any, start: any, end: any) => {
814
- const str = String(s);
815
- const a = Math.max(0, Math.floor(Number(start) || 0));
816
- const b = end === undefined ? str.length : Math.min(str.length, Math.floor(Number(end)));
817
- return str.slice(a, b);
818
- });
819
- defineIfMissing("string-copy", (s: any) => String(s));
820
- defineIfMissing("make-string", (n: any, ch?: any) => {
821
- const len = Math.max(0, Math.floor(Number(n) || 0));
822
- const c = ch === undefined ? " " : String(ch);
823
- return c.length === 1 ? c.repeat(len) : (c + "").repeat(len).slice(0, len);
824
- });
825
- defineIfMissing("string-foldcase", (s: any) => String(s).toLowerCase());
826
648
  defineIfMissing("string-trim", (s: any) => String(s).trim());
827
649
  defineIfMissing("string-trim-left", (s: any) => String(s).replace(/^\s+/, ""));
828
650
  defineIfMissing("string-trim-right", (s: any) => String(s).replace(/\s+$/, ""));
@@ -830,34 +652,15 @@ function installStdShims(env: any): void {
830
652
  String(s).startsWith(String(prefix)));
831
653
  defineIfMissing("string-suffix?", (suffix: any, s: any) =>
832
654
  String(s).endsWith(String(suffix)));
833
- defineIfMissing("non-empty-string?", (x: any) =>
834
- typeof x === "string" && x.length > 0);
655
+ defineIfMissing("non-empty-string?", (x: any) => {
656
+ x = toJsStr(x);
657
+ return typeof x === "string" && x.length > 0;
658
+ });
835
659
  defineIfMissing("string-index", (s: any, needle: any) => {
836
660
  const i = String(s).indexOf(String(needle));
837
661
  return i < 0 ? false : i;
838
662
  });
839
- defineIfMissing("string-ci=?", (a: any, b: any) =>
840
- String(a).toLowerCase() === String(b).toLowerCase());
841
- defineIfMissing("string-ci<?", (a: any, b: any) =>
842
- String(a).toLowerCase() < String(b).toLowerCase());
843
- defineIfMissing("string-ci>?", (a: any, b: any) =>
844
- String(a).toLowerCase() > String(b).toLowerCase());
845
- defineIfMissing("string<=?", (a: any, b: any) => String(a) <= String(b));
846
- defineIfMissing("string>=?", (a: any, b: any) => String(a) >= String(b));
847
-
848
- // ── R7RS / SRFI-1 list gaps ────────────────────────────────
849
- defineIfMissing("list-tail", (lst: any, n: any) => {
850
- let k = Math.floor(Number(n) || 0);
851
- let cur: any = lst;
852
- while (k-- > 0 && cur instanceof Pair) cur = cur.cdr;
853
- return cur;
854
- });
855
- defineIfMissing("list-ref", (lst: any, n: any) => {
856
- let k = Math.floor(Number(n) || 0);
857
- let cur: any = lst;
858
- while (k-- > 0 && cur instanceof Pair) cur = cur.cdr;
859
- return cur instanceof Pair ? cur.car : false;
860
- });
663
+
861
664
  defineIfMissing("list-index", (pred: any, lst: any) => {
862
665
  let i = 0, cur: any = lst;
863
666
  while (cur instanceof Pair) {
@@ -897,13 +700,6 @@ function installStdShims(env: any): void {
897
700
  for (let i = args.length - 2; i >= 0; i--) tail = new Pair(args[i], tail);
898
701
  return tail;
899
702
  });
900
- defineIfMissing("list*", (...args: any[]) => {
901
- if (args.length === 0) return nil;
902
- if (args.length === 1) return args[0];
903
- let tail: any = args[args.length - 1];
904
- for (let i = args.length - 2; i >= 0; i--) tail = new Pair(args[i], tail);
905
- return tail;
906
- });
907
703
  defineIfMissing("append-reverse", (a: any, b: any) => {
908
704
  let cur: any = a, out: any = b;
909
705
  while (cur instanceof Pair) { out = new Pair(cur.car, out); cur = cur.cdr; }
@@ -946,26 +742,6 @@ function installStdShims(env: any): void {
946
742
  }
947
743
  return toSchemeList(out);
948
744
  });
949
- // LIPS' `range` is single-arg only; override (not defineIfMissing) so the
950
- // 1/2/3-arg Racket form wins.
951
- env.set("range", (a: any, b: any, step: any) => {
952
- let start: number, end: number, st: number;
953
- if (b === undefined) { start = 0; end = Number(a); st = 1; }
954
- else { start = Number(a); end = Number(b); st = step === undefined ? 1 : Number(step); }
955
- const out: number[] = [];
956
- if (st > 0) for (let i = start; i < end; i += st) out.push(i);
957
- else if (st < 0) for (let i = start; i > end; i += st) out.push(i);
958
- return toSchemeList(out);
959
- });
960
- defineIfMissing("flatten", (lst: any) => {
961
- const out: any[] = [];
962
- const walk = (x: any) => {
963
- if (x instanceof Pair) { let c: any = x; while (c instanceof Pair) { walk(c.car); c = c.cdr; } }
964
- else if (x !== nil) out.push(x);
965
- };
966
- walk(lst);
967
- return toSchemeList(out);
968
- });
969
745
  defineIfMissing("index-of", (lst: any, x: any) => {
970
746
  let i = 0, cur: any = lst;
971
747
  while (cur instanceof Pair) {
@@ -1017,7 +793,6 @@ function installStdShims(env: any): void {
1017
793
  return toSchemeList(groups.map((g) => toSchemeList(g.items)));
1018
794
  });
1019
795
 
1020
- // ── Regex (Racket) ─────────────────────────────────────────
1021
796
  const reCompile = (pat: any): RegExp => {
1022
797
  if (pat instanceof RegExp) return pat;
1023
798
  let p = String(pat);
@@ -1031,10 +806,12 @@ function installStdShims(env: any): void {
1031
806
  };
1032
807
  defineIfMissing("regexp?", (x: any) => x instanceof RegExp);
1033
808
  defineIfMissing("regexp-replace", (pat: any, s: any, repl: any) => {
809
+ s = toJsStr(s);
1034
810
  if (typeof s !== "string") return s;
1035
811
  return s.replace(reCompile(pat), String(repl));
1036
812
  });
1037
813
  defineIfMissing("regexp-replace*", (pat: any, s: any, repl: any) => {
814
+ s = toJsStr(s);
1038
815
  if (typeof s !== "string") return s;
1039
816
  const re = reCompile(pat);
1040
817
  const global = re.flags.includes("g") ? re : new RegExp(re.source, re.flags + "g");
@@ -1042,10 +819,11 @@ function installStdShims(env: any): void {
1042
819
  });
1043
820
  defineIfMissing("regexp-quote", (s: any) =>
1044
821
  String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
1045
- defineIfMissing("regexp-split", (pat: any, s: any) =>
1046
- typeof s === "string" ? toSchemeList(s.split(reCompile(pat))) : nil);
822
+ defineIfMissing("regexp-split", (pat: any, s: any) => {
823
+ s = toJsStr(s);
824
+ return typeof s === "string" ? toSchemeList(s.split(reCompile(pat))) : nil;
825
+ });
1047
826
 
1048
- // ── Format (Racket) ────────────────────────────────────────
1049
827
  // format: simple ~a ~s ~v ~n support — covers most logging/inspection
1050
828
  defineIfMissing("format", (fmt: any, ...rest: any[]) => {
1051
829
  const f = String(fmt);
@@ -1074,7 +852,6 @@ function installStdShims(env: any): void {
1074
852
  defineIfMissing("~v", (...xs: any[]) =>
1075
853
  xs.map((x) => typeof x === "string" ? JSON.stringify(x) : (x === undefined ? "" : x.toString())).join(""));
1076
854
 
1077
- // ── Hash tables (Racket) ───────────────────────────────────
1078
855
  // Backed by JS Map. Stored as `LipsHash` symbol so we can pattern-match.
1079
856
  class LipsHash {
1080
857
  map: Map<any, any> = new Map();
@@ -1082,7 +859,7 @@ function installStdShims(env: any): void {
1082
859
  if (entries) for (const [k, v] of entries) this.map.set(this._key(k), v);
1083
860
  }
1084
861
  _key(k: any): any {
1085
- if (k instanceof LSymbol) return "::sym::" + (k as any).name;
862
+ if (k instanceof LSymbol) return "::sym::" + symName(k);
1086
863
  if (typeof k === "object" && k !== null) return JSON.stringify(k);
1087
864
  return k;
1088
865
  }
@@ -1139,20 +916,16 @@ function installStdShims(env: any): void {
1139
916
  defineIfMissing("hash-values", (h: any) => h instanceof LipsHash ? toSchemeList(h.values()) : nil);
1140
917
  defineIfMissing("hash-count", (h: any) => h instanceof LipsHash ? h.size() : 0);
1141
918
 
1142
- // ── Sort (R7RS-large / SRFI-132 / Racket) ──────────────────
1143
919
  // V8's Array.prototype.sort is stable (ES2019), so one impl serves all.
1144
920
  const sortImpl = (lst: any, less: any) => {
1145
921
  const arr = pairToArray(lst).slice();
1146
922
  arr.sort((a, b) => (truthy(less(a, b)) ? -1 : truthy(less(b, a)) ? 1 : 0));
1147
923
  return toSchemeList(arr);
1148
924
  };
1149
- defineIfMissing("sort", sortImpl);
1150
925
  defineIfMissing("sort!", sortImpl);
1151
926
  // SRFI-132 / R7RS-large flips the argument order.
1152
927
  defineIfMissing("list-sort", (less: any, lst: any) => sortImpl(lst, less));
1153
928
 
1154
- // ── Racket list aliases & gaps ─────────────────────────────
1155
- defineIfMissing("empty?", (v: any) => v === nil);
1156
929
  defineIfMissing("empty", nil);
1157
930
  defineIfMissing("cons?", (v: any) => v instanceof Pair);
1158
931
  defineIfMissing("andmap", (pred: any, lst: any) => pairToArray(lst).every((x) => truthy(pred(x))));
@@ -1170,11 +943,6 @@ function installStdShims(env: any): void {
1170
943
  }
1171
944
  return false;
1172
945
  });
1173
- defineIfMissing("make-list", (n: any, v: any) => {
1174
- const count = Math.max(0, Math.floor(Number(n) || 0));
1175
- const fill = v === undefined ? nil : v;
1176
- return toSchemeList(Array(count).fill(fill));
1177
- });
1178
946
  defineIfMissing("build-list", (n: any, proc: any) => {
1179
947
  const count = Math.max(0, Math.floor(Number(n) || 0));
1180
948
  const out: any[] = [];
@@ -1196,14 +964,6 @@ function installStdShims(env: any): void {
1196
964
  const k = Math.max(0, Math.floor(Number(n) || 0));
1197
965
  return new Pair(toSchemeList(a.slice(0, k)), toSchemeList(a.slice(k)));
1198
966
  });
1199
- defineIfMissing("shuffle", (lst: any) => {
1200
- const a = pairToArray(lst).slice();
1201
- for (let i = a.length - 1; i > 0; i--) {
1202
- const j = Math.floor(Math.random() * (i + 1));
1203
- [a[i], a[j]] = [a[j], a[i]];
1204
- }
1205
- return toSchemeList(a);
1206
- });
1207
967
  defineIfMissing("add-between", (lst: any, sep: any) => {
1208
968
  const a = pairToArray(lst);
1209
969
  if (a.length < 2) return toSchemeList(a);
@@ -1325,16 +1085,8 @@ function installStdShims(env: any): void {
1325
1085
  return toSchemeList(arr.filter((x) => !targets.some((t) => lipsEqual(t, x))));
1326
1086
  });
1327
1087
 
1328
- // ── Racket numbers (gaps) ──────────────────────────────────
1329
1088
  defineIfMissing("pi", Math.PI);
1330
1089
  // Racket overloads: (random) 0≤x<1, (random k) 0≤i<k, (random lo hi).
1331
- defineIfMissing("random", (a: any, b: any) => {
1332
- if (a === undefined) return Math.random();
1333
- if (b === undefined) return Math.floor(Math.random() * Math.max(0, Math.floor(Number(a))));
1334
- const lo = Math.floor(Number(a));
1335
- const hi = Math.floor(Number(b));
1336
- return lo + Math.floor(Math.random() * Math.max(0, hi - lo));
1337
- });
1338
1090
  defineIfMissing("exact-floor", (n: any) => Math.floor(Number(n)));
1339
1091
  defineIfMissing("exact-ceiling", (n: any) => Math.ceil(Number(n)));
1340
1092
  defineIfMissing("exact-round", (n: any) => Math.round(Number(n)));
@@ -1353,7 +1105,6 @@ function installStdShims(env: any): void {
1353
1105
  return Number(n).toFixed(d);
1354
1106
  });
1355
1107
 
1356
- // ── Racket strings & chars (gaps) ──────────────────────────
1357
1108
  defineIfMissing("string-titlecase", (s: any) =>
1358
1109
  String(s).replace(/\b([a-z])/g, (_, c) => c.toUpperCase()));
1359
1110
  defineIfMissing("string-pad", (s: any, width: any, ch?: any) => {
@@ -1368,8 +1119,6 @@ function installStdShims(env: any): void {
1368
1119
  const c = ch === undefined ? " " : String(ch).charAt(0) || " ";
1369
1120
  return str.length >= w ? str : str + c.repeat(w - str.length);
1370
1121
  });
1371
- defineIfMissing("char-upcase", (c: any) => String(c).toUpperCase());
1372
- defineIfMissing("char-downcase", (c: any) => String(c).toLowerCase());
1373
1122
  defineIfMissing("string-normalize-spaces", (s: any, sep?: any, repl?: any) => {
1374
1123
  const str = String(s).trim();
1375
1124
  const splitOn = sep === undefined ? /\s+/ : (sep instanceof RegExp ? sep : new RegExp(String(sep)));
@@ -1383,7 +1132,6 @@ function installStdShims(env: any): void {
1383
1132
  return out;
1384
1133
  });
1385
1134
 
1386
- // ── Racket hash (gaps) ─────────────────────────────────────
1387
1135
  defineIfMissing("hash-update!", (h: any, k: any, upd: any, dflt: any) => {
1388
1136
  if (!(h instanceof LipsHash)) return h;
1389
1137
  const cur = h.has(k) ? h.get(k) : (typeof dflt === "function" ? dflt() : dflt);
@@ -1462,14 +1210,12 @@ function installStdShims(env: any): void {
1462
1210
  defineIfMissing("make-hasheqv", (alist?: any) => (env as any).get("make-hash")(alist));
1463
1211
  defineIfMissing("make-immutable-hash", (alist?: any) => (env as any).get("make-hash")(alist));
1464
1212
 
1465
- // ── Racket boxes (mutable cells) ───────────────────────────
1466
1213
  class LipsBox { constructor(public v: any) {} }
1467
1214
  defineIfMissing("box", (v: any) => new LipsBox(v));
1468
1215
  defineIfMissing("box?", (x: any) => x instanceof LipsBox);
1469
1216
  defineIfMissing("unbox", (b: any) => b instanceof LipsBox ? b.v : b);
1470
1217
  defineIfMissing("set-box!", (b: any, v: any) => { if (b instanceof LipsBox) b.v = v; return undefined; });
1471
1218
 
1472
- // ── Environment introspection ──────────────────────────────
1473
1219
  const collectEnvNames = (): string[] => {
1474
1220
  const seen = new Set<string>();
1475
1221
  let cur: any = env;
@@ -1481,28 +1227,26 @@ function installStdShims(env: any): void {
1481
1227
  return Array.from(seen).sort();
1482
1228
  };
1483
1229
  defineIfMissing("defined?", (sym: any) => {
1484
- const name = sym instanceof LSymbol ? (sym as any).name : String(sym);
1230
+ const name = sym instanceof LSymbol ? symName(sym) : String(sym);
1485
1231
  return (env as any).get(name, { throwError: false }) !== undefined;
1486
1232
  });
1487
1233
  const aproposImpl = (pat: any) => {
1488
- const needle = pat instanceof LSymbol ? (pat as any).name : String(pat ?? "");
1234
+ const needle = pat instanceof LSymbol ? (symName(pat) ?? "") : String(pat ?? "");
1489
1235
  const re = pat instanceof RegExp ? pat : null;
1490
1236
  const match = (n: string) => re ? re.test(n) : n.includes(needle);
1491
1237
  return toSchemeList(collectEnvNames().filter(match).map((n) => new LSymbol(n)));
1492
1238
  };
1493
- defineIfMissing("apropos", aproposImpl);
1494
1239
  defineIfMissing("apropos-list", aproposImpl);
1495
1240
 
1496
- // ── Misc Racket ────────────────────────────────────────────
1497
1241
  defineIfMissing("current-seconds", () => Math.floor(Date.now() / 1000));
1498
1242
  defineIfMissing("current-milliseconds", () => Date.now());
1499
1243
  defineIfMissing("current-inexact-milliseconds", () => performance.now());
1500
1244
  }
1501
1245
 
1502
1246
  // Canonical names we claim coverage for. R = R7RS small base; S = SRFI-1;
1503
- // K = Racket racket/base/list/string/format. Continuations, ports, bytevectors,
1504
- // and char predicates are intentionally omitted — they'd be misleading
1505
- // "coverage" without real functionality.
1247
+ // K = Racket racket/base/list/string/format. Continuations, ports, and
1248
+ // bytevectors are intentionally omitted — they'd be misleading "coverage"
1249
+ // without real functionality.
1506
1250
  const COVERAGE_CHECKLIST: string[] = [
1507
1251
  // R7RS § 6.1 equivalence
1508
1252
  "eq?", "eqv?", "equal?",
@@ -1584,6 +1328,10 @@ const COVERAGE_CHECKLIST: string[] = [
1584
1328
  // Racket strings & chars (gaps)
1585
1329
  "string-titlecase", "string-pad", "string-pad-right",
1586
1330
  "char-upcase", "char-downcase",
1331
+ "char->integer", "integer->char", "char?",
1332
+ "char=?", "char<?", "char>?", "char<=?", "char>=?", "char-ci=?",
1333
+ "char-alphabetic?", "char-numeric?", "char-whitespace?",
1334
+ "char-upper-case?", "char-lower-case?", "digit-value",
1587
1335
  "string-normalize-spaces", "build-string",
1588
1336
  // Racket hash
1589
1337
  "make-hash", "hash", "hash?", "hash-ref", "hash-set!", "hash-set",
@@ -1619,7 +1367,7 @@ function auditShimCoverage(env: any): { defined: number; missing: string[] } {
1619
1367
  function unwrapSchemeBool(v: any): any {
1620
1368
  if (v === true || v === false) return v;
1621
1369
  if (v instanceof LSymbol) {
1622
- const n = (v as any).name;
1370
+ const n = symName(v);
1623
1371
  if (n === "#t") return true;
1624
1372
  if (n === "#f") return false;
1625
1373
  }
@@ -1628,32 +1376,82 @@ function unwrapSchemeBool(v: any): any {
1628
1376
  return v;
1629
1377
  }
1630
1378
 
1631
- // Convert a Scheme alist ((kebab-key . val) …) into the JS object shape
1632
- // the underlying tool expects. Renames keys (kebab→snake), coerces numeric
1633
- // values, and normalizes booleans. Returns {} for non-Pair input.
1634
- function readOptions(
1635
- value: any,
1636
- keyMap: Record<string, string>,
1637
- numericKeys?: Set<string>,
1638
- ): Record<string, unknown> {
1639
- const out: Record<string, unknown> = {};
1640
- if (!(value instanceof Pair)) return out;
1641
- let node: any = value;
1642
- while (node instanceof Pair) {
1643
- const entry = node.car;
1644
- if (entry instanceof Pair && entry.car instanceof LSymbol) {
1645
- const k = (entry.car as any).name;
1646
- const tgt = keyMap[k];
1647
- if (tgt !== undefined) {
1648
- let v: any = entry.cdr;
1649
- if (numericKeys && numericKeys.has(tgt)) v = Number(v);
1650
- else v = unwrapSchemeBool(v);
1651
- out[tgt] = v;
1379
+ // Single source of truth for the host primitive surface: drives both the tool
1380
+ // description and `(help …)`, so what the model reads matches what it can
1381
+ // introspect at runtime. Each binding returns the natural Scheme value for its
1382
+ // job; bash returns a record because the exit code has nowhere else to live (a
1383
+ // plain bash tool call drops it before the model ever sees it).
1384
+ type HostSig = { name: string; sig: string; ret: string; doc: string };
1385
+ const HOST_SIGS: HostSig[] = [
1386
+ { name: "bash", sig: '(bash "cmd" [timeout-sec])',
1387
+ ret: "((output . str) (exit-code . n) (error . bool))",
1388
+ doc: "run a shell command; full result. Accessors: output-of exit-code-of ok? error?" },
1389
+ { name: "sh", sig: '(sh "cmd" [timeout-sec])', ret: "str",
1390
+ doc: "run a shell command, return stdout only (stderr text on failure)" },
1391
+ { name: "read-file", sig: '(read-file "path" [offset] [limit])', ret: "str | #f",
1392
+ doc: "file contents, or #f on error. offset is 1-indexed; limit caps lines" },
1393
+ { name: "write-file", sig: '(write-file "path" "content")', ret: "#t | err-str",
1394
+ doc: "overwrite a file" },
1395
+ { name: "edit-file", sig: '(edit-file "path" "old" "new" [replace-all])', ret: "#t | err-str",
1396
+ doc: "replace exact text; pass #t to replace every occurrence" },
1397
+ { name: "grep", sig: '(grep "pat" ["dir"] [:opt val …])',
1398
+ ret: "(listof ((file . str) (line . n) (text . str)))",
1399
+ doc: "ripgrep search. options: :include :case-insensitive :context-before :context-after :limit :offset" },
1400
+ { name: "grep-files", sig: '(grep-files "pat" ["dir"] [:opt val …])', ret: "(listof str)",
1401
+ doc: "files containing a match. options: :include :case-insensitive :limit :offset" },
1402
+ { name: "glob", sig: '(glob "pat" ["dir"])', ret: "(listof str)",
1403
+ doc: "paths matching a glob, mtime-sorted" },
1404
+ ];
1405
+ const sigLine = (h: HostSig): string => `${h.sig} → ${h.ret}`;
1406
+ const sigForName = (name: string): string => {
1407
+ const h = HOST_SIGS.find((s) => s.name === name);
1408
+ return h ? sigLine(h) : name;
1409
+ };
1410
+
1411
+ // Append a primitive's signature to any exception it throws, so a malformed
1412
+ // call teaches the right shape in one round-trip instead of a bare stack.
1413
+ function withSig(name: string, fn: (...a: any[]) => Promise<any>) {
1414
+ return async (...a: any[]) => {
1415
+ try {
1416
+ return await fn(...a);
1417
+ } catch (e: any) {
1418
+ const base = e?.message ?? String(e);
1419
+ if (!String(base).includes("signature:")) {
1420
+ try { e.message = `${base}\n signature: ${sigForName(name)}`; } catch { /* frozen */ }
1652
1421
  }
1422
+ throw e;
1653
1423
  }
1654
- node = node.cdr;
1424
+ };
1425
+ }
1426
+
1427
+ const isKwSym = (x: any): boolean => {
1428
+ const n = symName(x);
1429
+ return typeof n === "string" && n.startsWith(":");
1430
+ };
1431
+
1432
+ // Split a host primitive's evaluated arg list into leading positionals and a
1433
+ // trailing :key value option map. LIPS has no keyword args, so the grep macros
1434
+ // quote leading-colon symbols (otherwise they'd be looked up as unbound
1435
+ // variables); they arrive here as plain LSymbols named ":foo".
1436
+ function splitArgs(
1437
+ args: any[], keyMap: Record<string, string>, numericKeys?: Set<string>,
1438
+ ): { positionals: any[]; opts: Record<string, unknown> } {
1439
+ const positionals: any[] = [];
1440
+ let i = 0;
1441
+ while (i < args.length && !isKwSym(args[i])) { positionals.push(args[i]); i++; }
1442
+ const opts: Record<string, unknown> = {};
1443
+ while (i < args.length) {
1444
+ if (!isKwSym(args[i])) { i++; continue; }
1445
+ const tgt = keyMap[symName(args[i])!.slice(1)];
1446
+ if (tgt !== undefined) {
1447
+ const raw = args[i + 1];
1448
+ opts[tgt] = numericKeys && numericKeys.has(tgt)
1449
+ ? Number(raw)
1450
+ : unwrapSchemeBool(toJsStr(raw));
1451
+ }
1452
+ i += 2;
1655
1453
  }
1656
- return out;
1454
+ return { positionals, opts };
1657
1455
  }
1658
1456
 
1659
1457
  function resolveExecutor(ctx: AgentContext, name: string): ToolExecutor {
@@ -1673,54 +1471,44 @@ function installBindings(
1673
1471
  glob: ToolExecutor | null,
1674
1472
  ): void {
1675
1473
  const runBash = async (command: string, timeoutSec?: number) => {
1676
- const args: Record<string, unknown> = { command };
1474
+ const args: Record<string, unknown> = { command: toJsStr(command) };
1677
1475
  if (typeof timeoutSec === "number") args.timeout = timeoutSec;
1678
1476
  const result = await bash(args);
1679
- let content = typeof result.content === "string" ? result.content : String(result.content ?? "");
1477
+ let output = typeof result.content === "string" ? result.content : String(result.content ?? "");
1680
1478
  // Undo bash.ts's "(no output)" sentinel so `(eq? out "")` works.
1681
- if (content === "(no output)") content = "";
1682
- return {
1683
- exitCode: result.exitCode ?? (result.isError ? 1 : 0),
1684
- stdout: content,
1685
- stderr: result.isError ? content : "",
1686
- success: !result.isError,
1687
- };
1479
+ if (output === "(no output)") output = "";
1480
+ // stdout and stderr are merged upstream — the bash tool surfaces only a
1481
+ // combined `content` so the record exposes one `output`, not a fabricated
1482
+ // split. `exit-code` is the real shell code and the only channel that
1483
+ // carries it: a plain bash tool call drops it before the model.
1484
+ return { exitCode: result.exitCode ?? (result.isError ? 1 : 0), output, error: result.isError };
1688
1485
  };
1689
- env.set("bash", async (command: string, timeoutSec?: number) => {
1486
+ env.set("bash", withSig("bash", async (command: string, timeoutSec?: number) => {
1690
1487
  try {
1691
1488
  const r = await runBash(command, timeoutSec);
1692
1489
  return alist([
1490
+ ["output", r.output],
1693
1491
  ["exit-code", r.exitCode],
1694
- ["stdout", r.stdout],
1695
- ["stderr", r.stderr],
1696
- ["success", r.success],
1492
+ ["error", r.error],
1697
1493
  ]);
1698
1494
  } catch (e: any) {
1699
1495
  logErr("bash", e, { command, typeofCommand: typeof command });
1700
1496
  throw e;
1701
1497
  }
1702
- });
1703
- // Shortcut: return stdout as string. Use `bash` when you need exit-code/stderr.
1704
- env.set("sh", async (command: string, timeoutSec?: number) => {
1498
+ }));
1499
+ // Shortcut: stdout as a string. Use `bash` when you need the exit code, or
1500
+ // `(ok? (bash "…"))` for a success predicate.
1501
+ env.set("sh", withSig("sh", async (command: string, timeoutSec?: number) => {
1705
1502
  try {
1706
- const r = await runBash(command, timeoutSec);
1707
- return r.stdout;
1503
+ return (await runBash(command, timeoutSec)).output;
1708
1504
  } catch (e: any) {
1709
1505
  logErr("sh", e, { command });
1710
1506
  return "";
1711
1507
  }
1712
- });
1713
- env.set("sh-ok?", async (command: string, timeoutSec?: number) => {
1714
- try {
1715
- const r = await runBash(command, timeoutSec);
1716
- return r.success;
1717
- } catch {
1718
- return false;
1719
- }
1720
- });
1508
+ }));
1721
1509
 
1722
- env.set("read-file", async (filePath: string, offset?: any, limit?: any) => {
1723
- const args: Record<string, unknown> = { path: filePath, bypass_cache: true };
1510
+ env.set("read-file", withSig("read-file", async (filePath: string, offset?: any, limit?: any) => {
1511
+ const args: Record<string, unknown> = { path: toJsStr(filePath), bypass_cache: true };
1724
1512
  if (offset !== undefined && offset !== null) {
1725
1513
  const n = Number(offset);
1726
1514
  if (!isNaN(n)) args.offset = n;
@@ -1731,19 +1519,21 @@ function installBindings(
1731
1519
  }
1732
1520
  const result = await readFile(args);
1733
1521
  return result.isError ? false : result.content;
1734
- });
1522
+ }));
1735
1523
 
1736
- env.set("write-file", async (filePath: string, content: string) => {
1524
+ env.set("write-file", withSig("write-file", async (filePath: string, content: string) => {
1525
+ filePath = toJsStr(filePath); content = toJsStr(content);
1737
1526
  // Re-emit tool lifecycle events so the TUI shows diffs.
1738
1527
  const result = await withDisplay(
1739
1528
  bus, "write_file", "write", { path: filePath, content }, filePath,
1740
1529
  () => writeFile({ path: filePath, content }),
1741
1530
  );
1742
1531
  return result.isError ? result.content : true;
1743
- });
1532
+ }));
1744
1533
 
1745
1534
  if (editFile) {
1746
- env.set("edit-file", async (filePath: string, oldStr: string, newStr: string, replaceAll?: any) => {
1535
+ env.set("edit-file", withSig("edit-file", async (filePath: string, oldStr: string, newStr: string, replaceAll?: any) => {
1536
+ filePath = toJsStr(filePath); oldStr = toJsStr(oldStr); newStr = toJsStr(newStr);
1747
1537
  const toolArgs: Record<string, unknown> = { path: filePath, old_text: oldStr, new_text: newStr };
1748
1538
  if (unwrapSchemeBool(replaceAll) === true) toolArgs.replace_all = true;
1749
1539
  const result = await withDisplay(
@@ -1751,18 +1541,18 @@ function installBindings(
1751
1541
  () => editFile(toolArgs),
1752
1542
  );
1753
1543
  return result.isError ? result.content : true;
1754
- });
1544
+ }));
1755
1545
  }
1756
1546
 
1757
1547
  if (grep) {
1758
- const GREP_KEYMAP = {
1548
+ const GREP_KEYMAP: Record<string, string> = {
1759
1549
  "include": "include",
1760
1550
  "case-insensitive": "case_insensitive",
1761
1551
  "context-before": "context_before",
1762
1552
  "context-after": "context_after",
1763
1553
  "limit": "head_limit",
1764
1554
  "offset": "offset",
1765
- } as const;
1555
+ };
1766
1556
  const GREP_NUMERIC = new Set([
1767
1557
  "context_before", "context_after", "head_limit", "offset",
1768
1558
  ]);
@@ -1779,93 +1569,96 @@ function installBindings(
1779
1569
  .replace(/\\\+/g, "+").replace(/\\\?/g, "?");
1780
1570
  };
1781
1571
 
1782
- env.set("%grep", async (pattern: string, p?: string, third?: any) => {
1783
- const args: Record<string, unknown> = { pattern: normalizePattern(String(pattern ?? "")), output_mode: "content" };
1784
- if (typeof p === "string") args.path = p;
1785
- if (third instanceof Pair) {
1786
- Object.assign(args, readOptions(third, GREP_KEYMAP, GREP_NUMERIC));
1787
- } else if (third !== undefined && third !== null) {
1788
- // Back-compat: third positional arg as numeric limit.
1789
- const n = Number(third);
1790
- if (!isNaN(n)) args.head_limit = n;
1791
- }
1572
+ // pattern [path] are positional; everything after is :key value options,
1573
+ // split out by splitArgs (the grep macro quotes the colon symbols).
1574
+ env.set("%grep", withSig("grep", async (...rest: any[]) => {
1575
+ const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1576
+ const pStr = toJsStr(positionals[1]);
1577
+ const args: Record<string, unknown> = {
1578
+ pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "content", ...opts,
1579
+ };
1580
+ if (typeof pStr === "string") args.path = pStr;
1792
1581
  const result = await grep(args);
1793
1582
  if (result.isError) return nil;
1794
1583
  if (result.content === "No matches found.") return nil;
1795
1584
  const rows: unknown[] = [];
1796
- for (const line of stripPagination(result.content)) {
1797
- const parsed = parseGrepLine(line, typeof p === "string" ? p : undefined);
1585
+ for (const line of stripPagination(result.content as string)) {
1586
+ const parsed = parseGrepLine(line, typeof pStr === "string" ? pStr : undefined);
1798
1587
  if (parsed) rows.push(parsed);
1799
1588
  }
1800
1589
  return toSchemeList(rows);
1801
- });
1590
+ }));
1802
1591
 
1803
- env.set("%grep-files", async (pattern: string, p?: string, opts?: any) => {
1804
- const args: Record<string, unknown> = { pattern: normalizePattern(String(pattern ?? "")), output_mode: "files_with_matches" };
1805
- if (typeof p === "string") args.path = p;
1806
- if (opts instanceof Pair) Object.assign(args, readOptions(opts, GREP_KEYMAP, GREP_NUMERIC));
1592
+ env.set("%grep-files", withSig("grep-files", async (...rest: any[]) => {
1593
+ const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1594
+ const pStr = toJsStr(positionals[1]);
1595
+ const args: Record<string, unknown> = {
1596
+ pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "files_with_matches", ...opts,
1597
+ };
1598
+ if (typeof pStr === "string") args.path = pStr;
1807
1599
  const result = await grep(args);
1808
1600
  if (result.isError || result.content === "No matches found.") return nil;
1809
- return toSchemeList(stripPagination(result.content));
1810
- });
1601
+ return toSchemeList(stripPagination(result.content as string));
1602
+ }));
1811
1603
  }
1812
1604
 
1813
1605
  if (glob) {
1814
1606
  // Strip leading "./" so glob paths match grep's — otherwise eq? on the
1815
1607
  // file field fails across the two.
1816
- env.set("glob", async (pattern: string, p?: string) => {
1817
- const args: Record<string, unknown> = { pattern };
1818
- if (typeof p === "string") args.path = p;
1608
+ env.set("glob", withSig("glob", async (pattern: string, p?: string) => {
1609
+ const pStr = toJsStr(p);
1610
+ const args: Record<string, unknown> = { pattern: toJsStr(pattern) };
1611
+ if (typeof pStr === "string") args.path = pStr;
1819
1612
  const result = await glob(args);
1820
1613
  if (result.isError || result.content === "No files matched.") return nil;
1821
- const paths = stripPagination(result.content).map((l) =>
1614
+ const paths = stripPagination(result.content as string).map((l) =>
1822
1615
  l.startsWith("./") ? l.slice(2) : l,
1823
1616
  );
1824
1617
  return toSchemeList(paths);
1825
- });
1618
+ }));
1826
1619
  }
1827
1620
 
1828
- // Shell-result accessors — JS-side so they're never missing.
1621
+ // Accessors on a bash result — JS-side so they're never missing.
1622
+ env.set("output-of", (r: unknown) => lookup(r, "output"));
1829
1623
  env.set("exit-code-of", (r: unknown) => lookup(r, "exit-code"));
1830
- env.set("stdout-of", (r: unknown) => lookup(r, "stdout"));
1831
- env.set("stderr-of", (r: unknown) => lookup(r, "stderr"));
1832
- env.set("success?", (r: unknown) => lookup(r, "success") === true);
1624
+ env.set("error?", (r: unknown) => lookup(r, "error") === true);
1625
+ env.set("ok?", (r: unknown) => lookup(r, "error") === false);
1626
+
1627
+ // Runtime discovery: (help) lists available host primitives; (help 'grep)
1628
+ // shows one. Filtered to what actually got bound this session.
1629
+ const availableNames = new Set<string>(["bash", "sh", "read-file", "write-file"]);
1630
+ if (editFile) availableNames.add("edit-file");
1631
+ if (grep) { availableNames.add("grep"); availableNames.add("grep-files"); }
1632
+ if (glob) availableNames.add("glob");
1633
+ const availableSigs = HOST_SIGS.filter((h) => availableNames.has(h.name));
1634
+ env.set("help", (name?: any) => {
1635
+ if (name === undefined || name === null) {
1636
+ return availableSigs.map(sigLine).join("\n");
1637
+ }
1638
+ const key = String(name instanceof LSymbol ? symName(name) : toJsStr(name)).replace(/^:/, "");
1639
+ const h = availableSigs.find((s) => s.name === key);
1640
+ return h ? `${sigLine(h)}\n ${h.doc}` : `no host primitive named ${key}; try (help) for the list`;
1641
+ });
1833
1642
 
1834
1643
  // R7RS / string helpers LIPS doesn't ship.
1835
- env.set("string-length", (s: unknown) => (typeof s === "string" ? s.length : 0));
1836
- const stringContains = (s: unknown, needle: unknown) =>
1837
- typeof s === "string" && typeof needle === "string" && s.includes(needle);
1644
+ const stringContains = (s: unknown, needle: unknown) => {
1645
+ s = toJsStr(s); needle = toJsStr(needle);
1646
+ return typeof s === "string" && typeof needle === "string" && s.includes(needle);
1647
+ };
1838
1648
  env.set("string-contains?", stringContains);
1839
1649
  // Racket spells it without the `?`. Bind both so the model isn't punished
1840
1650
  // for guessing dialect.
1841
1651
  env.set("string-contains", stringContains);
1842
- env.set("string-append", (...parts: unknown[]) =>
1843
- parts.map((p) => (p === undefined || p === null ? "" : String(p))).join(""));
1844
- env.set("number->string", (n: unknown) => String(n));
1845
- env.set("string->number", (s: unknown) => {
1846
- if (typeof s !== "string") return false;
1847
- const n = Number(s);
1848
- return Number.isNaN(n) ? false : n;
1849
- });
1850
- env.set("symbol->string", (sym: any) => (sym && sym.name) ? sym.name : String(sym));
1851
- // LIPS doesn't ship max/min — useful enough that not having them breaks
1852
- // common idioms like `(max 1 (- n 5))` for line-bound clamping.
1853
- env.set("max", (...args: any[]) => args.reduce((a, b) => (Number(a) >= Number(b) ? a : b)));
1854
- env.set("min", (...args: any[]) => args.reduce((a, b) => (Number(a) <= Number(b) ? a : b)));
1855
- installStdShims(env);
1856
- // Global string substitution — LIPS' built-in `replace` with a string
1857
- // pattern only replaces the first match (it calls JS String.replace).
1858
- // This binding replaces every occurrence, sed-style.
1652
+ // Global string substitution: `replace` with a string pattern replaces only
1653
+ // the first match; this binding replaces every occurrence, sed-style.
1859
1654
  env.set("string-replace", (oldStr: unknown, newStr: unknown, s: unknown) => {
1655
+ s = toJsStr(s);
1860
1656
  if (typeof s !== "string") return s;
1861
1657
  return s.split(String(oldStr ?? "")).join(String(newStr ?? ""));
1862
1658
  });
1863
1659
 
1864
- // LIPS' tokenizer doesn't recognize R7RS `#t`/`#f` — they parse as
1865
- // unbound symbols. Bind them so the natural R7RS reflex works.
1866
- env.set("#t", true);
1867
- env.set("#f", false);
1868
1660
  env.set("lines", (s: unknown) => {
1661
+ s = toJsStr(s);
1869
1662
  if (typeof s !== "string" || s.length === 0) return nil;
1870
1663
  const parts = s.split("\n");
1871
1664
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
@@ -1876,98 +1669,63 @@ function installBindings(
1876
1669
  }
1877
1670
 
1878
1671
  // ── tool registration ─────────────────────────────────────────────
1672
+ // Generated from HOST_SIGS so the catalog the model reads matches what
1673
+ // `(help …)` reports at runtime.
1674
+ const HOST_BINDINGS_BLOCK = HOST_SIGS.map(
1675
+ (h) => ` ${h.sig.padEnd(44)} → ${h.ret}\n ${h.doc}`,
1676
+ ).join("\n");
1677
+
1879
1678
  const DESCRIPTION = [
1880
1679
  "Evaluate a Scheme expression (R7RS-compatible).",
1881
1680
  "",
1882
- "A Scheme runtime with host bindings to the shell, filesystem, search,",
1883
- "and file editing. Each submission is parsed and evaluated against an",
1884
- "environment that persists across calls within the session — `define`s",
1885
- "in one submission are available in the next.",
1681
+ "A Scheme runtime with host bindings to the shell, filesystem, and search.",
1682
+ "The environment persists across calls within a session `define`s in one",
1683
+ "submission are visible in the next.",
1886
1684
  "",
1887
- "Productive patterns:",
1888
- " - Host bindings (`grep`, `glob`, `read-file`, …) return Scheme data,",
1889
- " so the output of one can feed into the next without re-parsing.",
1890
- " `(map proc (grep \"pat\" \"src/\"))` is the natural shape.",
1891
- " - Read-only calls (`read-file`, `grep`, `glob`, `sh` for queries) have",
1892
- " no side effects and can be batched in one submission — bind several",
1893
- " with `let`/`define` and assemble the answer locally, instead of",
1894
- " issuing each as a separate tool round. Side-effecting calls",
1895
- " (`write-file`, `edit-file`, mutating `bash`) are clearer one step",
1896
- " at a time so you can react to each result.",
1897
- " - The env persists across submissions, so binding intermediate",
1898
- " results once (e.g. `(define files (glob …))`) avoids recomputing",
1899
- " them in later calls.",
1900
- " - `(bash …)` calls a real shell — natural for shell-shaped work",
1901
- " (tests, builds, git, system commands). Three variants of the shell",
1902
- " binding return different shapes:",
1903
- " `(sh \"cmd\")` → just the output as a string. Fits \"run this,",
1904
- " show me the result\" without unwrapping.",
1905
- " `(sh-ok? \"cmd\")` → just a boolean. Fits `(if (sh-ok? \"…\") …)`",
1906
- " branches and existence checks.",
1907
- " `(bash \"cmd\")` → full alist when you need stdout *and* exit",
1908
- " code *and* stderr separately (e.g. capture",
1909
- " stderr while letting stdout flow on).",
1910
- " For file content work, the host bindings (`grep`, `read-file`,",
1911
- " `glob`) avoid shell-quoting entirely and return structured data.",
1912
- " - `scheme-define` saves a procedure to disk so it auto-loads next",
1913
- " session — useful when you've worked out something reusable.",
1685
+ "You already know the language: assume the full R7RS / SRFI-1 / Racket stdlib",
1686
+ "is present (map filter fold assoc, string-* and list ops, cond/when/unless,",
1687
+ "char and hash-table ops, …). The only novel surface is the host bindings.",
1688
+ "",
1689
+ "Calling convention:",
1690
+ " - Required arguments are positional: (read-file \"x\"), (grep \"pat\" \"src/\").",
1691
+ " - grep / grep-files options are :key value pairs:",
1692
+ " (grep \"TODO\" \"src/\" :include \"*.ts\" :context-after 2)",
1693
+ " - Each binding returns the natural Scheme value for its job (a string, a",
1694
+ " list, a boolean); bash returns a record because you usually want the code.",
1914
1695
  "",
1915
1696
  "Host bindings:",
1916
- " (bash cmd [timeout-sec]) → alist ((exit-code . N) (stdout . S) (stderr . S) (success . #t/#f))",
1917
- " cmd is run via `bash -c`. Pipes/redirects/$VARS/&&/||/here-docs work",
1918
- " inside the string; there's no piping between separate bash calls.",
1919
- " (sh cmd [timeout-sec]) → stdout string (stderr text on failure)",
1920
- " (sh-ok? cmd [timeout-sec]) → #t if exit code 0, else #f",
1921
- " (read-file path) → string, or #f on error",
1922
- " (read-file path offset) → from line offset (1-indexed) to end",
1923
- " (read-file path offset limit) → offset + N lines",
1924
- " (write-file path content) → #t on success, error string on failure",
1925
- " (edit-file path old new) → #t on success, error string on failure",
1926
- " (edit-file path old new #t) → replace every occurrence (not just one)",
1927
- " (grep pattern [path] [limit|opts]) → list of ((file . S) (line . N) (text . S))",
1928
- " (grep-files pattern [path] [opts]) → list of file paths",
1929
- " Patterns are ripgrep regex (Rust). Both POSIX BRE escapes (`\\|`,",
1930
- " `\\(`, `\\)`, `\\{`, `\\}`, `\\+`, `\\?`) and bare ERE-style metacharacters",
1931
- " (`|`, `(`, `)`, `{`, `}`, `+`, `?`) work — the bridge translates BRE to",
1932
- " ERE before invoking ripgrep. `.` is any char, `\\b` is a word boundary.",
1933
- " opts: ((include . \"*.ts\") ; filename glob filter",
1934
- " (case-insensitive . #t)",
1935
- " (context-before . N) (context-after . N) ; grep only",
1936
- " (limit . N) (offset . N))",
1937
- " The opts alist is auto-quoted, so `((k . v) …)` and `'((k . v) …)` both work.",
1938
- " (glob pattern [base-dir]) → list of file paths (mtime-sorted)",
1939
- "Accessors on bash result: (stdout-of r) (stderr-of r) (exit-code-of r) (success? r)",
1940
- "Strings: (string-length s) (string-contains? s n) (string-append . parts)",
1941
- " (string-replace old new s) (number->string n) (string->number s)",
1942
- " (lines s) (split sep s) (replace pat repl s) (max …) (min …)",
1697
+ HOST_BINDINGS_BLOCK,
1943
1698
  "",
1944
- "Standard Scheme: if cond when unless begin and or not | let let* define set! lambda",
1945
- " map filter fold reduce for-each | eq? null? pair? number? string? empty?",
1946
- " list car cdr cons length append reverse assoc | define-macro",
1699
+ " Discover at runtime: (help) lists these, (help 'grep) shows one.",
1700
+ " bash runs via `bash -c` pipes/redirects/$VARS/&&/here-docs work inside the",
1701
+ " string; stdout+stderr are merged into `output`, and `exit-code` is the real",
1702
+ " shell code. grep/grep-files patterns are ripgrep regex (Rust); POSIX BRE",
1703
+ " escapes (\\|, \\(, \\), \\{, \\}, \\+, \\?) and bare ERE metacharacters both work.",
1947
1704
  "",
1948
- "Dialect notes:",
1949
- " - R7RS truthy semantics: anything that isn't `#f` is true. `(if str …)`,",
1950
- " `(if 0 )`, `(if '() )` all take the then-branch.",
1951
- " - `#t`/`#f` work as expected. `equal?`, `eq?`, `eqv?`, `string=?` all work.",
1952
- " - SRFI-1: `member`, `assq`/`assv`/`assoc`, `delete-duplicates`, `first`",
1953
- " through `fifth`, `last`, `take`, `drop`, `iota`, `any`, `every`, `count`,",
1954
- " `find`, `filter-map`, `append-map`, `concatenate`, `partition`, `remove`,",
1955
- " `delete`, `zip`, `take-while`, `drop-while`, `fold-right` are all bound.",
1956
- " - R7RS extras: `string-upcase`/`-downcase`, `string-split`/`-join`,",
1957
- " `zero?`/`positive?`/`negative?`/`odd?`/`even?`, `modulo`, `quotient`,",
1958
- " `remainder`, `expt`, `ceiling`, `error`, `newline`, `displayln`.",
1705
+ "Composition is the point: chain read-only bindings in one submission so",
1706
+ "intermediate results stay in the Scheme heap instead of the conversation.",
1707
+ " (map (lambda (m) (read-file (cdr (assoc 'file m)) (cdr (assoc 'line m)) 3))",
1708
+ " (grep \"TODO\" \"src/\"))",
1709
+ "Side-effecting calls (write-file, edit-file, mutating bash) are clearer one",
1710
+ "at a time so you can react to each result.",
1959
1711
  "",
1712
+ "scheme-define saves a procedure to ~/.agent-sh/scheme-define/{name}.scm so it",
1713
+ "auto-loads next session:",
1960
1714
  " (scheme-define name (args …) \"docstring\" body …)",
1961
- " Defines like `define`, and also saves to",
1962
- " ~/.agent-sh/scheme-define/{name}.scm so it auto-loads next session.",
1715
+ "",
1716
+ "Dialect notes:",
1717
+ " - R7RS truthy semantics: only `#f` is false. `(if 0 …)`, `(if '() …)`,",
1718
+ " `(if \"\" …)` all take the then-branch.",
1719
+ " - Characters are a real type: `#\\A` `#\\newline` `#\\space` `#\\tab` `#\\xNN`.",
1720
+ " - String escapes are JSON-style (`\\\\` `\\\"` `\\n` `\\r` `\\t` `\\uXXXX`);",
1721
+ " for a literal backslash write `\\\\`.",
1963
1722
  "",
1964
1723
  "Default timeout 15s; pass timeout_ms to override (max 60s).",
1965
1724
  ].join("\n");
1966
1725
 
1967
- // Scheme prelude R7RS forms LIPS doesn't ship. Evaluated after the JS
1968
- // bindings (#t/#f, null?, etc.) are in place. `define-macro` is LIPS' own
1969
- // macro form (defmacro-style); used here because `define-syntax` isn't
1970
- // available either.
1726
+ // Scheme prelude (Lisp `define-macro`s), run after std is bootstrapped.
1727
+ // cond/when/unless/newline/assq for convenience; the grep/grep-files macros
1728
+ // quote :key option symbols so the bridge can read them as an option map.
1971
1729
  const PRELUDE = `
1972
1730
  (define-macro (cond . clauses)
1973
1731
  (if (null? clauses)
@@ -1989,36 +1747,24 @@ const PRELUDE = `
1989
1747
  (define (newline) (display "\n"))
1990
1748
  (define assq assoc)
1991
1749
 
1992
- ;; grep / grep-files: auto-quote alist-literal opts so callers can write
1993
- ;; either ((k . v) ...) or '((k . v) ...). Without this, the bare form is
1994
- ;; read as a function call on (k . v) and errors with an unbound-variable
1995
- ;; message that doesn't point at the cause.
1996
- (define (%alist-literal? x)
1997
- (and (pair? x) (pair? (car x)) (symbol? (car (car x)))))
1998
-
1999
- (define-macro (grep . args)
2000
- (if (and (>= (length args) 3) (%alist-literal? (car (cdr (cdr args)))))
2001
- (cons '%grep
2002
- (cons (car args)
2003
- (cons (car (cdr args))
2004
- (cons (list 'quote (car (cdr (cdr args))))
2005
- (cdr (cdr (cdr args)))))))
2006
- (cons '%grep args)))
2007
-
2008
- (define-macro (grep-files . args)
2009
- (if (and (>= (length args) 3) (%alist-literal? (car (cdr (cdr args)))))
2010
- (cons '%grep-files
2011
- (cons (car args)
2012
- (cons (car (cdr args))
2013
- (cons (list 'quote (car (cdr (cdr args))))
2014
- (cdr (cdr (cdr args)))))))
2015
- (cons '%grep-files args)))
1750
+ ;; grep / grep-files take :key value options. LIPS has no keyword args, so a
1751
+ ;; bare :include would be evaluated as an unbound variable; the macro quotes
1752
+ ;; leading-colon symbols and the %grep bridge splits them from the positionals.
1753
+ (define (%kw-symbol? s)
1754
+ (and (symbol? s)
1755
+ (let ((str (symbol->string s)))
1756
+ (and (> (string-length str) 0)
1757
+ (string=? (substring str 0 1) ":")))))
1758
+
1759
+ (define (%quote-kw args)
1760
+ (map (lambda (a) (if (%kw-symbol? a) (list 'quote a) a)) args))
1761
+
1762
+ (define-macro (grep . args) (cons '%grep (%quote-kw args)))
1763
+ (define-macro (grep-files . args) (cons '%grep-files (%quote-kw args)))
2016
1764
  `;
2017
1765
 
2018
1766
  export default function activate(ctx: AgentContext): void {
2019
1767
  const env = (lips as any).env.inherit("scheme-ext");
2020
- installFixedDefine(env);
2021
- installLenientIf(env);
2022
1768
  const defineRegistry: DefineRegistry = new Map();
2023
1769
  const defineLoading = { active: false };
2024
1770
  // Forward decl: assigned after baseInstruction is computed below.
@@ -2038,21 +1784,32 @@ export default function activate(ctx: AgentContext): void {
2038
1784
  try { glob = resolveExecutor(ctx, "glob"); } catch { /* optional */ }
2039
1785
  installBindings(env, ctx.bus, bash, readFile, writeFile, editFile, grep, glob);
2040
1786
 
2041
- // Fire-and-forget: exec is async but macros register in <1ms, well before
2042
- // any user call. Load persisted defines, then audit shim coverage.
2043
- void (lips as any).exec(PRELUDE, env)
2044
- .then(() => loadPersistedDefines(env, defineRegistry, defineLoading))
2045
- .then(() => {
2046
- const audit = auditShimCoverage(env);
2047
- if (audit.missing.length > 0) {
2048
- logErr("shim-audit", new Error("missing canonical names"), {
2049
- defined: audit.defined,
2050
- total: audit.defined + audit.missing.length,
2051
- missing: audit.missing,
2052
- });
2053
- }
2054
- })
2055
- .catch((e: any) => logErr("prelude", e));
1787
+ // Bootstrap LIPS' compiled std library into the global env, then install the
1788
+ // gap-filling shims (skipped where std already defines a name, so native
1789
+ // R7RS wins), the prelude macros, and any persisted scheme-defines. Bootstrap
1790
+ // must precede installStdShims so native bindings take priority.
1791
+ void (async () => {
1792
+ try {
1793
+ const stdXcb = path.join(
1794
+ path.dirname(createRequire(import.meta.url).resolve("@jcubic/lips")),
1795
+ "std.xcb",
1796
+ );
1797
+ await bootstrap(stdXcb);
1798
+ } catch (e) {
1799
+ logErr("bootstrap", e);
1800
+ }
1801
+ installStdShims(env);
1802
+ await (lips as any).exec(PRELUDE, { env });
1803
+ await loadPersistedDefines(env, defineRegistry, defineLoading);
1804
+ const audit = auditShimCoverage(env);
1805
+ if (audit.missing.length > 0) {
1806
+ logErr("shim-audit", new Error("missing canonical names"), {
1807
+ defined: audit.defined,
1808
+ total: audit.defined + audit.missing.length,
1809
+ missing: audit.missing,
1810
+ });
1811
+ }
1812
+ })().catch((e: any) => logErr("init", e));
2056
1813
 
2057
1814
  if (schemeOnly) {
2058
1815
  for (const name of HIDDEN_IN_SCHEME_ONLY) {
@@ -2085,11 +1842,8 @@ export default function activate(ctx: AgentContext): void {
2085
1842
  },
2086
1843
  getDisplayInfo: () => ({ kind: "execute", icon: "λ", sourceLanguage: "scheme" }),
2087
1844
  formatResult: (args, result) => {
2088
- // Output is usually a long alist or file dump the LLM still gets full
2089
- // content via tool_result, but the TUI body shows the SOURCE so Ctrl+O
2090
- // reveals what ran rather than what came back. scheme-render.ts (if
2091
- // loaded) honors this; ashi's default renderer currently ignores
2092
- // body.kind === "lines", which is fine — summary still carries the gist.
1845
+ // TUI body shows the SOURCE, not the result (Ctrl+O reveals what ran);
1846
+ // the LLM still gets full content via tool_result.
2093
1847
  const sourceLines = String(args.source ?? "").split("\n");
2094
1848
  if (!result.isError) {
2095
1849
  return {
@@ -2120,44 +1874,24 @@ export default function activate(ctx: AgentContext): void {
2120
1874
  },
2121
1875
  });
2122
1876
 
2123
- // schemeOnly: registerInstruction carries the full tool surface since
2124
- // deferred-lookup mode strips tool descriptions from the system prompt.
2125
- // coreTools puts scheme_eval's schema in the API tool list already; we
2126
- // add behavioral framing here — specifically the context-preservation
2127
- // nudge that would be lost in the long tool description.
1877
+ // Behavioral framing in the system prompt (the API + examples live in the
1878
+ // tool description). Keep it short and non-duplicative: just when to reach
1879
+ // for scheme_eval over a direct tool call.
2128
1880
  const baseInstruction = schemeOnly
2129
1881
  ? [
2130
1882
  "# Scheme runtime",
2131
- "scheme_eval is your only tool; see its description for the API.",
2132
- "",
2133
- "## Context preservation",
2134
- "Each tool round-trip permanently consumes context. Prefer composing",
2135
- "multi-step operations into a single scheme_eval call so intermediate",
2136
- "results stay in the Scheme heap instead of the conversation. Example:",
2137
- " (let ((files (glob \"src/**/*.ts\"))",
2138
- " (matches (grep \"TODO\" \"src/\")))",
2139
- " (filter (lambda (f) (member f (map (lambda (m) (cdr (assoc 'file m))) matches)))",
2140
- " files))",
2141
- "This does glob + grep + filter in one round-trip. Use `define` to",
2142
- "cache results across calls: `(define files (glob …))` once, reuse later.",
1883
+ "scheme_eval is your only tool; see its description for the API. Each tool",
1884
+ "round-trip consumes context permanently, so compose multi-step work into a",
1885
+ "single call — intermediate results stay in the Scheme heap, not the",
1886
+ "conversation. `define` caches across calls.",
2143
1887
  ].join("\n")
2144
1888
  : [
2145
1889
  "# Scheme runtime",
2146
- "scheme_eval evaluates Scheme with host bindings to bash, read-file, grep, glob, etc.",
2147
- "See its description for the full API.",
2148
- "",
2149
- "## When to reach for scheme_eval",
2150
- "The direct tools (grep, read_file, bash, etc.) are the right default for",
2151
- "single operations. scheme_eval becomes valuable when you'd chain 2+ read-only",
2152
- "tool calls that don't need inspection between steps — composing them inside",
2153
- "Scheme keeps intermediate results in the Scheme heap instead of the",
2154
- "conversation, saving context. Example:",
2155
- " (let ((matches (grep \"pattern\" \"src/\")))",
2156
- " (map (lambda (m) (list (cdr (assoc 'file m))",
2157
- " (read-file (cdr (assoc 'file m)) (cdr (assoc 'line m)) 3)))",
2158
- " (take matches 5)))",
2159
- "does grep + read-file × 5 in one round-trip. `define` caches across",
2160
- "calls: `(define files (glob …))` once, reuse in later submissions.",
1890
+ "scheme_eval evaluates Scheme with host bindings (bash, read-file, grep, glob,",
1891
+ "…); see its description for the API. Direct tools are the right default for",
1892
+ "single operations; reach for scheme_eval to chain 2+ read-only calls that",
1893
+ "don't need inspection between steps — composing them keeps intermediate",
1894
+ "results in the Scheme heap instead of the conversation.",
2161
1895
  ].join("\n");
2162
1896
  // Re-register when the scheme-define registry changes so the index stays
2163
1897
  // current within a session.