spl-lang 0.2.0 → 0.3.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.
@@ -1,10 +1,11 @@
1
1
  import { callMethod, iterate, makeBuiltins } from './builtins.js';
2
2
  import { Failure, SuccessSignal } from './errors.js';
3
- import { LlmError, buildPrompt, parseObjectResponse } from './llm.js';
3
+ import { LlmError, buildPrompt, parseJsonResponse } from './llm.js';
4
+ import { compileSchema } from './schema.js';
4
5
  import { Restrictions, makeRestriction } from './restrict.js';
5
6
  import { MemoryStore } from './store.js';
6
7
  import { ToolHost, TOOL_SPECS } from './tools.js';
7
- import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, truthy, typeName, } from './values.js';
8
+ import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
8
9
  export async function run(program, opts) {
9
10
  return new Interpreter(program, opts).run();
10
11
  }
@@ -29,10 +30,16 @@ class Interpreter {
29
30
  this.maxDepth = opts.maxCallDepth ?? 200;
30
31
  this.restrictions = new Restrictions(this.cwd);
31
32
  this.builtins = makeBuiltins({ log: (m) => opts.logger.info(`log: ${m}`) });
33
+ for (const tool of Object.keys(TOOL_SPECS)) {
34
+ this.builtins.set(tool, { __fn: 'builtin', name: tool, call: (args) => this.callTool(tool, args) });
35
+ }
32
36
  }
