agent-sh 0.14.10 → 0.15.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 (97) hide show
  1. package/README.md +36 -13
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +123 -150
  4. package/dist/agent/events.d.ts +10 -12
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +76 -29
  8. package/dist/agent/live-view.d.ts +3 -3
  9. package/dist/agent/live-view.js +15 -7
  10. package/dist/agent/providers/deepseek.js +9 -1
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/session-store.js +1 -1
  13. package/dist/agent/subagent.js +1 -1
  14. package/dist/agent/system-prompt.d.ts +7 -3
  15. package/dist/agent/system-prompt.js +11 -14
  16. package/dist/agent/tool-protocol.js +0 -7
  17. package/dist/cli/args.js +2 -1
  18. package/dist/cli/install.d.ts +1 -0
  19. package/dist/cli/install.js +39 -2
  20. package/dist/cli/subcommands.js +1 -0
  21. package/dist/core/event-bus.js +0 -2
  22. package/dist/core/extension-loader.js +3 -1
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +3 -2
  25. package/dist/extensions/slash-commands/index.js +16 -11
  26. package/dist/shell/events.d.ts +3 -0
  27. package/dist/shell/index.js +9 -0
  28. package/dist/shell/shell-context.d.ts +2 -2
  29. package/dist/shell/shell-context.js +26 -11
  30. package/dist/shell/shell.js +3 -0
  31. package/dist/shell/tui-renderer.js +0 -1
  32. package/dist/utils/diff-renderer.d.ts +4 -0
  33. package/dist/utils/diff-renderer.js +15 -27
  34. package/dist/utils/handler-registry.d.ts +1 -6
  35. package/dist/utils/handler-registry.js +1 -6
  36. package/dist/utils/line-editor.js +0 -2
  37. package/dist/utils/palette.js +4 -4
  38. package/dist/utils/terminal-buffer.d.ts +2 -0
  39. package/dist/utils/terminal-buffer.js +4 -0
  40. package/examples/extensions/ads/SKILL.md +170 -0
  41. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  42. package/examples/extensions/ash-scheme/index.ts +377 -687
  43. package/examples/extensions/ash-scheme/package.json +1 -1
  44. package/examples/extensions/ashi/EXTENDING.md +118 -0
  45. package/examples/extensions/ashi/README.md +26 -54
  46. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  47. package/examples/extensions/ashi/package.json +14 -2
  48. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  49. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  50. package/examples/extensions/ashi/src/capture.ts +54 -10
  51. package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
  52. package/examples/extensions/ashi/src/chat/lines.ts +39 -0
  53. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  54. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  55. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  56. package/examples/extensions/ashi/src/cli.ts +80 -12
  57. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  58. package/examples/extensions/ashi/src/commands.ts +11 -1
  59. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  60. package/examples/extensions/ashi/src/display-config.ts +16 -1
  61. package/examples/extensions/ashi/src/docks.ts +31 -0
  62. package/examples/extensions/ashi/src/events.ts +16 -0
  63. package/examples/extensions/ashi/src/frontend.ts +456 -268
  64. package/examples/extensions/ashi/src/hooks.ts +27 -40
  65. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  66. package/examples/extensions/ashi/src/renderer.ts +222 -0
  67. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  68. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
  69. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
  70. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
  71. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  72. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  73. package/examples/extensions/ashi/src/schema.ts +46 -205
  74. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  75. package/examples/extensions/ashi/src/status-footer.ts +35 -25
  76. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  77. package/examples/extensions/ashi/src/theme.ts +1 -47
  78. package/examples/extensions/ashi/src/ui.ts +88 -0
  79. package/examples/extensions/ashi-ink/README.md +61 -0
  80. package/examples/extensions/ashi-ink/package.json +30 -0
  81. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  82. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  83. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  84. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  85. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  86. package/examples/extensions/ashi-scheme-render.ts +10 -10
  87. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  88. package/examples/extensions/ashi-ui-demo.ts +63 -0
  89. package/examples/extensions/latex-images.ts +70 -19
  90. package/examples/extensions/overlay-agent.ts +5 -5
  91. package/examples/extensions/pi-bridge/index.ts +7 -12
  92. package/examples/extensions/terminal-buffer.ts +4 -2
  93. package/package.json +3 -9
  94. package/examples/extensions/ashi/src/components.ts +0 -238
  95. package/examples/extensions/ollama.ts +0 -108
  96. package/examples/extensions/opencode-provider.ts +0 -251
  97. 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;
@@ -23,15 +27,18 @@ async function withDisplay(
23
27
  ): Promise<ToolResult> {
24
28
  const toolCallId = `scheme-${toolName}-${++callCounter}`;
25
29
  bus.emit("agent:tool-started", {
26
- title: toolName, toolCallId, kind, rawInput, displayDetail,
30
+ title: toolName, toolCallId, kind, rawInput, displayDetail, nested: true,
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,
32
38
  rawOutput: result.content,
33
39
  kind,
34
40
  resultDisplay: result.display,
41
+ nested: true,
35
42
  });
36
43
  return result;
37
44
  }
@@ -41,64 +48,16 @@ async function withDisplay(
41
48
  // the TUI still renders diffs.
42
49
  const HIDDEN_IN_SCHEME_ONLY = ["bash", "pwsh", "read_file", "write_file", "edit_file", "ls", "glob", "grep"];
43
50
 
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
- }
51
+ const { Pair, nil, LSymbol, LNumber, Macro, bootstrap, LString } = lips as any;
84
52
 
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
- }
53
+ // LIPS 1.0 boxes string literals as LString; unbox to a JS primitive so the gap
54
+ // shims and host bridge (which assume JS strings) operate on them correctly.
55
+ const toJsStr = (x: any): any => (x instanceof LString ? x.toString() : x);
56
+
57
+ // LIPS 1.0 stores a symbol's name in `__name__`; `.name` is undefined (the 0.x
58
+ // build used `.name`). Read every symbol name through this so both builds work.
59
+ const symName = (x: any): string | undefined =>
60
+ x instanceof LSymbol ? ((x as any).__name__ ?? (x as any).name) : undefined;
102
61
 
103
62
  const LOG_PATH = path.join(os.homedir(), ".agent-sh", "scheme-eval.log");
104
63
  const SCHEME_DEFINE_DIR = path.join(os.homedir(), ".agent-sh", "scheme-define");
@@ -123,7 +82,7 @@ function installSchemeDefine(
123
82
  throw new Error("scheme-define: expected (scheme-define name (args …) \"doc\" body …)");
124
83
  }
125
84
  const nameSym = code.car;
126
- const name = nameSym.name;
85
+ const name = symName(nameSym)!;
127
86
  const argsForm = code.cdr instanceof Pair ? code.cdr.car : nil;
128
87
  let rest = code.cdr instanceof Pair ? code.cdr.cdr : nil;
129
88
  let doc = "";
@@ -175,7 +134,7 @@ async function loadPersistedDefines(
175
134
  const fp = path.join(SCHEME_DEFINE_DIR, f);
176
135
  try {
177
136
  const src = fs.readFileSync(fp, "utf-8");
178
- await (lips as any).exec(src, env);
137
+ await (lips as any).exec(src, { env });
179
138
  } catch (e) {
180
139
  logErr("scheme-define load", e, { file: fp });
181
140
  }
@@ -266,7 +225,7 @@ function lookup(result: unknown, key: string): unknown {
266
225
  let node: any = result;
267
226
  while (node && node instanceof Pair) {
268
227
  const entry = node.car;
269
- if (entry && entry.car && entry.car.name === key) return entry.cdr;
228
+ if (entry && entry.car && symName(entry.car) === key) return entry.cdr;
270
229
  node = node.cdr;
271
230
  }
272
231
  return undefined;
@@ -310,8 +269,11 @@ function stripPagination(raw: string): string[] {
310
269
 
311
270
  function format(v: unknown): string {
312
271
  if (v === undefined || v === null) return "";
272
+ if (v instanceof LString) v = v.toString();
313
273
  if (typeof v === "string") return JSON.stringify(v);
314
- if (typeof v === "number" || typeof v === "boolean") return String(v);
274
+ // Render JS booleans Scheme-style, matching how #t/#f print inside records.
275
+ if (typeof v === "boolean") return v ? "#t" : "#f";
276
+ if (typeof v === "number") return String(v);
315
277
  if (v && typeof (v as any).toString === "function") {
316
278
  try { return (v as any).toString(); } catch {}
317
279
  }
@@ -319,40 +281,9 @@ function format(v: unknown): string {
319
281
  }
320
282
 
321
283
  // ── evaluator ─────────────────────────────────────────────────────
322
- // LIPS 0.20.x has no character type, so a `#\…` literal reads as an unbound
323
- // symbol. preprocessSchemeSource expands each to a 1-char string instead.
324
- const NAMED_CHARS: Record<string, string> = {
325
- newline: "\n", linefeed: "\n", nl: "\n",
326
- space: " ", tab: "\t", return: "\r",
327
- null: "\u0000", nul: "\u0000",
328
- delete: "\u007f", rubout: "\u007f",
329
- escape: "\u001b", altmode: "\u001b",
330
- backspace: "\u0008", alarm: "\u0007", page: "\u000c",
331
- };
332
-
333
- // The char after `#\` as a 1-char string + its span, or null for an unknown
334
- // multi-char name (left untranslated so it surfaces as an error).
335
- function resolveCharLiteral(source: string, start: number): { ch: string; span: number } | null {
336
- let j = start;
337
- while (j < source.length && /[A-Za-z0-9]/.test(source[j])) j++;
338
- const run = source.slice(start, j);
339
- if (run.length <= 1) {
340
- const ch = source[start];
341
- return ch === undefined ? null : { ch, span: 1 };
342
- }
343
- const hex = /^[xX]([0-9a-fA-F]+)$/.exec(run);
344
- if (hex) {
345
- const cp = parseInt(hex[1], 16);
346
- if (cp >= 0 && cp <= 0x10ffff) return { ch: String.fromCodePoint(cp), span: run.length };
347
- }
348
- const named = NAMED_CHARS[run.toLowerCase()];
349
- return named === undefined ? null : { ch: named, span: run.length };
350
- }
351
-
352
- // LIPS implements string literals via JSON.parse, which rejects backslash
353
- // escapes outside JSON's tiny set (\" \\ \/ \b \f \n \r \t \uXXXX). Models
354
- // routinely write \s \w \d etc. in regex strings. Pre-process: promote any
355
- // invalid \X to \\X so LIPS parses it as a literal backslash + X.
284
+ // LIPS' string lexer accepts only JSON-style escapes (\" \\ \/ \b \f \n \r \t
285
+ // \uXXXX), but models routinely write \s \w \d etc. in regex strings. Promote
286
+ // any other \X to \\X so it parses as a literal backslash + X.
356
287
  function preprocessSchemeSource(source: string): string {
357
288
  const JSON_ESC = new Set(["\\", "/", '"', "b", "f", "n", "r", "t"]);
358
289
  let out = "";
@@ -369,14 +300,6 @@ function preprocessSchemeSource(source: string): string {
369
300
  if (!inStr) {
370
301
  if (c === ";") { inComment = true; out += c; continue; }
371
302
  if (c === '"') { inStr = true; out += c; continue; }
372
- if (c === "#" && source[i + 1] === "\\") {
373
- const lit = resolveCharLiteral(source, i + 2);
374
- if (lit) {
375
- out += JSON.stringify(lit.ch);
376
- i += 1 + lit.span; // skip '\' + the name/char; loop's i++ skips '#'
377
- continue;
378
- }
379
- }
380
303
  out += c;
381
304
  continue;
382
305
  }
@@ -393,16 +316,16 @@ function preprocessSchemeSource(source: string): string {
393
316
  i++;
394
317
  continue;
395
318
  }
396
- // Any other \X — promote so JSON.parse sees \\X literal
319
+ // Any other \X — promote to \\X so it parses as a literal backslash
397
320
  out += "\\\\" + next;
398
321
  i++;
399
322
  }
400
323
  return out;
401
324
  }
402
325
 
403
- // If LIPS reports a JSON-parse failure, localize the invalid escapes so the
404
- // agent gets actionable line/col info instead of a raw offset. Only triggers
405
- // when preprocessing didn't catch everything.
326
+ // If LIPS rejects a string literal, localize the invalid escapes so the agent
327
+ // gets actionable line/col info instead of a raw offset. Only triggers when
328
+ // preprocessing didn't catch everything.
406
329
  function formatStringEscapeDiagnostic(source: string, baseMsg: string): string {
407
330
  const JSON_ESC = new Set(["\\", "/", '"', "b", "f", "n", "r", "t"]);
408
331
  let line = 1, col = 1;
@@ -512,30 +435,29 @@ function formatParenDiagnostic(source: string, baseMsg: string): string {
512
435
 
513
436
  async function evaluate(env: any, source: string, timeoutMs: number) {
514
437
  const preprocessed = preprocessSchemeSource(source);
515
- // Install a per-eval stdout buffer so (display …) output is captured into
516
- // the result instead of vanishing to console.log. Also override `display`
517
- // to drop LIPS' string-quoting (its default writes `"hello"` with literal
518
- // quote marks; R7RS display should be raw).
519
- const prevStdout = (env as any).get("stdout", { throwError: false });
520
- const prevDisplay = (env as any).get("display", { throwError: false });
438
+ // Capture output into the result instead of letting it vanish to console.log.
439
+ // LIPS 1.0's native display/write resolve the *functions* from the env (the
440
+ // stdout-port override alone misses them), so shadow each output procedure.
441
+ const OUTPUT_PROCS = ["stdout", "display", "write", "write-string", "write-char"];
442
+ const prev: Record<string, any> = {};
443
+ for (const name of OUTPUT_PROCS) prev[name] = (env as any).get(name, { throwError: false });
521
444
  const buf: string[] = [];
522
- (env as any).set("stdout", {
523
- write: (...args: any[]) => {
524
- for (const a of args) buf.push(typeof a === "string" ? a : String(a));
525
- },
526
- });
527
- (env as any).set("display", (...args: any[]) => {
528
- const out = args.map((a) => {
529
- if (a === null || a === undefined) return "";
530
- if (typeof a === "string") return a;
531
- if (a && typeof (a as any).toString === "function") return (a as any).toString();
532
- return String(a);
533
- }).join("");
534
- buf.push(out);
445
+ const raw = (a: any): string => {
446
+ if (a === null || a === undefined) return "";
447
+ if (typeof a === "string") return a;
448
+ if (a && typeof (a as any).toString === "function") return (a as any).toString();
449
+ return String(a);
450
+ };
451
+ (env as any).set("stdout", { write: (...args: any[]) => { for (const a of args) buf.push(raw(a)); } });
452
+ (env as any).set("display", (...args: any[]) => { buf.push(args.map(raw).join("")); });
453
+ (env as any).set("write", (...args: any[]) => {
454
+ buf.push(args.map((a) => (typeof toJsStr(a) === "string" ? JSON.stringify(toJsStr(a)) : raw(a))).join(""));
535
455
  });
456
+ (env as any).set("write-string", (...args: any[]) => { buf.push(raw(args[0])); });
457
+ (env as any).set("write-char", (...args: any[]) => { buf.push(raw(args[0])); });
536
458
  try {
537
459
  const results = await Promise.race<any>([
538
- (lips as any).exec(preprocessed, env),
460
+ (lips as any).exec(preprocessed, { env }),
539
461
  new Promise((_, reject) =>
540
462
  setTimeout(() => reject(new Error(`scheme_eval timed out after ${timeoutMs}ms`)), timeoutMs),
541
463
  ),
@@ -554,27 +476,25 @@ async function evaluate(env: any, source: string, timeoutMs: number) {
554
476
  let msg = e?.message ?? String(e);
555
477
  if (/[Uu]nbalanced parenthes/.test(msg)) {
556
478
  msg = formatParenDiagnostic(source, msg);
557
- } else if (/Bad escaped character in JSON|Unexpected.*JSON|JSON at position/.test(msg)) {
479
+ } else if (/Invalid string literal|Bad escaped character|Unexpected.*JSON|JSON at position/.test(msg)) {
558
480
  msg = formatStringEscapeDiagnostic(source, msg);
559
481
  } else if (msg.includes("Unbound variable `#\\")) {
560
- msg += "\n This runtime has no character type characters are 1-char strings." +
561
- " `#\\newline`, `#\\space`, `#\\tab`, `#\\return`, `#\\xNN`, and `#\\<char>`" +
562
- " are accepted (read as strings); other `#\\<name>` forms are not." +
563
- " Use a string literal instead, e.g. \"\\n\".";
482
+ msg += "\n Unknown character literal. Supported: #\\newline #\\space #\\tab" +
483
+ " #\\return #\\null #\\delete #\\escape, #\\xNN, and #\\<char>.";
564
484
  }
565
485
  return { ok: false as const, error: msg };
566
486
  } finally {
567
- if (prevStdout !== undefined) (env as any).set("stdout", prevStdout);
568
- if (prevDisplay !== undefined) (env as any).set("display", prevDisplay);
487
+ for (const name of OUTPUT_PROCS) {
488
+ if (prev[name] !== undefined) (env as any).set(name, prev[name]);
489
+ }
569
490
  }
570
491
  }
571
492
 
572
493
  // ── standard-library shims ───────────────────────────────────────
573
- // LIPS ships a small subset of R7RS and almost no SRFI-1. Models trained on
574
- // Racket/Chicken/Guile reach for the canonical names (equal?, member, take,
575
- // iota, etc.) and hit "Unbound variable" costing a retry round trip per
576
- // gap. We pre-populate the most common ones so the model's first attempt
577
- // works regardless of which Scheme dialect it learned from.
494
+ // Fill the gaps the bootstrapped std library leaves: SRFI-1 helpers, Racket
495
+ // spellings, and cross-dialect aliases. std covers R7RS base, but a model
496
+ // trained on Racket/Chicken/Guile still reaches for names std doesn't bind.
497
+ // defineIfMissing so anything std already provides wins.
578
498
  function installStdShims(env: any): void {
579
499
  const defineIfMissing = (name: string, fn: any) => {
580
500
  if ((env as any).get(name, { throwError: false }) === undefined) env.set(name, fn);
@@ -586,7 +506,6 @@ function installStdShims(env: any): void {
586
506
  };
587
507
  const truthy = (v: any) => v !== false;
588
508
 
589
- // ── R7RS equality ─────────────────────────────────────────
590
509
  // LIPS wraps numbers as LNumber instances, so `===` fails on equal-valued
591
510
  // numbers from different sources. Handle the wrapper types before recursing.
592
511
  const atomEqual = (a: any, b: any): boolean => {
@@ -594,7 +513,7 @@ function installStdShims(env: any): void {
594
513
  if (a instanceof LNumber && b instanceof LNumber) return a.cmp(b) === 0;
595
514
  if (typeof a === "number" && b instanceof LNumber) return LNumber(a).cmp(b) === 0;
596
515
  if (typeof b === "number" && a instanceof LNumber) return LNumber(b).cmp(a) === 0;
597
- if (a instanceof LSymbol && b instanceof LSymbol) return a.name === b.name;
516
+ if (a instanceof LSymbol && b instanceof LSymbol) return symName(a) === symName(b);
598
517
  return false;
599
518
  };
600
519
  const lipsEqual = (a: any, b: any): boolean => {
@@ -610,41 +529,13 @@ function installStdShims(env: any): void {
610
529
  }
611
530
  return false;
612
531
  };
613
- defineIfMissing("equal?", lipsEqual);
614
- defineIfMissing("eqv?", atomEqual);
615
-
616
- // ── R7RS list/member ─────────────────────────────────────
617
- defineIfMissing("list?", (v: any) => v === nil || v instanceof Pair);
618
- const memberLike = (eq: (a: any, b: any) => boolean) => (item: any, lst: any) => {
619
- let p = lst;
620
- while (p instanceof Pair) {
621
- if (eq(p.car, item)) return p;
622
- p = p.cdr;
623
- }
624
- return false;
625
- };
626
- defineIfMissing("member", memberLike(lipsEqual));
627
- defineIfMissing("memq", memberLike((a, b) => a === b));
628
- defineIfMissing("memv", memberLike((a, b) => a === b));
629
- defineIfMissing("assv", (item: any, lst: any) => {
630
- let p = lst;
631
- while (p instanceof Pair) {
632
- const e = p.car;
633
- if (e instanceof Pair && e.car === item) return e;
634
- p = p.cdr;
635
- }
636
- return false;
637
- });
638
532
 
639
- // ── SRFI-1 list helpers ──────────────────────────────────
640
533
  defineIfMissing("first", (lst: any) => pairToArray(lst)[0]);
641
534
  defineIfMissing("second", (lst: any) => pairToArray(lst)[1]);
642
535
  defineIfMissing("third", (lst: any) => pairToArray(lst)[2]);
643
536
  defineIfMissing("fourth", (lst: any) => pairToArray(lst)[3]);
644
537
  defineIfMissing("fifth", (lst: any) => pairToArray(lst)[4]);
645
538
  defineIfMissing("last", (lst: any) => { const a = pairToArray(lst); return a[a.length - 1]; });
646
- defineIfMissing("take", (lst: any, n: any) => toSchemeList(pairToArray(lst).slice(0, Number(n))));
647
- defineIfMissing("drop", (lst: any, n: any) => toSchemeList(pairToArray(lst).slice(Number(n))));
648
539
  defineIfMissing("take-while", (pred: any, lst: any) => {
649
540
  const out: any[] = [];
650
541
  for (const x of pairToArray(lst)) { if (!truthy(pred(x))) break; out.push(x); }
@@ -662,7 +553,6 @@ function installStdShims(env: any): void {
662
553
  return toSchemeList(Array.from({ length: n }, (_, i) => s + i * k));
663
554
  });
664
555
  defineIfMissing("any", (pred: any, lst: any) => pairToArray(lst).some((x) => truthy(pred(x))));
665
- defineIfMissing("every", (pred: any, lst: any) => pairToArray(lst).every((x) => truthy(pred(x))));
666
556
  defineIfMissing("count", (pred: any, lst: any) => pairToArray(lst).filter((x) => truthy(pred(x))).length);
667
557
  defineIfMissing("filter-map", (f: any, lst: any) => {
668
558
  const out: any[] = [];
@@ -688,44 +578,8 @@ function installStdShims(env: any): void {
688
578
  for (const x of pairToArray(lst)) (truthy(pred(x)) ? t : f).push(x);
689
579
  return new Pair(toSchemeList(t), toSchemeList(f));
690
580
  });
691
- defineIfMissing("fold-right", (f: any, init: any, lst: any) => {
692
- const a = pairToArray(lst); let acc = init;
693
- for (let i = a.length - 1; i >= 0; i--) acc = f(a[i], acc);
694
- return acc;
695
- });
696
- defineIfMissing("zip", (a: any, b: any) => {
697
- const xs = pairToArray(a); const ys = pairToArray(b);
698
- const n = Math.min(xs.length, ys.length);
699
- return toSchemeList(Array.from({ length: n }, (_, i) => toSchemeList([xs[i], ys[i]])));
700
- });
701
-
702
- // ── R7RS numeric predicates / ops ────────────────────────
703
- defineIfMissing("zero?", (n: any) => Number(n) === 0);
704
- defineIfMissing("positive?", (n: any) => Number(n) > 0);
705
- defineIfMissing("negative?", (n: any) => Number(n) < 0);
706
- defineIfMissing("odd?", (n: any) => Math.abs(Number(n)) % 2 === 1);
707
- defineIfMissing("even?", (n: any) => Number(n) % 2 === 0);
708
- defineIfMissing("modulo", (a: any, b: any) => { const m = Number(a) % Number(b); return (m < 0) === (Number(b) < 0) ? m : m + Number(b); });
709
- defineIfMissing("quotient", (a: any, b: any) => Math.trunc(Number(a) / Number(b)));
710
- defineIfMissing("remainder", (a: any, b: any) => Number(a) % Number(b));
711
- defineIfMissing("expt", (a: any, b: any) => Math.pow(Number(a), Number(b)));
712
- defineIfMissing("ceiling", (n: any) => Math.ceil(Number(n)));
713
-
714
- // ── R7RS string ops ──────────────────────────────────────
715
- defineIfMissing("string=?", (a: any, b: any) => String(a) === String(b));
716
- defineIfMissing("string<?", (a: any, b: any) => String(a) < String(b));
717
- defineIfMissing("string>?", (a: any, b: any) => String(a) > String(b));
718
- defineIfMissing("string-upcase", (s: any) => String(s).toUpperCase());
719
- defineIfMissing("string-downcase", (s: any) => String(s).toLowerCase());
720
- defineIfMissing("string->list", (s: any) => toSchemeList(Array.from(String(s))));
721
- defineIfMissing("list->string", (lst: any) => pairToArray(lst).map(String).join(""));
722
- defineIfMissing("string-ref", (s: any, i: any) => {
723
- const str = String(s);
724
- const idx = Math.floor(Number(i));
725
- return idx >= 0 && idx < str.length ? str[idx] : false;
726
- });
581
+
727
582
  defineIfMissing("string-trim-both", (s: any) => String(s).trim());
728
- defineIfMissing("identity", (x: any) => x);
729
583
  // Pattern can be string or (regexp "pat"). Racket (?i:…) / (?m:…) inline
730
584
  // flag groups are translated to JS RegExp flags.
731
585
  const compileRegex = (pat: any): RegExp => {
@@ -741,6 +595,7 @@ function installStdShims(env: any): void {
741
595
  };
742
596
  defineIfMissing("regexp", (pat: any) => compileRegex(pat));
743
597
  const regexpMatch = (pat: any, s: any) => {
598
+ s = toJsStr(s);
744
599
  if (typeof s !== "string") return false;
745
600
  const m = s.match(compileRegex(pat));
746
601
  return m ? toSchemeList(Array.from(m)) : false;
@@ -763,6 +618,7 @@ function installStdShims(env: any): void {
763
618
  return false;
764
619
  });
765
620
  defineIfMissing("regexp-match-positions", (pat: any, s: any) => {
621
+ s = toJsStr(s);
766
622
  if (typeof s !== "string") return false;
767
623
  const m = s.match(compileRegex(pat));
768
624
  if (!m) return false;
@@ -771,101 +627,25 @@ function installStdShims(env: any): void {
771
627
  return new Pair(full, nil);
772
628
  });
773
629
 
774
- // ── Racket spellings ─────────────────────────────────────
775
- defineIfMissing("string-split", (s: any, sep: any) => {
776
- if (typeof s !== "string") return nil;
777
- if (sep === undefined) return toSchemeList(s.split(/\s+/).filter(Boolean));
778
- return toSchemeList(s.split(String(sep)));
779
- });
780
- defineIfMissing("string-join", (lst: any, sep: any) =>
781
- pairToArray(lst).map(String).join(sep === undefined ? " " : String(sep)));
782
630
  defineIfMissing("displayln", function (this: any, x: any) {
783
631
  const display = (env as any).get("display", { throwError: false });
784
632
  if (display) { display(x); display("\n"); }
785
633
  });
786
634
 
787
- // ── R7RS error/exit ──────────────────────────────────────
788
635
  defineIfMissing("error", (...msgs: any[]) => {
789
636
  throw new Error(msgs.map((m) => (typeof m === "string" ? m : String(m))).join(" "));
790
637
  });
791
638
  defineIfMissing("void", () => undefined);
792
639
 
793
- // ── R7RS write (LIPS' display is good enough; write quotes strings) ──
794
640
  defineIfMissing("write", function (this: any, x: any) {
795
641
  const display = (env as any).get("display", { throwError: false });
796
642
  if (display) display(typeof x === "string" ? JSON.stringify(x) : x);
797
643
  });
798
644
 
799
- // ── R7RS numbers (gaps) ────────────────────────────────────
800
- defineIfMissing("abs", (n: any) => Math.abs(Number(n)));
801
- defineIfMissing("floor", (n: any) => Math.floor(Number(n)));
802
- defineIfMissing("round", (n: any) => Math.round(Number(n)));
803
- defineIfMissing("truncate", (n: any) => Math.trunc(Number(n)));
804
- defineIfMissing("sqrt", (n: any) => Math.sqrt(Number(n)));
805
- defineIfMissing("log", (n: any, base: any) =>
806
- base === undefined ? Math.log(Number(n)) : Math.log(Number(n)) / Math.log(Number(base)));
807
- defineIfMissing("exp", (n: any) => Math.exp(Number(n)));
808
- defineIfMissing("sin", (n: any) => Math.sin(Number(n)));
809
- defineIfMissing("cos", (n: any) => Math.cos(Number(n)));
810
- defineIfMissing("tan", (n: any) => Math.tan(Number(n)));
811
- defineIfMissing("asin", (n: any) => Math.asin(Number(n)));
812
- defineIfMissing("acos", (n: any) => Math.acos(Number(n)));
813
- defineIfMissing("atan", (a: any, b: any) =>
814
- b === undefined ? Math.atan(Number(a)) : Math.atan2(Number(a), Number(b)));
815
- defineIfMissing("gcd", (...args: any[]) => {
816
- const gcd2 = (a: number, b: number): number => b === 0 ? Math.abs(a) : gcd2(b, a % b);
817
- return args.length === 0 ? 0 : args.map(Number).reduce(gcd2);
818
- });
819
- defineIfMissing("lcm", (...args: any[]) => {
820
- const gcd2 = (a: number, b: number): number => b === 0 ? Math.abs(a) : gcd2(b, a % b);
821
- const lcm2 = (a: number, b: number): number => a && b ? Math.abs(a * b) / gcd2(a, b) : 0;
822
- return args.length === 0 ? 1 : args.map(Number).reduce(lcm2);
823
- });
824
- defineIfMissing("exact", (n: any) => Math.round(Number(n)));
825
- defineIfMissing("inexact", (n: any) => Number(n));
826
- defineIfMissing("exact->inexact", (n: any) => Number(n));
827
- defineIfMissing("inexact->exact", (n: any) => Math.round(Number(n)));
828
- defineIfMissing("exact-integer?", (n: any) => Number.isInteger(Number(n)));
829
- defineIfMissing("exact?", (n: any) => Number.isInteger(Number(n)));
830
- defineIfMissing("inexact?", (n: any) => !Number.isInteger(Number(n)));
831
- defineIfMissing("=", (...args: any[]) => {
832
- if (args.length < 2) return true;
833
- const first = Number(args[0]);
834
- for (let i = 1; i < args.length; i++) if (Number(args[i]) !== first) return false;
835
- return true;
836
- });
837
- defineIfMissing("finite?", (n: any) => Number.isFinite(Number(n)));
838
- defineIfMissing("infinite?", (n: any) => !Number.isFinite(Number(n)) && !Number.isNaN(Number(n)));
839
- defineIfMissing("nan?", (n: any) => Number.isNaN(Number(n)));
840
645
  defineIfMissing("add1", (n: any) => Number(n) + 1);
841
646
  defineIfMissing("sub1", (n: any) => Number(n) - 1);
842
647
  defineIfMissing("sqr", (n: any) => Number(n) * Number(n));
843
648
 
844
- // ── R7RS predicates ────────────────────────────────────────
845
- defineIfMissing("boolean?", (x: any) => x === true || x === false);
846
- defineIfMissing("boolean=?", (a: any, b: any) => a === b && (a === true || a === false));
847
- defineIfMissing("procedure?", (x: any) => typeof x === "function");
848
- defineIfMissing("symbol?", (x: any) => x instanceof LSymbol);
849
- defineIfMissing("symbol=?", (a: any, b: any) =>
850
- a instanceof LSymbol && b instanceof LSymbol && (a as any).name === (b as any).name);
851
- defineIfMissing("string->symbol", (s: any) => new LSymbol(String(s)));
852
- defineIfMissing("integer?", (x: any) => typeof x === "number" ? Number.isInteger(x)
853
- : (x && typeof x.valueOf === "function" && Number.isInteger(Number(x.valueOf()))));
854
-
855
- // ── R7RS strings (gaps) ────────────────────────────────────
856
- defineIfMissing("substring", (s: any, start: any, end: any) => {
857
- const str = String(s);
858
- const a = Math.max(0, Math.floor(Number(start) || 0));
859
- const b = end === undefined ? str.length : Math.min(str.length, Math.floor(Number(end)));
860
- return str.slice(a, b);
861
- });
862
- defineIfMissing("string-copy", (s: any) => String(s));
863
- defineIfMissing("make-string", (n: any, ch?: any) => {
864
- const len = Math.max(0, Math.floor(Number(n) || 0));
865
- const c = ch === undefined ? " " : String(ch);
866
- return c.length === 1 ? c.repeat(len) : (c + "").repeat(len).slice(0, len);
867
- });
868
- defineIfMissing("string-foldcase", (s: any) => String(s).toLowerCase());
869
649
  defineIfMissing("string-trim", (s: any) => String(s).trim());
870
650
  defineIfMissing("string-trim-left", (s: any) => String(s).replace(/^\s+/, ""));
871
651
  defineIfMissing("string-trim-right", (s: any) => String(s).replace(/\s+$/, ""));
@@ -873,34 +653,15 @@ function installStdShims(env: any): void {
873
653
  String(s).startsWith(String(prefix)));
874
654
  defineIfMissing("string-suffix?", (suffix: any, s: any) =>
875
655
  String(s).endsWith(String(suffix)));
876
- defineIfMissing("non-empty-string?", (x: any) =>
877
- typeof x === "string" && x.length > 0);
656
+ defineIfMissing("non-empty-string?", (x: any) => {
657
+ x = toJsStr(x);
658
+ return typeof x === "string" && x.length > 0;
659
+ });
878
660
  defineIfMissing("string-index", (s: any, needle: any) => {
879
661
  const i = String(s).indexOf(String(needle));
880
662
  return i < 0 ? false : i;
881
663
  });
882
- defineIfMissing("string-ci=?", (a: any, b: any) =>
883
- String(a).toLowerCase() === String(b).toLowerCase());
884
- defineIfMissing("string-ci<?", (a: any, b: any) =>
885
- String(a).toLowerCase() < String(b).toLowerCase());
886
- defineIfMissing("string-ci>?", (a: any, b: any) =>
887
- String(a).toLowerCase() > String(b).toLowerCase());
888
- defineIfMissing("string<=?", (a: any, b: any) => String(a) <= String(b));
889
- defineIfMissing("string>=?", (a: any, b: any) => String(a) >= String(b));
890
-
891
- // ── R7RS / SRFI-1 list gaps ────────────────────────────────
892
- defineIfMissing("list-tail", (lst: any, n: any) => {
893
- let k = Math.floor(Number(n) || 0);
894
- let cur: any = lst;
895
- while (k-- > 0 && cur instanceof Pair) cur = cur.cdr;
896
- return cur;
897
- });
898
- defineIfMissing("list-ref", (lst: any, n: any) => {
899
- let k = Math.floor(Number(n) || 0);
900
- let cur: any = lst;
901
- while (k-- > 0 && cur instanceof Pair) cur = cur.cdr;
902
- return cur instanceof Pair ? cur.car : false;
903
- });
664
+
904
665
  defineIfMissing("list-index", (pred: any, lst: any) => {
905
666
  let i = 0, cur: any = lst;
906
667
  while (cur instanceof Pair) {
@@ -940,13 +701,6 @@ function installStdShims(env: any): void {
940
701
  for (let i = args.length - 2; i >= 0; i--) tail = new Pair(args[i], tail);
941
702
  return tail;
942
703
  });
943
- defineIfMissing("list*", (...args: any[]) => {
944
- if (args.length === 0) return nil;
945
- if (args.length === 1) return args[0];
946
- let tail: any = args[args.length - 1];
947
- for (let i = args.length - 2; i >= 0; i--) tail = new Pair(args[i], tail);
948
- return tail;
949
- });
950
704
  defineIfMissing("append-reverse", (a: any, b: any) => {
951
705
  let cur: any = a, out: any = b;
952
706
  while (cur instanceof Pair) { out = new Pair(cur.car, out); cur = cur.cdr; }
@@ -989,26 +743,6 @@ function installStdShims(env: any): void {
989
743
  }
990
744
  return toSchemeList(out);
991
745
  });
992
- // LIPS' `range` is single-arg only; override (not defineIfMissing) so the
993
- // 1/2/3-arg Racket form wins.
994
- env.set("range", (a: any, b: any, step: any) => {
995
- let start: number, end: number, st: number;
996
- if (b === undefined) { start = 0; end = Number(a); st = 1; }
997
- else { start = Number(a); end = Number(b); st = step === undefined ? 1 : Number(step); }
998
- const out: number[] = [];
999
- if (st > 0) for (let i = start; i < end; i += st) out.push(i);
1000
- else if (st < 0) for (let i = start; i > end; i += st) out.push(i);
1001
- return toSchemeList(out);
1002
- });
1003
- defineIfMissing("flatten", (lst: any) => {
1004
- const out: any[] = [];
1005
- const walk = (x: any) => {
1006
- if (x instanceof Pair) { let c: any = x; while (c instanceof Pair) { walk(c.car); c = c.cdr; } }
1007
- else if (x !== nil) out.push(x);
1008
- };
1009
- walk(lst);
1010
- return toSchemeList(out);
1011
- });
1012
746
  defineIfMissing("index-of", (lst: any, x: any) => {
1013
747
  let i = 0, cur: any = lst;
1014
748
  while (cur instanceof Pair) {
@@ -1060,7 +794,6 @@ function installStdShims(env: any): void {
1060
794
  return toSchemeList(groups.map((g) => toSchemeList(g.items)));
1061
795
  });
1062
796
 
1063
- // ── Regex (Racket) ─────────────────────────────────────────
1064
797
  const reCompile = (pat: any): RegExp => {
1065
798
  if (pat instanceof RegExp) return pat;
1066
799
  let p = String(pat);
@@ -1074,10 +807,12 @@ function installStdShims(env: any): void {
1074
807
  };
1075
808
  defineIfMissing("regexp?", (x: any) => x instanceof RegExp);
1076
809
  defineIfMissing("regexp-replace", (pat: any, s: any, repl: any) => {
810
+ s = toJsStr(s);
1077
811
  if (typeof s !== "string") return s;
1078
812
  return s.replace(reCompile(pat), String(repl));
1079
813
  });
1080
814
  defineIfMissing("regexp-replace*", (pat: any, s: any, repl: any) => {
815
+ s = toJsStr(s);
1081
816
  if (typeof s !== "string") return s;
1082
817
  const re = reCompile(pat);
1083
818
  const global = re.flags.includes("g") ? re : new RegExp(re.source, re.flags + "g");
@@ -1085,10 +820,11 @@ function installStdShims(env: any): void {
1085
820
  });
1086
821
  defineIfMissing("regexp-quote", (s: any) =>
1087
822
  String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
1088
- defineIfMissing("regexp-split", (pat: any, s: any) =>
1089
- typeof s === "string" ? toSchemeList(s.split(reCompile(pat))) : nil);
823
+ defineIfMissing("regexp-split", (pat: any, s: any) => {
824
+ s = toJsStr(s);
825
+ return typeof s === "string" ? toSchemeList(s.split(reCompile(pat))) : nil;
826
+ });
1090
827
 
1091
- // ── Format (Racket) ────────────────────────────────────────
1092
828
  // format: simple ~a ~s ~v ~n support — covers most logging/inspection
1093
829
  defineIfMissing("format", (fmt: any, ...rest: any[]) => {
1094
830
  const f = String(fmt);
@@ -1117,7 +853,6 @@ function installStdShims(env: any): void {
1117
853
  defineIfMissing("~v", (...xs: any[]) =>
1118
854
  xs.map((x) => typeof x === "string" ? JSON.stringify(x) : (x === undefined ? "" : x.toString())).join(""));
1119
855
 
1120
- // ── Hash tables (Racket) ───────────────────────────────────
1121
856
  // Backed by JS Map. Stored as `LipsHash` symbol so we can pattern-match.
1122
857
  class LipsHash {
1123
858
  map: Map<any, any> = new Map();
@@ -1125,7 +860,7 @@ function installStdShims(env: any): void {
1125
860
  if (entries) for (const [k, v] of entries) this.map.set(this._key(k), v);
1126
861
  }
1127
862
  _key(k: any): any {
1128
- if (k instanceof LSymbol) return "::sym::" + (k as any).name;
863
+ if (k instanceof LSymbol) return "::sym::" + symName(k);
1129
864
  if (typeof k === "object" && k !== null) return JSON.stringify(k);
1130
865
  return k;
1131
866
  }
@@ -1182,20 +917,16 @@ function installStdShims(env: any): void {
1182
917
  defineIfMissing("hash-values", (h: any) => h instanceof LipsHash ? toSchemeList(h.values()) : nil);
1183
918
  defineIfMissing("hash-count", (h: any) => h instanceof LipsHash ? h.size() : 0);
1184
919
 
1185
- // ── Sort (R7RS-large / SRFI-132 / Racket) ──────────────────
1186
920
  // V8's Array.prototype.sort is stable (ES2019), so one impl serves all.
1187
921
  const sortImpl = (lst: any, less: any) => {
1188
922
  const arr = pairToArray(lst).slice();
1189
923
  arr.sort((a, b) => (truthy(less(a, b)) ? -1 : truthy(less(b, a)) ? 1 : 0));
1190
924
  return toSchemeList(arr);
1191
925
  };
1192
- defineIfMissing("sort", sortImpl);
1193
926
  defineIfMissing("sort!", sortImpl);
1194
927
  // SRFI-132 / R7RS-large flips the argument order.
1195
928
  defineIfMissing("list-sort", (less: any, lst: any) => sortImpl(lst, less));
1196
929
 
1197
- // ── Racket list aliases & gaps ─────────────────────────────
1198
- defineIfMissing("empty?", (v: any) => v === nil);
1199
930
  defineIfMissing("empty", nil);
1200
931
  defineIfMissing("cons?", (v: any) => v instanceof Pair);
1201
932
  defineIfMissing("andmap", (pred: any, lst: any) => pairToArray(lst).every((x) => truthy(pred(x))));
@@ -1213,11 +944,6 @@ function installStdShims(env: any): void {
1213
944
  }
1214
945
  return false;
1215
946
  });
1216
- defineIfMissing("make-list", (n: any, v: any) => {
1217
- const count = Math.max(0, Math.floor(Number(n) || 0));
1218
- const fill = v === undefined ? nil : v;
1219
- return toSchemeList(Array(count).fill(fill));
1220
- });
1221
947
  defineIfMissing("build-list", (n: any, proc: any) => {
1222
948
  const count = Math.max(0, Math.floor(Number(n) || 0));
1223
949
  const out: any[] = [];
@@ -1239,14 +965,6 @@ function installStdShims(env: any): void {
1239
965
  const k = Math.max(0, Math.floor(Number(n) || 0));
1240
966
  return new Pair(toSchemeList(a.slice(0, k)), toSchemeList(a.slice(k)));
1241
967
  });
1242
- defineIfMissing("shuffle", (lst: any) => {
1243
- const a = pairToArray(lst).slice();
1244
- for (let i = a.length - 1; i > 0; i--) {
1245
- const j = Math.floor(Math.random() * (i + 1));
1246
- [a[i], a[j]] = [a[j], a[i]];
1247
- }
1248
- return toSchemeList(a);
1249
- });
1250
968
  defineIfMissing("add-between", (lst: any, sep: any) => {
1251
969
  const a = pairToArray(lst);
1252
970
  if (a.length < 2) return toSchemeList(a);
@@ -1368,16 +1086,8 @@ function installStdShims(env: any): void {
1368
1086
  return toSchemeList(arr.filter((x) => !targets.some((t) => lipsEqual(t, x))));
1369
1087
  });
1370
1088
 
1371
- // ── Racket numbers (gaps) ──────────────────────────────────
1372
1089
  defineIfMissing("pi", Math.PI);
1373
1090
  // Racket overloads: (random) 0≤x<1, (random k) 0≤i<k, (random lo hi).
1374
- defineIfMissing("random", (a: any, b: any) => {
1375
- if (a === undefined) return Math.random();
1376
- if (b === undefined) return Math.floor(Math.random() * Math.max(0, Math.floor(Number(a))));
1377
- const lo = Math.floor(Number(a));
1378
- const hi = Math.floor(Number(b));
1379
- return lo + Math.floor(Math.random() * Math.max(0, hi - lo));
1380
- });
1381
1091
  defineIfMissing("exact-floor", (n: any) => Math.floor(Number(n)));
1382
1092
  defineIfMissing("exact-ceiling", (n: any) => Math.ceil(Number(n)));
1383
1093
  defineIfMissing("exact-round", (n: any) => Math.round(Number(n)));
@@ -1396,7 +1106,6 @@ function installStdShims(env: any): void {
1396
1106
  return Number(n).toFixed(d);
1397
1107
  });
1398
1108
 
1399
- // ── Racket strings & chars (gaps) ──────────────────────────
1400
1109
  defineIfMissing("string-titlecase", (s: any) =>
1401
1110
  String(s).replace(/\b([a-z])/g, (_, c) => c.toUpperCase()));
1402
1111
  defineIfMissing("string-pad", (s: any, width: any, ch?: any) => {
@@ -1411,27 +1120,6 @@ function installStdShims(env: any): void {
1411
1120
  const c = ch === undefined ? " " : String(ch).charAt(0) || " ";
1412
1121
  return str.length >= w ? str : str + c.repeat(w - str.length);
1413
1122
  });
1414
- defineIfMissing("char-upcase", (c: any) => String(c).toUpperCase());
1415
- defineIfMissing("char-downcase", (c: any) => String(c).toLowerCase());
1416
- // Characters are 1-char strings; char? therefore can't tell a char from a
1417
- // length-1 string.
1418
- const charStr = (c: any) => (typeof c === "string" ? c : String(c));
1419
- const codeOf = (c: any) => charStr(c).codePointAt(0) ?? 0;
1420
- defineIfMissing("char->integer", (c: any) => codeOf(c));
1421
- defineIfMissing("integer->char", (n: any) => String.fromCodePoint(Math.max(0, Math.floor(Number(n) || 0))));
1422
- defineIfMissing("char?", (x: any) => typeof x === "string" && Array.from(x).length === 1);
1423
- defineIfMissing("char=?", (a: any, b: any) => charStr(a) === charStr(b));
1424
- defineIfMissing("char<?", (a: any, b: any) => codeOf(a) < codeOf(b));
1425
- defineIfMissing("char>?", (a: any, b: any) => codeOf(a) > codeOf(b));
1426
- defineIfMissing("char<=?", (a: any, b: any) => codeOf(a) <= codeOf(b));
1427
- defineIfMissing("char>=?", (a: any, b: any) => codeOf(a) >= codeOf(b));
1428
- defineIfMissing("char-ci=?", (a: any, b: any) => charStr(a).toLowerCase() === charStr(b).toLowerCase());
1429
- defineIfMissing("char-alphabetic?", (c: any) => /^\p{L}$/u.test(charStr(c)));
1430
- defineIfMissing("char-numeric?", (c: any) => /^\p{Nd}$/u.test(charStr(c)));
1431
- defineIfMissing("char-whitespace?", (c: any) => /^\s$/.test(charStr(c)));
1432
- defineIfMissing("char-upper-case?", (c: any) => { const s = charStr(c); return s.length === 1 && s === s.toUpperCase() && s !== s.toLowerCase(); });
1433
- defineIfMissing("char-lower-case?", (c: any) => { const s = charStr(c); return s.length === 1 && s === s.toLowerCase() && s !== s.toUpperCase(); });
1434
- defineIfMissing("digit-value", (c: any) => { const s = charStr(c); return /^[0-9]$/.test(s) ? Number(s) : false; });
1435
1123
  defineIfMissing("string-normalize-spaces", (s: any, sep?: any, repl?: any) => {
1436
1124
  const str = String(s).trim();
1437
1125
  const splitOn = sep === undefined ? /\s+/ : (sep instanceof RegExp ? sep : new RegExp(String(sep)));
@@ -1445,7 +1133,6 @@ function installStdShims(env: any): void {
1445
1133
  return out;
1446
1134
  });
1447
1135
 
1448
- // ── Racket hash (gaps) ─────────────────────────────────────
1449
1136
  defineIfMissing("hash-update!", (h: any, k: any, upd: any, dflt: any) => {
1450
1137
  if (!(h instanceof LipsHash)) return h;
1451
1138
  const cur = h.has(k) ? h.get(k) : (typeof dflt === "function" ? dflt() : dflt);
@@ -1524,14 +1211,12 @@ function installStdShims(env: any): void {
1524
1211
  defineIfMissing("make-hasheqv", (alist?: any) => (env as any).get("make-hash")(alist));
1525
1212
  defineIfMissing("make-immutable-hash", (alist?: any) => (env as any).get("make-hash")(alist));
1526
1213
 
1527
- // ── Racket boxes (mutable cells) ───────────────────────────
1528
1214
  class LipsBox { constructor(public v: any) {} }
1529
1215
  defineIfMissing("box", (v: any) => new LipsBox(v));
1530
1216
  defineIfMissing("box?", (x: any) => x instanceof LipsBox);
1531
1217
  defineIfMissing("unbox", (b: any) => b instanceof LipsBox ? b.v : b);
1532
1218
  defineIfMissing("set-box!", (b: any, v: any) => { if (b instanceof LipsBox) b.v = v; return undefined; });
1533
1219
 
1534
- // ── Environment introspection ──────────────────────────────
1535
1220
  const collectEnvNames = (): string[] => {
1536
1221
  const seen = new Set<string>();
1537
1222
  let cur: any = env;
@@ -1543,19 +1228,17 @@ function installStdShims(env: any): void {
1543
1228
  return Array.from(seen).sort();
1544
1229
  };
1545
1230
  defineIfMissing("defined?", (sym: any) => {
1546
- const name = sym instanceof LSymbol ? (sym as any).name : String(sym);
1231
+ const name = sym instanceof LSymbol ? symName(sym) : String(sym);
1547
1232
  return (env as any).get(name, { throwError: false }) !== undefined;
1548
1233
  });
1549
1234
  const aproposImpl = (pat: any) => {
1550
- const needle = pat instanceof LSymbol ? (pat as any).name : String(pat ?? "");
1235
+ const needle = pat instanceof LSymbol ? (symName(pat) ?? "") : String(pat ?? "");
1551
1236
  const re = pat instanceof RegExp ? pat : null;
1552
1237
  const match = (n: string) => re ? re.test(n) : n.includes(needle);
1553
1238
  return toSchemeList(collectEnvNames().filter(match).map((n) => new LSymbol(n)));
1554
1239
  };
1555
- defineIfMissing("apropos", aproposImpl);
1556
1240
  defineIfMissing("apropos-list", aproposImpl);
1557
1241
 
1558
- // ── Misc Racket ────────────────────────────────────────────
1559
1242
  defineIfMissing("current-seconds", () => Math.floor(Date.now() / 1000));
1560
1243
  defineIfMissing("current-milliseconds", () => Date.now());
1561
1244
  defineIfMissing("current-inexact-milliseconds", () => performance.now());
@@ -1564,7 +1247,7 @@ function installStdShims(env: any): void {
1564
1247
  // Canonical names we claim coverage for. R = R7RS small base; S = SRFI-1;
1565
1248
  // K = Racket racket/base/list/string/format. Continuations, ports, and
1566
1249
  // bytevectors are intentionally omitted — they'd be misleading "coverage"
1567
- // without real functionality. Characters are modeled as 1-char strings.
1250
+ // without real functionality.
1568
1251
  const COVERAGE_CHECKLIST: string[] = [
1569
1252
  // R7RS § 6.1 equivalence
1570
1253
  "eq?", "eqv?", "equal?",
@@ -1685,7 +1368,7 @@ function auditShimCoverage(env: any): { defined: number; missing: string[] } {
1685
1368
  function unwrapSchemeBool(v: any): any {
1686
1369
  if (v === true || v === false) return v;
1687
1370
  if (v instanceof LSymbol) {
1688
- const n = (v as any).name;
1371
+ const n = symName(v);
1689
1372
  if (n === "#t") return true;
1690
1373
  if (n === "#f") return false;
1691
1374
  }
@@ -1694,38 +1377,94 @@ function unwrapSchemeBool(v: any): any {
1694
1377
  return v;
1695
1378
  }
1696
1379
 
1697
- // Convert a Scheme alist ((kebab-key . val) …) into the JS object shape
1698
- // the underlying tool expects. Renames keys (kebab→snake), coerces numeric
1699
- // values, and normalizes booleans. Returns {} for non-Pair input.
1700
- function readOptions(
1701
- value: any,
1702
- keyMap: Record<string, string>,
1703
- numericKeys?: Set<string>,
1704
- ): Record<string, unknown> {
1705
- const out: Record<string, unknown> = {};
1706
- if (!(value instanceof Pair)) return out;
1707
- let node: any = value;
1708
- while (node instanceof Pair) {
1709
- const entry = node.car;
1710
- if (entry instanceof Pair && entry.car instanceof LSymbol) {
1711
- const k = (entry.car as any).name;
1712
- const tgt = keyMap[k];
1713
- if (tgt !== undefined) {
1714
- let v: any = entry.cdr;
1715
- if (numericKeys && numericKeys.has(tgt)) v = Number(v);
1716
- else v = unwrapSchemeBool(v);
1717
- out[tgt] = v;
1380
+ // Single source of truth for the host primitive surface: drives both the tool
1381
+ // description and `(help …)`, so what the model reads matches what it can
1382
+ // introspect at runtime. Each binding returns the natural Scheme value for its
1383
+ // job; bash returns a record because the exit code has nowhere else to live (a
1384
+ // plain bash tool call drops it before the model ever sees it).
1385
+ type HostSig = { name: string; sig: string; ret: string; doc: string };
1386
+ const HOST_SIGS: HostSig[] = [
1387
+ { name: "bash", sig: '(bash "cmd" [:timeout sec])',
1388
+ ret: "((output . str) (exit-code . n) (error . bool))",
1389
+ doc: "run a shell command; full result. Accessors: output-of exit-code-of ok? error?" },
1390
+ { name: "sh", sig: '(sh "cmd" [:timeout sec])', ret: "str",
1391
+ doc: "run a shell command, return stdout only (stderr text on failure)" },
1392
+ { name: "read-file", sig: '(read-file "path" [:offset n] [:limit n])', ret: "str | #f",
1393
+ doc: "file contents, or #f on error. :offset is 1-indexed; :limit caps lines" },
1394
+ { name: "write-file", sig: '(write-file "path" "content")', ret: "#t | err-str",
1395
+ doc: "overwrite a file" },
1396
+ { name: "edit-file", sig: '(edit-file "path" "old" "new" [:replace-all #t])', ret: "#t | err-str",
1397
+ doc: "replace exact text; :replace-all #t replaces every occurrence" },
1398
+ { name: "grep", sig: '(grep "pat" ["dir"] [:opt val …])',
1399
+ ret: "(listof ((file . str) (line . n) (text . str)))",
1400
+ doc: "ripgrep search. options: :path :include :case-insensitive :context-before :context-after :limit :offset" },
1401
+ { name: "grep-files", sig: '(grep-files "pat" ["dir"] [:opt val …])', ret: "(listof str)",
1402
+ doc: "files containing a match. options: :path :include :case-insensitive :limit :offset" },
1403
+ { name: "glob", sig: '(glob "pat" [:path "dir"])', ret: "(listof str)",
1404
+ doc: "paths matching a glob, mtime-sorted" },
1405
+ ];
1406
+ const sigLine = (h: HostSig): string => `${h.sig} → ${h.ret}`;
1407
+ const sigForName = (name: string): string => {
1408
+ const h = HOST_SIGS.find((s) => s.name === name);
1409
+ return h ? sigLine(h) : name;
1410
+ };
1411
+
1412
+ // Append a primitive's signature to any exception it throws, so a malformed
1413
+ // call teaches the right shape in one round-trip instead of a bare stack.
1414
+ function withSig(name: string, fn: (...a: any[]) => Promise<any>) {
1415
+ return async (...a: any[]) => {
1416
+ try {
1417
+ return await fn(...a);
1418
+ } catch (e: any) {
1419
+ const base = e?.message ?? String(e);
1420
+ if (!String(base).includes("signature:")) {
1421
+ try { e.message = `${base}\n signature: ${sigForName(name)}`; } catch { /* frozen */ }
1718
1422
  }
1423
+ throw e;
1719
1424
  }
1720
- node = node.cdr;
1425
+ };
1426
+ }
1427
+
1428
+ const isKwSym = (x: any): boolean => {
1429
+ const n = symName(x);
1430
+ return typeof n === "string" && n.startsWith(":");
1431
+ };
1432
+
1433
+ // Split a primitive's args into leading positionals and a trailing :key value
1434
+ // option map. An unknown key throws so a wrong option name teaches the valid set.
1435
+ function splitArgs(
1436
+ args: any[], keyMap: Record<string, string>, numericKeys?: Set<string>,
1437
+ ): { positionals: any[]; opts: Record<string, unknown> } {
1438
+ const positionals: any[] = [];
1439
+ let i = 0;
1440
+ while (i < args.length && !isKwSym(args[i])) { positionals.push(args[i]); i++; }
1441
+ const opts: Record<string, unknown> = {};
1442
+ while (i < args.length) {
1443
+ if (!isKwSym(args[i])) { i++; continue; }
1444
+ const key = symName(args[i])!.slice(1);
1445
+ const tgt = keyMap[key];
1446
+ if (tgt === undefined) {
1447
+ const valid = Object.keys(keyMap).map((k) => `:${k}`).join(" ");
1448
+ throw new Error(`unknown option :${key}; valid options: ${valid || "(none)"}`);
1449
+ }
1450
+ const raw = args[i + 1];
1451
+ opts[tgt] = numericKeys && numericKeys.has(tgt)
1452
+ ? Number(raw)
1453
+ : unwrapSchemeBool(toJsStr(raw));
1454
+ i += 2;
1721
1455
  }
1722
- return out;
1456
+ return { positionals, opts };
1723
1457
  }
1724
1458
 
1459
+ // Cache executors on globalThis (survives reload's module-cache bust): in
1460
+ // schemeOnly the built-ins get unregistered, but the tool objects outlive it.
1461
+ const EXECUTOR_CACHE: Record<string, ToolExecutor> =
1462
+ ((globalThis as any).__ashSchemeExecutors ??= {});
1725
1463
  function resolveExecutor(ctx: AgentContext, name: string): ToolExecutor {
1726
1464
  const tool = ctx.agent.getTools().find((t) => t.name === name);
1727
- if (!tool) throw new Error(`scheme bridge: tool '${name}' not registered`);
1728
- return (args) => tool.execute(args);
1465
+ if (tool) return (EXECUTOR_CACHE[name] = (args) => tool.execute(args));
1466
+ if (EXECUTOR_CACHE[name]) return EXECUTOR_CACHE[name];
1467
+ throw new Error(`scheme bridge: tool '${name}' not registered`);
1729
1468
  }
1730
1469
 
1731
1470
  function installBindings(
@@ -1738,55 +1477,87 @@ function installBindings(
1738
1477
  grep: ToolExecutor | null,
1739
1478
  glob: ToolExecutor | null,
1740
1479
  ): void {
1480
+ // Optional args are :key value pairs; a trailing positional is still accepted.
1481
+ const READ_KEYMAP: Record<string, string> = { "offset": "offset", "limit": "limit" };
1482
+ const READ_NUMERIC = new Set(["offset", "limit"]);
1483
+ const BASH_KEYMAP: Record<string, string> = { "timeout": "timeout" };
1484
+ const BASH_NUMERIC = new Set(["timeout"]);
1485
+ const EDIT_KEYMAP: Record<string, string> = { "replace-all": "replace_all" };
1486
+ const GLOB_KEYMAP: Record<string, string> = { "path": "path" };
1487
+ const GREP_KEYMAP: Record<string, string> = {
1488
+ "include": "include",
1489
+ "case-insensitive": "case_insensitive",
1490
+ "context-before": "context_before",
1491
+ "context-after": "context_after",
1492
+ "limit": "head_limit",
1493
+ "offset": "offset",
1494
+ "path": "path",
1495
+ };
1496
+ const GREP_NUMERIC = new Set([
1497
+ "context_before", "context_after", "head_limit", "offset",
1498
+ ]);
1499
+
1500
+ // LIPS has no keyword-argument syntax: bind each option key to a self-quoting
1501
+ // symbol so a bare `:offset` yields the symbol instead of an unbound error.
1502
+ for (const km of [READ_KEYMAP, BASH_KEYMAP, EDIT_KEYMAP, GLOB_KEYMAP, GREP_KEYMAP]) {
1503
+ for (const key of Object.keys(km)) {
1504
+ const kw = `:${key}`;
1505
+ env.set(kw, new LSymbol(kw));
1506
+ }
1507
+ }
1508
+
1741
1509
  const runBash = async (command: string, timeoutSec?: number) => {
1742
- const args: Record<string, unknown> = { command };
1510
+ const args: Record<string, unknown> = { command: toJsStr(command) };
1743
1511
  if (typeof timeoutSec === "number") args.timeout = timeoutSec;
1744
1512
  const result = await bash(args);
1745
- let content = typeof result.content === "string" ? result.content : String(result.content ?? "");
1513
+ let output = typeof result.content === "string" ? result.content : String(result.content ?? "");
1746
1514
  // Undo bash.ts's "(no output)" sentinel so `(eq? out "")` works.
1747
- if (content === "(no output)") content = "";
1748
- return {
1749
- exitCode: result.exitCode ?? (result.isError ? 1 : 0),
1750
- stdout: content,
1751
- stderr: result.isError ? content : "",
1752
- success: !result.isError,
1753
- };
1515
+ if (output === "(no output)") output = "";
1516
+ // stdout and stderr are merged upstream — the bash tool surfaces only a
1517
+ // combined `content` so the record exposes one `output`, not a fabricated
1518
+ // split. `exit-code` is the real shell code and the only channel that
1519
+ // carries it: a plain bash tool call drops it before the model.
1520
+ return { exitCode: result.exitCode ?? (result.isError ? 1 : 0), output, error: result.isError };
1754
1521
  };
1755
- env.set("bash", async (command: string, timeoutSec?: number) => {
1522
+ const bashTimeout = (positionals: any[], opts: Record<string, unknown>): number | undefined => {
1523
+ const t = opts.timeout !== undefined ? opts.timeout : positionals[1];
1524
+ if (t === undefined || t === null) return undefined;
1525
+ const n = Number(t);
1526
+ return isNaN(n) ? undefined : n;
1527
+ };
1528
+ env.set("bash", withSig("bash", async (...rest: any[]) => {
1529
+ const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
1530
+ const command = positionals[0];
1756
1531
  try {
1757
- const r = await runBash(command, timeoutSec);
1532
+ const r = await runBash(command, bashTimeout(positionals, opts));
1758
1533
  return alist([
1534
+ ["output", r.output],
1759
1535
  ["exit-code", r.exitCode],
1760
- ["stdout", r.stdout],
1761
- ["stderr", r.stderr],
1762
- ["success", r.success],
1536
+ ["error", r.error],
1763
1537
  ]);
1764
1538
  } catch (e: any) {
1765
1539
  logErr("bash", e, { command, typeofCommand: typeof command });
1766
1540
  throw e;
1767
1541
  }
1768
- });
1769
- // Shortcut: return stdout as string. Use `bash` when you need exit-code/stderr.
1770
- env.set("sh", async (command: string, timeoutSec?: number) => {
1542
+ }));
1543
+ // Shortcut: stdout as a string. Use `bash` when you need the exit code, or
1544
+ // `(ok? (bash "…"))` for a success predicate.
1545
+ env.set("sh", withSig("sh", async (...rest: any[]) => {
1546
+ const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
1547
+ const command = positionals[0];
1771
1548
  try {
1772
- const r = await runBash(command, timeoutSec);
1773
- return r.stdout;
1549
+ return (await runBash(command, bashTimeout(positionals, opts))).output;
1774
1550
  } catch (e: any) {
1775
1551
  logErr("sh", e, { command });
1776
1552
  return "";
1777
1553
  }
1778
- });
1779
- env.set("sh-ok?", async (command: string, timeoutSec?: number) => {
1780
- try {
1781
- const r = await runBash(command, timeoutSec);
1782
- return r.success;
1783
- } catch {
1784
- return false;
1785
- }
1786
- });
1554
+ }));
1787
1555
 
1788
- env.set("read-file", async (filePath: string, offset?: any, limit?: any) => {
1789
- const args: Record<string, unknown> = { path: filePath, bypass_cache: true };
1556
+ env.set("read-file", withSig("read-file", async (...rest: any[]) => {
1557
+ const { positionals, opts } = splitArgs(rest, READ_KEYMAP, READ_NUMERIC);
1558
+ const args: Record<string, unknown> = { path: toJsStr(positionals[0]), bypass_cache: true };
1559
+ const offset = opts.offset !== undefined ? opts.offset : positionals[1];
1560
+ const limit = opts.limit !== undefined ? opts.limit : positionals[2];
1790
1561
  if (offset !== undefined && offset !== null) {
1791
1562
  const n = Number(offset);
1792
1563
  if (!isNaN(n)) args.offset = n;
@@ -1797,19 +1568,25 @@ function installBindings(
1797
1568
  }
1798
1569
  const result = await readFile(args);
1799
1570
  return result.isError ? false : result.content;
1800
- });
1571
+ }));
1801
1572
 
1802
- env.set("write-file", async (filePath: string, content: string) => {
1573
+ env.set("write-file", withSig("write-file", async (filePath: string, content: string) => {
1574
+ filePath = toJsStr(filePath); content = toJsStr(content);
1803
1575
  // Re-emit tool lifecycle events so the TUI shows diffs.
1804
1576
  const result = await withDisplay(
1805
1577
  bus, "write_file", "write", { path: filePath, content }, filePath,
1806
1578
  () => writeFile({ path: filePath, content }),
1807
1579
  );
1808
1580
  return result.isError ? result.content : true;
1809
- });
1581
+ }));
1810
1582
 
1811
1583
  if (editFile) {
1812
- env.set("edit-file", async (filePath: string, oldStr: string, newStr: string, replaceAll?: any) => {
1584
+ env.set("edit-file", withSig("edit-file", async (...rest: any[]) => {
1585
+ const { positionals, opts } = splitArgs(rest, EDIT_KEYMAP);
1586
+ const filePath = toJsStr(positionals[0]);
1587
+ const oldStr = toJsStr(positionals[1]);
1588
+ const newStr = toJsStr(positionals[2]);
1589
+ const replaceAll = opts.replace_all !== undefined ? opts.replace_all : positionals[3];
1813
1590
  const toolArgs: Record<string, unknown> = { path: filePath, old_text: oldStr, new_text: newStr };
1814
1591
  if (unwrapSchemeBool(replaceAll) === true) toolArgs.replace_all = true;
1815
1592
  const result = await withDisplay(
@@ -1817,22 +1594,10 @@ function installBindings(
1817
1594
  () => editFile(toolArgs),
1818
1595
  );
1819
1596
  return result.isError ? result.content : true;
1820
- });
1597
+ }));
1821
1598
  }
1822
1599
 
1823
1600
  if (grep) {
1824
- const GREP_KEYMAP = {
1825
- "include": "include",
1826
- "case-insensitive": "case_insensitive",
1827
- "context-before": "context_before",
1828
- "context-after": "context_after",
1829
- "limit": "head_limit",
1830
- "offset": "offset",
1831
- } as const;
1832
- const GREP_NUMERIC = new Set([
1833
- "context_before", "context_after", "head_limit", "offset",
1834
- ]);
1835
-
1836
1601
  // Ripgrep uses Rust/ERE regex, but models write BRE (the default flavor
1837
1602
  // of plain grep/sed) where \| \( \) \{ \} \+ \? are metacharacters.
1838
1603
  // Translate BRE escapes to their ERE equivalents so the model's intent is
@@ -1845,97 +1610,97 @@ function installBindings(
1845
1610
  .replace(/\\\+/g, "+").replace(/\\\?/g, "?");
1846
1611
  };
1847
1612
 
1848
- env.set("%grep", async (pattern: string, p?: string, third?: any) => {
1849
- const args: Record<string, unknown> = { pattern: normalizePattern(String(pattern ?? "")), output_mode: "content" };
1850
- if (typeof p === "string") args.path = p;
1851
- if (third instanceof Pair) {
1852
- Object.assign(args, readOptions(third, GREP_KEYMAP, GREP_NUMERIC));
1853
- } else if (third !== undefined && third !== null) {
1854
- // Back-compat: third positional arg as numeric limit.
1855
- const n = Number(third);
1856
- if (!isNaN(n)) args.head_limit = n;
1857
- }
1613
+ // pattern is positional; search root is the 2nd positional or :path.
1614
+ env.set("grep", withSig("grep", async (...rest: any[]) => {
1615
+ const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1616
+ const args: Record<string, unknown> = {
1617
+ pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "content", ...opts,
1618
+ };
1619
+ const posPath = toJsStr(positionals[1]);
1620
+ if (args.path === undefined && typeof posPath === "string") args.path = posPath;
1621
+ const pStr = typeof args.path === "string" ? args.path : undefined;
1858
1622
  const result = await grep(args);
1859
1623
  if (result.isError) return nil;
1860
1624
  if (result.content === "No matches found.") return nil;
1861
1625
  const rows: unknown[] = [];
1862
- for (const line of stripPagination(result.content)) {
1863
- const parsed = parseGrepLine(line, typeof p === "string" ? p : undefined);
1626
+ for (const line of stripPagination(result.content as string)) {
1627
+ const parsed = parseGrepLine(line, pStr);
1864
1628
  if (parsed) rows.push(parsed);
1865
1629
  }
1866
1630
  return toSchemeList(rows);
1867
- });
1631
+ }));
1868
1632
 
1869
- env.set("%grep-files", async (pattern: string, p?: string, opts?: any) => {
1870
- const args: Record<string, unknown> = { pattern: normalizePattern(String(pattern ?? "")), output_mode: "files_with_matches" };
1871
- if (typeof p === "string") args.path = p;
1872
- if (opts instanceof Pair) Object.assign(args, readOptions(opts, GREP_KEYMAP, GREP_NUMERIC));
1633
+ env.set("grep-files", withSig("grep-files", async (...rest: any[]) => {
1634
+ const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1635
+ const args: Record<string, unknown> = {
1636
+ pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "files_with_matches", ...opts,
1637
+ };
1638
+ const posPath = toJsStr(positionals[1]);
1639
+ if (args.path === undefined && typeof posPath === "string") args.path = posPath;
1873
1640
  const result = await grep(args);
1874
1641
  if (result.isError || result.content === "No matches found.") return nil;
1875
- return toSchemeList(stripPagination(result.content));
1876
- });
1642
+ return toSchemeList(stripPagination(result.content as string));
1643
+ }));
1877
1644
  }
1878
1645
 
1879
1646
  if (glob) {
1880
1647
  // Strip leading "./" so glob paths match grep's — otherwise eq? on the
1881
1648
  // file field fails across the two.
1882
- env.set("glob", async (pattern: string, p?: string) => {
1883
- const args: Record<string, unknown> = { pattern };
1884
- if (typeof p === "string") args.path = p;
1649
+ env.set("glob", withSig("glob", async (...rest: any[]) => {
1650
+ const { positionals, opts } = splitArgs(rest, GLOB_KEYMAP);
1651
+ const args: Record<string, unknown> = { pattern: toJsStr(positionals[0]) };
1652
+ const pStr = opts.path !== undefined ? String(opts.path) : toJsStr(positionals[1]);
1653
+ if (typeof pStr === "string") args.path = pStr;
1885
1654
  const result = await glob(args);
1886
1655
  if (result.isError || result.content === "No files matched.") return nil;
1887
- const paths = stripPagination(result.content).map((l) =>
1656
+ const paths = stripPagination(result.content as string).map((l) =>
1888
1657
  l.startsWith("./") ? l.slice(2) : l,
1889
1658
  );
1890
1659
  return toSchemeList(paths);
1891
- });
1660
+ }));
1892
1661
  }
1893
1662
 
1894
- // Shell-result accessors — JS-side so they're never missing.
1663
+ // Accessors on a bash result — JS-side so they're never missing.
1664
+ env.set("output-of", (r: unknown) => lookup(r, "output"));
1895
1665
  env.set("exit-code-of", (r: unknown) => lookup(r, "exit-code"));
1896
- env.set("stdout-of", (r: unknown) => lookup(r, "stdout"));
1897
- env.set("stderr-of", (r: unknown) => lookup(r, "stderr"));
1898
- env.set("success?", (r: unknown) => lookup(r, "success") === true);
1666
+ env.set("error?", (r: unknown) => lookup(r, "error") === true);
1667
+ env.set("ok?", (r: unknown) => lookup(r, "error") === false);
1668
+
1669
+ // Runtime discovery: (help) lists available host primitives; (help 'grep)
1670
+ // shows one. Filtered to what actually got bound this session.
1671
+ const availableNames = new Set<string>(["bash", "sh", "read-file", "write-file"]);
1672
+ if (editFile) availableNames.add("edit-file");
1673
+ if (grep) { availableNames.add("grep"); availableNames.add("grep-files"); }
1674
+ if (glob) availableNames.add("glob");
1675
+ const availableSigs = HOST_SIGS.filter((h) => availableNames.has(h.name));
1676
+ env.set("help", (name?: any) => {
1677
+ if (name === undefined || name === null) {
1678
+ return availableSigs.map(sigLine).join("\n");
1679
+ }
1680
+ const key = String(name instanceof LSymbol ? symName(name) : toJsStr(name)).replace(/^:/, "");
1681
+ const h = availableSigs.find((s) => s.name === key);
1682
+ return h ? `${sigLine(h)}\n ${h.doc}` : `no host primitive named ${key}; try (help) for the list`;
1683
+ });
1899
1684
 
1900
1685
  // R7RS / string helpers LIPS doesn't ship.
1901
- env.set("string-length", (s: unknown) => (typeof s === "string" ? s.length : 0));
1902
- const stringContains = (s: unknown, needle: unknown) =>
1903
- typeof s === "string" && typeof needle === "string" && s.includes(needle);
1686
+ const stringContains = (s: unknown, needle: unknown) => {
1687
+ s = toJsStr(s); needle = toJsStr(needle);
1688
+ return typeof s === "string" && typeof needle === "string" && s.includes(needle);
1689
+ };
1904
1690
  env.set("string-contains?", stringContains);
1905
1691
  // Racket spells it without the `?`. Bind both so the model isn't punished
1906
1692
  // for guessing dialect.
1907
1693
  env.set("string-contains", stringContains);
1908
- env.set("string-append", (...parts: unknown[]) =>
1909
- parts.map((p) => (p === undefined || p === null ? "" : String(p))).join(""));
1910
- // LIPS' native `string` returns only its first argument; override to actually
1911
- // concatenate (chars are 1-char strings here, so that's the right semantics).
1912
- env.set("string", (...parts: unknown[]) =>
1913
- parts.map((p) => (p === undefined || p === null ? "" : String(p))).join(""));
1914
- env.set("number->string", (n: unknown) => String(n));
1915
- env.set("string->number", (s: unknown) => {
1916
- if (typeof s !== "string") return false;
1917
- const n = Number(s);
1918
- return Number.isNaN(n) ? false : n;
1919
- });
1920
- env.set("symbol->string", (sym: any) => (sym && sym.name) ? sym.name : String(sym));
1921
- // LIPS doesn't ship max/min — useful enough that not having them breaks
1922
- // common idioms like `(max 1 (- n 5))` for line-bound clamping.
1923
- env.set("max", (...args: any[]) => args.reduce((a, b) => (Number(a) >= Number(b) ? a : b)));
1924
- env.set("min", (...args: any[]) => args.reduce((a, b) => (Number(a) <= Number(b) ? a : b)));
1925
- installStdShims(env);
1926
- // Global string substitution — LIPS' built-in `replace` with a string
1927
- // pattern only replaces the first match (it calls JS String.replace).
1928
- // This binding replaces every occurrence, sed-style.
1694
+ // Global string substitution: `replace` with a string pattern replaces only
1695
+ // the first match; this binding replaces every occurrence, sed-style.
1929
1696
  env.set("string-replace", (oldStr: unknown, newStr: unknown, s: unknown) => {
1697
+ s = toJsStr(s);
1930
1698
  if (typeof s !== "string") return s;
1931
1699
  return s.split(String(oldStr ?? "")).join(String(newStr ?? ""));
1932
1700
  });
1933
1701
 
1934
- // LIPS' tokenizer doesn't recognize R7RS `#t`/`#f` — they parse as
1935
- // unbound symbols. Bind them so the natural R7RS reflex works.
1936
- env.set("#t", true);
1937
- env.set("#f", false);
1938
1702
  env.set("lines", (s: unknown) => {
1703
+ s = toJsStr(s);
1939
1704
  if (typeof s !== "string" || s.length === 0) return nil;
1940
1705
  const parts = s.split("\n");
1941
1706
  if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
@@ -1946,102 +1711,66 @@ function installBindings(
1946
1711
  }
1947
1712
 
1948
1713
  // ── tool registration ─────────────────────────────────────────────
1714
+ // Generated from HOST_SIGS so the catalog the model reads matches what
1715
+ // `(help …)` reports at runtime.
1716
+ const HOST_BINDINGS_BLOCK = HOST_SIGS.map(
1717
+ (h) => ` ${h.sig.padEnd(44)} → ${h.ret}\n ${h.doc}`,
1718
+ ).join("\n");
1719
+
1949
1720
  const DESCRIPTION = [
1950
1721
  "Evaluate a Scheme expression (R7RS-compatible).",
1951
1722
  "",
1952
- "A Scheme runtime with host bindings to the shell, filesystem, search,",
1953
- "and file editing. Each submission is parsed and evaluated against an",
1954
- "environment that persists across calls within the session — `define`s",
1955
- "in one submission are available in the next.",
1723
+ "A Scheme runtime with host bindings to the shell, filesystem, and search.",
1724
+ "The environment persists across calls within a session `define`s in one",
1725
+ "submission are visible in the next.",
1726
+ "",
1727
+ "You already know the language: assume the full R7RS / SRFI-1 / Racket stdlib",
1728
+ "is present (map filter fold assoc, string-* and list ops, cond/when/unless,",
1729
+ "char and hash-table ops, …). The only novel surface is the host bindings.",
1956
1730
  "",
1957
- "Productive patterns:",
1958
- " - Host bindings (`grep`, `glob`, `read-file`, ) return Scheme data,",
1959
- " so the output of one can feed into the next without re-parsing.",
1960
- " `(map proc (grep \"pat\" \"src/\"))` is the natural shape.",
1961
- " - Read-only calls (`read-file`, `grep`, `glob`, `sh` for queries) have",
1962
- " no side effects and can be batched in one submission — bind several",
1963
- " with `let`/`define` and assemble the answer locally, instead of",
1964
- " issuing each as a separate tool round. Side-effecting calls",
1965
- " (`write-file`, `edit-file`, mutating `bash`) are clearer one step",
1966
- " at a time so you can react to each result.",
1967
- " - The env persists across submissions, so binding intermediate",
1968
- " results once (e.g. `(define files (glob …))`) avoids recomputing",
1969
- " them in later calls.",
1970
- " - `(bash …)` calls a real shell — natural for shell-shaped work",
1971
- " (tests, builds, git, system commands). Three variants of the shell",
1972
- " binding return different shapes:",
1973
- " `(sh \"cmd\")` → just the output as a string. Fits \"run this,",
1974
- " show me the result\" without unwrapping.",
1975
- " `(sh-ok? \"cmd\")` → just a boolean. Fits `(if (sh-ok? \"…\") …)`",
1976
- " branches and existence checks.",
1977
- " `(bash \"cmd\")` → full alist when you need stdout *and* exit",
1978
- " code *and* stderr separately (e.g. capture",
1979
- " stderr while letting stdout flow on).",
1980
- " For file content work, the host bindings (`grep`, `read-file`,",
1981
- " `glob`) avoid shell-quoting entirely and return structured data.",
1982
- " - `scheme-define` saves a procedure to disk so it auto-loads next",
1983
- " session — useful when you've worked out something reusable.",
1731
+ "Calling convention:",
1732
+ " - Required arguments are positional: (read-file \"x\"), (grep \"pat\").",
1733
+ " - Optional arguments are :key value pairs, and the same key reads the same",
1734
+ " on every binding :offset/:limit on read-file and grep, :timeout on bash:",
1735
+ " (read-file \"x\" :offset 40 :limit 20)",
1736
+ " (grep \"TODO\" \"src/\" :include \"*.ts\" :context-after 2)",
1737
+ " (A trailing positional is still accepted for each optional, but :key is",
1738
+ " the canonical form and never depends on argument order.)",
1739
+ " - Each binding returns the natural Scheme value for its job (a string, a",
1740
+ " list, a boolean); bash returns a record because you usually want the code.",
1984
1741
  "",
1985
1742
  "Host bindings:",
1986
- " (bash cmd [timeout-sec]) → alist ((exit-code . N) (stdout . S) (stderr . S) (success . #t/#f))",
1987
- " cmd is run via `bash -c`. Pipes/redirects/$VARS/&&/||/here-docs work",
1988
- " inside the string; there's no piping between separate bash calls.",
1989
- " (sh cmd [timeout-sec]) → stdout string (stderr text on failure)",
1990
- " (sh-ok? cmd [timeout-sec]) → #t if exit code 0, else #f",
1991
- " (read-file path) → string, or #f on error",
1992
- " (read-file path offset) → from line offset (1-indexed) to end",
1993
- " (read-file path offset limit) → offset + N lines",
1994
- " (write-file path content) → #t on success, error string on failure",
1995
- " (edit-file path old new) → #t on success, error string on failure",
1996
- " (edit-file path old new #t) → replace every occurrence (not just one)",
1997
- " (grep pattern [path] [limit|opts]) → list of ((file . S) (line . N) (text . S))",
1998
- " (grep-files pattern [path] [opts]) → list of file paths",
1999
- " Patterns are ripgrep regex (Rust). Both POSIX BRE escapes (`\\|`,",
2000
- " `\\(`, `\\)`, `\\{`, `\\}`, `\\+`, `\\?`) and bare ERE-style metacharacters",
2001
- " (`|`, `(`, `)`, `{`, `}`, `+`, `?`) work — the bridge translates BRE to",
2002
- " ERE before invoking ripgrep. `.` is any char, `\\b` is a word boundary.",
2003
- " opts: ((include . \"*.ts\") ; filename glob filter",
2004
- " (case-insensitive . #t)",
2005
- " (context-before . N) (context-after . N) ; grep only",
2006
- " (limit . N) (offset . N))",
2007
- " The opts alist is auto-quoted, so `((k . v) …)` and `'((k . v) …)` both work.",
2008
- " (glob pattern [base-dir]) → list of file paths (mtime-sorted)",
2009
- "Accessors on bash result: (stdout-of r) (stderr-of r) (exit-code-of r) (success? r)",
2010
- "Strings: (string-length s) (string-contains? s n) (string-append . parts)",
2011
- " (string-replace old new s) (number->string n) (string->number s)",
2012
- " (lines s) (split sep s) (replace pat repl s) (max …) (min …)",
1743
+ HOST_BINDINGS_BLOCK,
2013
1744
  "",
2014
- "Standard Scheme: if cond when unless begin and or not | let let* define set! lambda",
2015
- " map filter fold reduce for-each | eq? null? pair? number? string? empty?",
2016
- " list car cdr cons length append reverse assoc | define-macro",
1745
+ " Discover at runtime: (help) lists these, (help 'grep) shows one.",
1746
+ " bash runs via `bash -c` pipes/redirects/$VARS/&&/here-docs work inside the",
1747
+ " string; stdout+stderr are merged into `output`, and `exit-code` is the real",
1748
+ " shell code. grep/grep-files patterns are ripgrep regex (Rust); POSIX BRE",
1749
+ " escapes (\\|, \\(, \\), \\{, \\}, \\+, \\?) and bare ERE metacharacters both work.",
2017
1750
  "",
2018
- "Dialect notes:",
2019
- " - R7RS truthy semantics: anything that isn't `#f` is true. `(if str …)`,",
2020
- " `(if 0 )`, `(if '() )` all take the then-branch.",
2021
- " - `#t`/`#f` work as expected. `equal?`, `eq?`, `eqv?`, `string=?` all work.",
2022
- " - Characters are 1-char strings (no separate char type). `#\\newline`,",
2023
- " `#\\space`, `#\\tab`, `#\\return`, `#\\xNN`, and `#\\<char>` literals read as",
2024
- " the equivalent string; `char->integer`/`integer->char`/`char?`/`char=?`/",
2025
- " `char-whitespace?` etc. operate on them. A bare newline is just \"\\n\".",
2026
- " - SRFI-1: `member`, `assq`/`assv`/`assoc`, `delete-duplicates`, `first`",
2027
- " through `fifth`, `last`, `take`, `drop`, `iota`, `any`, `every`, `count`,",
2028
- " `find`, `filter-map`, `append-map`, `concatenate`, `partition`, `remove`,",
2029
- " `delete`, `zip`, `take-while`, `drop-while`, `fold-right` are all bound.",
2030
- " - R7RS extras: `string-upcase`/`-downcase`, `string-split`/`-join`,",
2031
- " `zero?`/`positive?`/`negative?`/`odd?`/`even?`, `modulo`, `quotient`,",
2032
- " `remainder`, `expt`, `ceiling`, `error`, `newline`, `displayln`.",
1751
+ "Composition is the point: chain read-only bindings in one submission so",
1752
+ "intermediate results stay in the Scheme heap instead of the conversation.",
1753
+ " (map (lambda (m) (read-file (cdr (assoc 'file m)) :offset (cdr (assoc 'line m)) :limit 3))",
1754
+ " (grep \"TODO\" \"src/\"))",
1755
+ "Side-effecting calls (write-file, edit-file, mutating bash) are clearer one",
1756
+ "at a time so you can react to each result.",
2033
1757
  "",
1758
+ "scheme-define saves a procedure to ~/.agent-sh/scheme-define/{name}.scm so it",
1759
+ "auto-loads next session:",
2034
1760
  " (scheme-define name (args …) \"docstring\" body …)",
2035
- " Defines like `define`, and also saves to",
2036
- " ~/.agent-sh/scheme-define/{name}.scm so it auto-loads next session.",
1761
+ "",
1762
+ "Dialect notes:",
1763
+ " - R7RS truthy semantics: only `#f` is false. `(if 0 …)`, `(if '() …)`,",
1764
+ " `(if \"\" …)` all take the then-branch.",
1765
+ " - Characters are a real type: `#\\A` `#\\newline` `#\\space` `#\\tab` `#\\xNN`.",
1766
+ " - String escapes are JSON-style (`\\\\` `\\\"` `\\n` `\\r` `\\t` `\\uXXXX`);",
1767
+ " for a literal backslash write `\\\\`.",
2037
1768
  "",
2038
1769
  "Default timeout 15s; pass timeout_ms to override (max 60s).",
2039
1770
  ].join("\n");
2040
1771
 
2041
- // Scheme prelude R7RS forms LIPS doesn't ship. Evaluated after the JS
2042
- // bindings (#t/#f, null?, etc.) are in place. `define-macro` is LIPS' own
2043
- // macro form (defmacro-style); used here because `define-syntax` isn't
2044
- // available either.
1772
+ // Scheme prelude (Lisp `define-macro`s), run after std is bootstrapped.
1773
+ // cond/when/unless/newline/assq for convenience.
2045
1774
  const PRELUDE = `
2046
1775
  (define-macro (cond . clauses)
2047
1776
  (if (null? clauses)
@@ -2062,37 +1791,10 @@ const PRELUDE = `
2062
1791
  ;; R7RS shims for things models commonly reach for that LIPS doesn't ship.
2063
1792
  (define (newline) (display "\n"))
2064
1793
  (define assq assoc)
2065
-
2066
- ;; grep / grep-files: auto-quote alist-literal opts so callers can write
2067
- ;; either ((k . v) ...) or '((k . v) ...). Without this, the bare form is
2068
- ;; read as a function call on (k . v) and errors with an unbound-variable
2069
- ;; message that doesn't point at the cause.
2070
- (define (%alist-literal? x)
2071
- (and (pair? x) (pair? (car x)) (symbol? (car (car x)))))
2072
-
2073
- (define-macro (grep . args)
2074
- (if (and (>= (length args) 3) (%alist-literal? (car (cdr (cdr args)))))
2075
- (cons '%grep
2076
- (cons (car args)
2077
- (cons (car (cdr args))
2078
- (cons (list 'quote (car (cdr (cdr args))))
2079
- (cdr (cdr (cdr args)))))))
2080
- (cons '%grep args)))
2081
-
2082
- (define-macro (grep-files . args)
2083
- (if (and (>= (length args) 3) (%alist-literal? (car (cdr (cdr args)))))
2084
- (cons '%grep-files
2085
- (cons (car args)
2086
- (cons (car (cdr args))
2087
- (cons (list 'quote (car (cdr (cdr args))))
2088
- (cdr (cdr (cdr args)))))))
2089
- (cons '%grep-files args)))
2090
1794
  `;
2091
1795
 
2092
1796
  export default function activate(ctx: AgentContext): void {
2093
1797
  const env = (lips as any).env.inherit("scheme-ext");
2094
- installFixedDefine(env);
2095
- installLenientIf(env);
2096
1798
  const defineRegistry: DefineRegistry = new Map();
2097
1799
  const defineLoading = { active: false };
2098
1800
  // Forward decl: assigned after baseInstruction is computed below.
@@ -2112,21 +1814,32 @@ export default function activate(ctx: AgentContext): void {
2112
1814
  try { glob = resolveExecutor(ctx, "glob"); } catch { /* optional */ }
2113
1815
  installBindings(env, ctx.bus, bash, readFile, writeFile, editFile, grep, glob);
2114
1816
 
2115
- // Fire-and-forget: exec is async but macros register in <1ms, well before
2116
- // any user call. Load persisted defines, then audit shim coverage.
2117
- void (lips as any).exec(PRELUDE, env)
2118
- .then(() => loadPersistedDefines(env, defineRegistry, defineLoading))
2119
- .then(() => {
2120
- const audit = auditShimCoverage(env);
2121
- if (audit.missing.length > 0) {
2122
- logErr("shim-audit", new Error("missing canonical names"), {
2123
- defined: audit.defined,
2124
- total: audit.defined + audit.missing.length,
2125
- missing: audit.missing,
2126
- });
2127
- }
2128
- })
2129
- .catch((e: any) => logErr("prelude", e));
1817
+ // Bootstrap LIPS' compiled std library into the global env, then install the
1818
+ // gap-filling shims (skipped where std already defines a name, so native
1819
+ // R7RS wins), the prelude macros, and any persisted scheme-defines. Bootstrap
1820
+ // must precede installStdShims so native bindings take priority.
1821
+ void (async () => {
1822
+ try {
1823
+ const stdXcb = path.join(
1824
+ path.dirname(createRequire(import.meta.url).resolve("@jcubic/lips")),
1825
+ "std.xcb",
1826
+ );
1827
+ await bootstrap(stdXcb);
1828
+ } catch (e) {
1829
+ logErr("bootstrap", e);
1830
+ }
1831
+ installStdShims(env);
1832
+ await (lips as any).exec(PRELUDE, { env });
1833
+ await loadPersistedDefines(env, defineRegistry, defineLoading);
1834
+ const audit = auditShimCoverage(env);
1835
+ if (audit.missing.length > 0) {
1836
+ logErr("shim-audit", new Error("missing canonical names"), {
1837
+ defined: audit.defined,
1838
+ total: audit.defined + audit.missing.length,
1839
+ missing: audit.missing,
1840
+ });
1841
+ }
1842
+ })().catch((e: any) => logErr("init", e));
2130
1843
 
2131
1844
  if (schemeOnly) {
2132
1845
  for (const name of HIDDEN_IN_SCHEME_ONLY) {
@@ -2159,11 +1872,8 @@ export default function activate(ctx: AgentContext): void {
2159
1872
  },
2160
1873
  getDisplayInfo: () => ({ kind: "execute", icon: "λ", sourceLanguage: "scheme" }),
2161
1874
  formatResult: (args, result) => {
2162
- // Output is usually a long alist or file dump the LLM still gets full
2163
- // content via tool_result, but the TUI body shows the SOURCE so Ctrl+O
2164
- // reveals what ran rather than what came back. scheme-render.ts (if
2165
- // loaded) honors this; ashi's default renderer currently ignores
2166
- // body.kind === "lines", which is fine — summary still carries the gist.
1875
+ // TUI body shows the SOURCE, not the result (Ctrl+O reveals what ran);
1876
+ // the LLM still gets full content via tool_result.
2167
1877
  const sourceLines = String(args.source ?? "").split("\n");
2168
1878
  if (!result.isError) {
2169
1879
  return {
@@ -2194,44 +1904,24 @@ export default function activate(ctx: AgentContext): void {
2194
1904
  },
2195
1905
  });
2196
1906
 
2197
- // schemeOnly: registerInstruction carries the full tool surface since
2198
- // deferred-lookup mode strips tool descriptions from the system prompt.
2199
- // coreTools puts scheme_eval's schema in the API tool list already; we
2200
- // add behavioral framing here — specifically the context-preservation
2201
- // nudge that would be lost in the long tool description.
1907
+ // Behavioral framing in the system prompt (the API + examples live in the
1908
+ // tool description). Keep it short and non-duplicative: just when to reach
1909
+ // for scheme_eval over a direct tool call.
2202
1910
  const baseInstruction = schemeOnly
2203
1911
  ? [
2204
1912
  "# Scheme runtime",
2205
- "scheme_eval is your only tool; see its description for the API.",
2206
- "",
2207
- "## Context preservation",
2208
- "Each tool round-trip permanently consumes context. Prefer composing",
2209
- "multi-step operations into a single scheme_eval call so intermediate",
2210
- "results stay in the Scheme heap instead of the conversation. Example:",
2211
- " (let ((files (glob \"src/**/*.ts\"))",
2212
- " (matches (grep \"TODO\" \"src/\")))",
2213
- " (filter (lambda (f) (member f (map (lambda (m) (cdr (assoc 'file m))) matches)))",
2214
- " files))",
2215
- "This does glob + grep + filter in one round-trip. Use `define` to",
2216
- "cache results across calls: `(define files (glob …))` once, reuse later.",
1913
+ "scheme_eval is your only tool; see its description for the API. Each tool",
1914
+ "round-trip consumes context permanently, so compose multi-step work into a",
1915
+ "single call — intermediate results stay in the Scheme heap, not the",
1916
+ "conversation. `define` caches across calls.",
2217
1917
  ].join("\n")
2218
1918
  : [
2219
1919
  "# Scheme runtime",
2220
- "scheme_eval evaluates Scheme with host bindings to bash, read-file, grep, glob, etc.",
2221
- "See its description for the full API.",
2222
- "",
2223
- "## When to reach for scheme_eval",
2224
- "The direct tools (grep, read_file, bash, etc.) are the right default for",
2225
- "single operations. scheme_eval becomes valuable when you'd chain 2+ read-only",
2226
- "tool calls that don't need inspection between steps — composing them inside",
2227
- "Scheme keeps intermediate results in the Scheme heap instead of the",
2228
- "conversation, saving context. Example:",
2229
- " (let ((matches (grep \"pattern\" \"src/\")))",
2230
- " (map (lambda (m) (list (cdr (assoc 'file m))",
2231
- " (read-file (cdr (assoc 'file m)) (cdr (assoc 'line m)) 3)))",
2232
- " (take matches 5)))",
2233
- "does grep + read-file × 5 in one round-trip. `define` caches across",
2234
- "calls: `(define files (glob …))` once, reuse in later submissions.",
1920
+ "scheme_eval evaluates Scheme with host bindings (bash, read-file, grep, glob,",
1921
+ "…); see its description for the API. Direct tools are the right default for",
1922
+ "single operations; reach for scheme_eval to chain 2+ read-only calls that",
1923
+ "don't need inspection between steps — composing them keeps intermediate",
1924
+ "results in the Scheme heap instead of the conversation.",
2235
1925
  ].join("\n");
2236
1926
  // Re-register when the scheme-define registry changes so the index stays
2237
1927
  // current within a session.