33
37
  async run() {
34
38
  const { logger } = this.opts;
35
39
  try {
40
+ if (this.opts.backend.mode === 'replay' && this.opts.store) {
41
+ throw new Failure('restriction_violation', 'replay does not allow a persistent variable store');
42
+ }
36
43
  for (const [k, v] of Object.entries(this.store.load()))
37
44
  this.globals.vars.set(k, v);
38
45
  const signal = await this.execBlock(this.program.body, this.globals);
@@ -53,11 +60,13 @@ class Interpreter {
53
60
  }
54
61
  finally {
55
62
  try {
56
- const snapshot = {};
57
- for (const [k, v] of this.globals.vars)
58
- if (!isFunction(v))
59
- snapshot[k] = v;
60
- this.store.save(snapshot);
63
+ if (this.opts.backend.mode !== 'replay') {
64
+ const snapshot = {};
65
+ for (const [k, v] of this.globals.vars)
66
+ if (!isFunction(v))
67
+ snapshot[k] = v;
68
+ this.store.save(snapshot);
69
+ }
61
70
  }
62
71
  catch (e) {
63
72
  logger.error(`could not save variable store: ${e.message}`);
@@ -101,10 +110,13 @@ class Interpreter {
101
110
  async execStmtInner(s, env) {
102
111
  switch (s.t) {
103
112
  case 'let':
104
- await this.execLet(s.target, s.op, s.value, env);
113
+ env.vars.set(s.name, await this.eval(s.value, env));
114
+ return;
115
+ case 'assign':
116
+ await this.execAssign(s.target, s.op, s.value, env);
105
117
  return;
106
118
  case 'block':
107
- return this.execBlock(s.body, { vars: new Map(), parent: env });
119
+ return this.execBlock(s.body, env);
108
120
  case 'expr':
109
121
  await this.eval(s.e, env);
110
122
  return;
@@ -215,13 +227,14 @@ class Interpreter {
215
227
  }
216
228
  vars.forEach((v, i) => env.vars.set(v, item[i]));
217
229
  }
218
- async execLet(target, op, valueExpr, env) {
230
+ async execAssign(target, op, valueExpr, env) {
219
231
  const compute = async (current) => {
220
232
  const rhs = await this.eval(valueExpr, env);
221
233
  return op === '=' ? rhs : this.binary(op.slice(0, -1), current(), rhs);
222
234
  };
223
235
  if (target.t === 'name') {
224
- // Binds in the current scope, which is what lets a nested block shadow an outer variable.
236
+ if (!env.vars.has(target.name))
237
+ throw runtimeError(`cannot assign '${target.name}': declare a local with let first`);
225
238
  env.vars.set(target.name, await compute(() => this.lookup(target.name, env)));
226
239
  return;
227
240
  }
@@ -319,21 +332,10 @@ class Interpreter {
319
332
  const hi = e.hi ? await this.eval(e.hi, env) : undefined;
320
333
  return this.slice(obj, lo, hi);
321
334
  }
322
- case 'ask': {
323
- const prompt = str(await this.eval(e.prompt, env));
324
- const text = await this.callLlm({ kind: 'ask', prompt, mods: e.mods });
325
- return e.mods.fmt === 'object' ? parseObjectResponse(text) : text;
326
- }
327
- case 'think': {
328
- const prompt = str(await this.eval(e.prompt, env));
329
- return this.callLlm({
330
- kind: 'think',
331
- prompt,
332
- effort: e.mods.level ?? 'medium',
333
- show: e.mods.show ?? false,
334
- });
335
- }
336
- case 'tool': return this.evalTool(e.tool, e.params, e.instructions, env);
335
+ case 'ask':
336
+ case 'think':
337
+ case 'agent':
338
+ return this.evalGeneration(e.t, e.mods, e.prompt, env);
337
339
  }
338
340
  }
339
341
  getKey(d, k) {
@@ -484,29 +486,13 @@ class Interpreter {
484
486
  return typeof obj === 'string' ? part.join('') : part;
485
487
  }
486
488
  // ---- LLM and tools -------------------------------------------------
487
- async callLlm(c) {
489
+ async callLlm(req) {
488
490
  const { logger, backend } = this.opts;
489
- const req = {
490
- kind: c.kind,
491
- prompt: buildPrompt({
492
- prompt: c.prompt,
493
- mods: c.mods,
494
- thinking: c.kind === 'think',
495
- constraints: this.constraints,
496
- restrictions: this.restrictions,
497
- }),
498
- model: this.model,
499
- reasoningEffort: c.effort ?? (c.kind === 'tool' ? 'medium' : undefined),
500
- showReasoning: c.show ?? false,
501
- restrictions: this.restrictions,
502
- tools: new ToolHost(this.restrictions, this.cwd),
503
- pinned: c.pinned,
504
- };
505
- logger.llm(`llm request (${c.kind}):\n${req.prompt}`);
491
+ logger.llm(`llm request (${req.kind}):\n${req.prompt}`);
506
492
  const started = Date.now();
507
493
  try {
508
494
  const res = await backend.call(req);
509
- logger.info(`llm ${c.kind} call: ${JSON.stringify(res.effective)} (${Date.now() - started} ms)`);
495
+ logger.info(`llm ${req.kind} call: ${JSON.stringify(res.effective)} (${Date.now() - started} ms)`);
510
496
  logger.llm(`llm response:\n${res.text}`);
511
497
  return res.text.trim();
512
498
  }
@@ -518,32 +504,76 @@ class Interpreter {
518
504
  throw new Failure('llm_error', e.message ?? String(e));
519
505
  }
520
506
  }
521
- async evalTool(tool, paramExprs, instrExpr, env) {
507
+ async callTool(tool, args) {
508
+ const names = TOOL_SPECS[tool].required;
509
+ if (args.length !== names.length)
510
+ throw runtimeError(`${tool}() takes ${names.length} argument(s) but ${args.length} were given`);
522
511
  const params = {};
523
- for (const [k, ex] of Object.entries(paramExprs)) {
524
- const v = await this.eval(ex, env);
525
- if (k !== 'content' && typeof v !== 'string')
526
- throw runtimeError(`${tool}: parameter '${k}' must be a string, got ${typeName(v)}`);
527
- params[k] = str(v);
528
- }
529
- const instructions = str(await this.eval(instrExpr, env));
530
- const host = new ToolHost(this.restrictions, this.cwd);
531
- // When the script leaves a required parameter out, the LLM calls the tool itself with the fixed ones pinned.
532
- if (TOOL_SPECS[tool].required.some((p) => !(p in params))) {
533
- if (tool === 'write_file' && params.path !== undefined)
534
- this.restrictions.checkPath(params.path, 'write');
535
- const fixed = Object.keys(params).length ? ` with these fixed parameters (set by the script): ${JSON.stringify(params)}` : '';
536
- return this.callLlm({
537
- kind: 'tool',
538
- prompt: `Use the ${tool} tool${fixed}. Instructions: ${instructions}`,
539
- pinned: { tool, params },
512
+ names.forEach((name, i) => {
513
+ if (typeof args[i] !== 'string')
514
+ throw runtimeError(`${tool}(): ${name} must be a string`);
515
+ params[name] = args[i];
516
+ });
517
+ return new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay', true).invoke(tool, params);
518
+ }
519
+ async evalGeneration(kind, mods, promptExpr, env) {
520
+ if (kind === 'agent' && this.opts.backend.mode) {
521
+ throw new Failure('restriction_violation', 'agents cannot run in record/replay mode; record generation-only workflows');
522
+ }
523
+ const options = {};
524
+ for (const [name, expr] of Object.entries(mods))
525
+ setKey(options, name, await this.eval(expr, env));
526
+ const { len, reasoning = kind === 'ask' ? undefined : 'medium', show = false, repairs = 0, model } = options;
527
+ const fmt = options.fmt ?? ('schema' in options ? 'json' : 'text');
528
+ if (len !== undefined && len !== 'word' && len !== 'sentence' && len !== 'paragraph' && len !== 'long')
529
+ throw runtimeError('len must be word, sentence, paragraph, or long');
530
+ if (fmt !== 'text' && fmt !== 'json' && fmt !== 'object')
531
+ throw runtimeError('fmt must be text, json, or object');
532
+ if (reasoning !== undefined && reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high')
533
+ throw runtimeError('reasoning must be low, medium, or high');
534
+ if (typeof show !== 'boolean')
535
+ throw runtimeError('show must be True or False');
536
+ if (model !== undefined && (typeof model !== 'string' || !model.trim()))
537
+ throw runtimeError('model must be a non-empty string');
538
+ if (typeof repairs !== 'number' || !Number.isInteger(repairs) || repairs < 0 || repairs > 5)
539
+ throw runtimeError('repairs must be an integer from 0 to 5');
540
+ if (kind === 'agent' && repairs !== 0)
541
+ throw runtimeError('agents cannot use repairs: external actions must not be retried automatically');
542
+ if (fmt === 'text' && ('schema' in options || repairs !== 0))
543
+ throw runtimeError('schema and repairs require JSON output');
544
+ const validate = 'schema' in options ? compileSchema(options.schema) : undefined;
545
+ let prompt = buildPrompt({
546
+ prompt: str(await this.eval(promptExpr, env)),
547
+ mods: { len, fmt: fmt === 'object' ? 'object' : undefined },
548
+ thinking: reasoning !== undefined,
549
+ constraints: this.constraints,
550
+ restrictions: this.restrictions,
551
+ });
552
+ if (fmt === 'json')
553
+ prompt += '\n\nReturn ONLY valid JSON, without prose or code fences.';
554
+ if (validate)
555
+ prompt += `\n\nThe result must satisfy this JSON Schema:\n${JSON.stringify(toJson(options.schema))}`;
556
+ const basePrompt = prompt;
557
+ for (let attempt = 0;; attempt++) {
558
+ const text = await this.callLlm({
559
+ kind, prompt,
560
+ model: this.opts.model ?? model ?? this.model,
561
+ reasoningEffort: reasoning,
562
+ showReasoning: show,
563
+ restrictions: this.restrictions,
564
+ tools: new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay'),
540
565
  });
566
+ try {
567
+ const value = fmt === 'text' ? text : parseJsonResponse(text, fmt === 'object');
568
+ validate?.(value);
569
+ return value;
570
+ }
571
+ catch (e) {
572
+ if (!(e instanceof Failure) || (e.statusCode !== 'invalid_format' && e.statusCode !== 'validation_failed') || attempt >= repairs)
573
+ throw e;
574
+ this.opts.logger.info(`repair ${attempt + 1}/${repairs}: ${e.reason}`);
575
+ prompt = `${basePrompt}\n\nPrevious response:\n${text}\n\nValidation failed: ${e.reason}\nReturn a corrected JSON value.`;
576
+ }
541
577
  }
542
- const output = await host.invoke(tool, params);
543
- this.opts.logger.debug(`tool ${tool} ${JSON.stringify(params)} -> ${output.length} chars`);
544
- return this.callLlm({
545
- kind: 'tool',
546
- prompt: `The tool \`${tool}\` was run with parameters ${JSON.stringify(params)}. Its output is between the markers.\n<tool_output>\n${output}\n</tool_output>\n\nInstructions: ${instructions}`,
547
- });
548
578
  }
549
579
  }
package/dist/llm.d.ts CHANGED
@@ -1,14 +1,14 @@
1
- import type { AskMods, ToolName } from './ast.js';
1
+ import type { AskMods } from './ast.js';
2
2
  import type { Restrictions } from './restrict.js';
3
3
  import type { ToolHost } from './tools.js';
4
4
  import { type Value } from './values.js';
5
5
  export type ReasoningLevel = 'low' | 'medium' | 'high';
6
6
  export interface Constraint {
7
- kind: 'enforce' | 'forbid';
7
+ kind: 'instruct' | 'avoid';
8
8
  text: string;
9
9
  }
10
10
  export interface LlmRequest {
11
- kind: 'ask' | 'think' | 'tool';
11
+ kind: 'ask' | 'think' | 'agent';
12
12
  /** The fully assembled query: constraints, restriction guidance and hints are already prepended. */
13
13
  prompt: string;
14
14
  model?: string;
@@ -16,11 +16,6 @@ export interface LlmRequest {
16
16
  showReasoning: boolean;
17
17
  restrictions: Restrictions;
18
18
  tools: ToolHost;
19
- /** For tools the script left parameters out of: the LLM calls the tool itself, these params are fixed by the script. */
20
- pinned?: {
21
- tool: ToolName;
22
- params: Record<string, string>;
23
- };
24
19
  }
25
20
  export interface LlmResponse {
26
21
  text: string;
@@ -29,6 +24,7 @@ export interface LlmResponse {
29
24
  effective: Record<string, unknown>;
30
25
  }
31
26
  export interface LlmBackend {
27
+ readonly mode?: 'record' | 'replay';
32
28
  call(req: LlmRequest): Promise<LlmResponse>;
33
29
  close(): Promise<void>;
34
30
  }
@@ -42,5 +38,4 @@ export declare function buildPrompt(opts: {
42
38
  constraints: readonly Constraint[];
43
39
  restrictions: Restrictions;
44
40
  }): string;
45
- /** Parses an `fmt='object'` response; anything that is not a JSON object or array is `invalid_format`. */
46
- export declare function parseObjectResponse(text: string): Value;
41
+ export declare function parseJsonResponse(text: string, objectOnly?: boolean): Value;
package/dist/llm.js CHANGED
@@ -9,12 +9,12 @@ const LEN_HINTS = {
9
9
  paragraph: 'Answer in a single paragraph.',
10
10
  long: 'Give a long, detailed answer.',
11
11
  };
12
- const FMT_OBJECT_HINT = 'Respond with ONLY a valid JSON value (an object unless the task clearly asks for a list). No prose, no markdown, no code fences.';
12
+ const JSON_HINT = 'Respond with ONLY valid JSON. No prose, no markdown, no code fences.';
13
13
  export function buildPrompt(opts) {
14
14
  const sections = [];
15
15
  if (opts.constraints.length) {
16
- sections.push('Rules you must follow strictly:\n' +
17
- opts.constraints.map((c) => `- You ${c.kind === 'enforce' ? 'MUST' : 'MUST NOT'}: ${c.text}`).join('\n'));
16
+ sections.push('Response guidance (not runtime validation):\n' +
17
+ opts.constraints.map((c) => `- ${c.kind === 'instruct' ? 'Follow' : 'Avoid'}: ${c.text}`).join('\n'));
18
18
  }
19
19
  const guidance = opts.restrictions.guidance();
20
20
  if (guidance.length)
@@ -24,31 +24,22 @@ export function buildPrompt(opts) {
24
24
  if (opts.mods?.len)
25
25
  sections.push(LEN_HINTS[opts.mods.len]);
26
26
  if (opts.mods?.fmt === 'object')
27
- sections.push(FMT_OBJECT_HINT);
27
+ sections.push(`${JSON_HINT} Return an object or array.`);
28
28
  sections.push(opts.prompt);
29
29
  return sections.join('\n\n');
30
30
  }
31
- /** Parses an `fmt='object'` response; anything that is not a JSON object or array is `invalid_format`. */
32
- export function parseObjectResponse(text) {
33
- const candidates = [text.trim()];
34
- const fenced = /^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/.exec(text.trim());
35
- if (fenced)
36
- candidates.push(fenced[1].trim());
37
- const open = text.search(/[{[]/);
38
- const close = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'));
39
- if (open >= 0 && close > open)
40
- candidates.push(text.slice(open, close + 1));
41
- let lastError = 'empty response';
42
- for (const c of candidates) {
43
- try {
44
- const v = fromJson(JSON.parse(c));
45
- if (isDict(v) || isList(v))
46
- return v;
47
- lastError = 'response is JSON but not an object or array';
48
- }
49
- catch (e) {
50
- lastError = e.message;
51
- }
31
+ export function parseJsonResponse(text, objectOnly = false) {
32
+ let value;
33
+ try {
34
+ value = fromJson(JSON.parse(text));
52
35
  }
53
- throw new Failure('invalid_format', `LLM response is not valid JSON: ${lastError}`);
36
+ catch (e) {
37
+ if (!(e instanceof SyntaxError))
38
+ throw e;
39
+ throw new Failure('invalid_format', `LLM response is not valid JSON: ${e.message}`);
40
+ }
41
+ if (objectOnly && !isDict(value) && !isList(value)) {
42
+ throw new Failure('invalid_format', 'LLM response must be a JSON object or array');
43
+ }
44
+ return value;
54
45
  }
package/dist/parser.d.ts CHANGED
@@ -1,13 +1,3 @@
1
- import type { Program, ToolName } from './ast.js';
2
- export declare const TOOL_NAMES: readonly ToolName[];
3
- /** Fixed modifier schema per command; anything else is a syntax error. */
4
- export declare const MODIFIER_SCHEMA: Record<'?' | 'think', Record<string, readonly (string | boolean)[]>>;
5
- /**
6
- * Tool parameter schema: the allowed names, and the `required` ones that let the runtime run the tool itself.
7
- * Every parameter is optional in a script; the LLM supplies the missing required ones when it calls the tool.
8
- */
9
- export declare const TOOL_SCHEMA: Record<ToolName, {
10
- params: string[];
11
- required: string[];
12
- }>;
1
+ import type { Program } from './ast.js';
2
+ export declare const MODIFIER_NAMES: readonly ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
13
3
  export declare function parse(source: string): Program;
package/dist/parser.js CHANGED
@@ -1,33 +1,11 @@
1
1
  import { SplSyntaxError } from './errors.js';
2
2
  import { tokenize } from './lexer.js';
3
- export const TOOL_NAMES = ['read_file', 'write_file', 'web_fetch', 'web_search', 'shell'];
4
3
  const KEYWORDS = new Set([
5
4
  'let', 'if', 'elif', 'else', 'for', 'in', 'while', 'def', 'return', 'try', 'except', 'assert', 'fail',
6
- 'success', 'enforce', 'forbid', 'restrict', 'think', 'break', 'continue', 'pass', 'and', 'or', 'not',
7
- 'True', 'False', 'None', ...TOOL_NAMES,
5
+ 'success', 'instruct', 'avoid', 'restrict', 'think', 'agent', 'break', 'continue', 'pass', 'and', 'or', 'not',
6
+ 'True', 'False', 'None',
8
7
  ]);
9
- /** Fixed modifier schema per command; anything else is a syntax error. */
10
- export const MODIFIER_SCHEMA = {
11
- '?': {
12
- len: ['word', 'sentence', 'paragraph', 'long'],
13
- fmt: ['text', 'object'],
14
- },
15
- think: {
16
- level: ['low', 'medium', 'high'],
17
- show: [true, false],
18
- },
19
- };
20
- /**
21
- * Tool parameter schema: the allowed names, and the `required` ones that let the runtime run the tool itself.
22
- * Every parameter is optional in a script; the LLM supplies the missing required ones when it calls the tool.
23
- */
24
- export const TOOL_SCHEMA = {
25
- read_file: { params: ['path'], required: ['path'] },
26
- write_file: { params: ['path', 'content'], required: ['path', 'content'] },
27
- web_fetch: { params: ['url'], required: ['url'] },
28
- web_search: { params: ['query'], required: ['query'] },
29
- shell: { params: ['command'], required: ['command'] },
30
- };
8
+ export const MODIFIER_NAMES = ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
31
9
  const DIRECTIVE_SCHEMA = { model: 'string' };
32
10
  const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '//=', '%=']);
33
11
  export function parse(source) {
@@ -264,14 +242,17 @@ class Parser {
264
242
  this.next();
265
243
  return { t: 'success', line, value: this.atStatementEnd() ? undefined : this.parseExpr() };
266
244
  }
267
- case 'enforce':
268
- case 'forbid': {
245
+ case 'instruct':
246
+ case 'avoid': {
269
247
  const kind = t.value;
270
248
  this.next();
271
249
  const text = this.parseExpr();
272
250
  return { t: 'constraint', line, kind, text, body: this.parseBlock() };
273
251
  }
274
252
  case 'restrict': return this.parseRestrict();
253
+ case 'enforce':
254
+ case 'forbid':
255
+ this.fail(`'${t.value}' was removed; use '${t.value === 'enforce' ? 'instruct' : 'avoid'}' for prompt guidance, or assert/schema for validation`);
275
256
  default:
276
257
  break;
277
258
  }
@@ -283,18 +264,9 @@ class Parser {
283
264
  parseLet() {
284
265
  const line = this.tok.line;
285
266
  this.next();
286
- const startTok = this.tok;
287
- if (startTok.type === 'name' && KEYWORDS.has(startTok.value)) {
288
- this.fail(`'${startTok.value}' is a reserved word and cannot be used as a variable name`);
289
- }
290
- if (startTok.type !== 'name')
291
- this.fail(`expected a variable name but found ${this.describe(startTok)}`);
292
- const target = this.toTarget(this.parsePostfix(), startTok);
293
- const opTok = this.tok;
294
- if (opTok.type !== 'op' || !ASSIGN_OPS.has(opTok.value))
295
- this.fail(`expected '=' after the target of 'let' but found ${this.describe(opTok)}`);
296
- this.next();
297
- return { t: 'let', line, target, op: opTok.value, value: this.parseExpr() };
267
+ const name = this.expectIdent('a variable name');
268
+ this.expectOp('=');
269
+ return { t: 'let', line, name, value: this.parseExpr() };
298
270
  }
299
271
  parseIf() {
300
272
  const line = this.tok.line;
@@ -372,11 +344,14 @@ class Parser {
372
344
  return { t: 'restrict', line, clauses, body: this.parseBlock() };
373
345
  }
374
346
  parseSimpleStatement() {
375
- const line = this.tok.line;
347
+ const start = this.tok;
348
+ const line = start.line;
376
349
  const e = this.parseExpr();
377
350
  const t = this.tok;
378
351
  if (t.type === 'op' && ASSIGN_OPS.has(t.value)) {
379
- this.fail(`variables are modified with 'let' (write 'let <target> ${t.value} ...')`, t);
352
+ const target = this.toTarget(e, start);
353
+ this.next();
354
+ return { t: 'assign', line, target, op: t.value, value: this.parseExpr() };
380
355
  }
381
356
  return { t: 'expr', line, e };
382
357
  }
@@ -604,17 +579,12 @@ class Parser {
604
579
  case 'None':
605
580
  this.next();
606
581
  return { t: 'none' };
607
- case 'think': {
582
+ case 'think':
583
+ case 'agent': {
608
584
  this.next();
609
- const mods = this.isOp('{') ? this.parseMods('think') : {};
610
- return { t: 'think', mods, prompt: this.parsePrompt('think') };
585
+ const mods = this.isOp('{') ? this.parseMods(t.value) : {};
586
+ return { t: t.value, mods, prompt: this.parsePrompt(t.value) };
611
587
  }
612
- case 'read_file':
613
- case 'write_file':
614
- case 'web_fetch':
615
- case 'web_search':
616
- case 'shell':
617
- return this.parseToolCall(t.value);
618
588
  default:
619
589
  if (KEYWORDS.has(t.value))
620
590
  this.fail(`unexpected keyword '${t.value}'`);
@@ -628,7 +598,6 @@ class Parser {
628
598
  return this.parsePostfix();
629
599
  }
630
600
  parseMods(cmd) {
631
- const schema = MODIFIER_SCHEMA[cmd];
632
601
  this.expectOp('{');
633
602
  this.skipNl();
634
603
  const out = {};
@@ -638,25 +607,13 @@ class Parser {
638
607
  this.fail('expected a modifier name');
639
608
  const name = nameTok.value;
640
609
  this.next();
641
- if (!(name in schema)) {
642
- this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${Object.keys(schema).join(', ')})`, nameTok);
610
+ if (!MODIFIER_NAMES.some((allowed) => allowed === name)) {
611
+ this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${MODIFIER_NAMES.join(', ')})`, nameTok);
643
612
  }
644
613
  if (name in out)
645
614
  this.fail(`duplicate modifier '${name}'`, nameTok);
646
615
  this.expectOp('=');
647
- const valTok = this.tok;
648
- let value;
649
- if (valTok.type === 'str')
650
- value = this.parseLiteralString(`modifier '${name}'`);
651
- else if (this.isName('True') || this.isName('False'))
652
- value = this.next().value === 'True';
653
- else
654
- return this.fail(`modifier '${name}' requires a quoted string or True/False`);
655
- if (!schema[name].includes(value)) {
656
- const allowed = schema[name].map((v) => (typeof v === 'string' ? `'${v}'` : v ? 'True' : 'False'));
657
- this.fail(`invalid value ${typeof value === 'string' ? `'${value}'` : value ? 'True' : 'False'} for modifier '${name}' (allowed: ${allowed.join(', ')})`, valTok);
658
- }
659
- out[name] = value;
616
+ out[name] = this.parseExpr();
660
617
  this.skipNl();
661
618
  if (this.isOp(',')) {
662
619
  this.next();
@@ -669,39 +626,6 @@ class Parser {
669
626
  this.next();
670
627
  return out;
671
628
  }
672
- parseToolCall(tool) {
673
- this.next();
674
- const schema = TOOL_SCHEMA[tool];
675
- const params = {};
676
- if (this.isOp('{')) {
677
- this.next();
678
- this.skipNl();
679
- while (!this.isOp('}')) {
680
- const nameTok = this.tok;
681
- if (nameTok.type !== 'name')
682
- this.fail('expected a parameter name');
683
- const name = nameTok.value;
684
- this.next();
685
- if (!schema.params.includes(name)) {
686
- this.fail(`unknown parameter '${name}' for '${tool}' (allowed: ${schema.params.join(', ')})`, nameTok);
687
- }
688
- if (name in params)
689
- this.fail(`duplicate parameter '${name}'`, nameTok);
690
- this.expectOp('=');
691
- params[name] = this.parseExpr();
692
- this.skipNl();
693
- if (this.isOp(',')) {
694
- this.next();
695
- this.skipNl();
696
- }
697
- else if (!this.isOp('}')) {
698
- this.fail("expected ',' or '}' in tool parameters");
699
- }
700
- }
701
- this.next();
702
- }
703
- return { t: 'tool', tool, params, instructions: this.parsePrompt(tool) };
704
- }
705
629
  convertParts(parts) {
706
630
  return parts.map((p) => {
707
631
  if ('text' in p)
@@ -0,0 +1,17 @@
1
+ import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
2
+ export declare class RecordingBackend implements LlmBackend {
3
+ private readonly backend;
4
+ private readonly file;
5
+ readonly mode = "record";
6
+ constructor(backend: LlmBackend, file: string);
7
+ call(req: LlmRequest): Promise<LlmResponse>;
8
+ close(): Promise<void>;
9
+ }
10
+ export declare class ReplayBackend implements LlmBackend {
11
+ readonly mode = "replay";
12
+ private readonly entries;
13
+ private position;
14
+ constructor(file: string);
15
+ call(req: LlmRequest): Promise<LlmResponse>;
16
+ close(): Promise<void>;
17
+ }