spl-lang 0.3.1 → 0.4.1

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.
package/README.md CHANGED
@@ -26,16 +26,22 @@ under `~/.spl` (override with `SPL_HOME`); `spl logout` removes those saved cred
26
26
  - **Generation:** `?` and `think` have no tools or MCP access.
27
27
  - **Agency:** `agent` explicitly enables model-directed runtime and configured MCP tools.
28
28
  - **Validation:** expression-valued `schema` uses strict synchronous JSON Schema draft-07;
29
- `fmt='json'` accepts any JSON value, `fmt='object'` requires an object/array.
30
- Prose and code fences are not silently extracted.
29
+ `fmt` supports `integer`, `float`, `array`, `object`, `text`, and `boolean`.
30
+ Non-text formats require strict JSON of the selected type; `float` accepts any finite
31
+ number, including whole numbers, and `object` requires a dict, not a list.
32
+ `fmt='json'` still accepts any JSON value. Prose and code fences are not silently extracted.
31
33
  - **Repairs:** `repairs=0..5` retries only malformed/invalid generation, not transport errors
32
34
  or external actions. Agents require `repairs=0`. Use `assert` for business rules.
33
35
  - **Guidance:** `enforce`/`forbid` and `len` influence prompts, not runtime guarantees.
34
36
  - **Capabilities:** nested `restrict fs/net/exec/mcp` clauses intersect. Shell command
35
37
  inspection is best effort; MCP servers are trusted services, not OS-sandboxed.
36
- - **Bindings:** `let` declares/redeclares function/script locals; `x = ...` and `x += ...`
37
- update existing locals. Every block shares that scope. Enclosing names are readable but
38
- cannot be rebound by a function; shared collections remain mutable.
38
+ - **Bindings:** every block has a lexical child scope. `let` declares/redeclares locally
39
+ and shadows outer names. Reads and assignments search outward for the first declaration;
40
+ child locals, loop variables and caught failures do not leak to parents.
41
+ - **Expressions:** blocks and conditionals yield their final expression, otherwise `None`.
42
+ `{}` remains an empty dict. Functions still require explicit `return`.
43
+ - **Logging:** `log(...)` defaults to info. `log{level=debug}(...)` selects error/info/debug;
44
+ bare level names, strings and expressions are accepted and checked at runtime.
39
45
 
40
46
  Common generation modifiers are `len`, `fmt`, `reasoning`, `show`, `schema`, `repairs`,
41
47
  and `model`. Values are evaluated once, then validated before calling the model.
@@ -88,4 +94,6 @@ See [embedding](../docs/internals.html#embedding).
88
94
  Use `enforce` and `forbid` for scoped prompt guidance; `instruct` and `avoid` are not accepted.
89
95
  0.3 removes `think{level=...}`, tool-command syntax, and `let` before
90
96
  compound/member/index assignments. Use `reasoning`, ordinary raw functions, and explicit agents instead.
91
- Bare blocks no longer create scopes. See the [migration table](../README.md#breaking-changes-in-03).
97
+ Blocks create lexical child scopes; replace inner `let` with assignment when an outer
98
+ binding must be updated. See the [migration table](../README.md#breaking-changes-in-03).
99
+ `fmt='object'` is now object-only; use `fmt='array'` for lists or `fmt='json'` for any JSON value.
package/dist/ast.d.ts CHANGED
@@ -60,6 +60,22 @@ export type Expr = {
60
60
  obj: Expr;
61
61
  lo?: Expr;
62
62
  hi?: Expr;
63
+ } | {
64
+ t: 'block';
65
+ body: Stmt[];
66
+ line: number;
67
+ } | {
68
+ t: 'if';
69
+ branches: {
70
+ cond: Expr;
71
+ body: Stmt[];
72
+ }[];
73
+ orelse?: Stmt[];
74
+ line: number;
75
+ } | {
76
+ t: 'log';
77
+ level: Expr;
78
+ args: Expr[];
63
79
  } | {
64
80
  t: 'ask' | 'think' | 'agent';
65
81
  mods: Record<string, Expr>;
@@ -67,7 +83,7 @@ export type Expr = {
67
83
  };
68
84
  export interface AskMods {
69
85
  len?: 'word' | 'sentence' | 'paragraph' | 'long';
70
- fmt?: 'text' | 'object';
86
+ fmt?: 'integer' | 'float' | 'array' | 'object' | 'text' | 'boolean' | 'json';
71
87
  }
72
88
  export type ToolName = 'read_file' | 'write_file' | 'web_fetch' | 'web_search' | 'shell';
73
89
  export type RestrictKind = 'fs' | 'net' | 'exec' | 'mcp';
@@ -6,6 +6,12 @@ import { Restrictions, makeRestriction } from './restrict.js';
6
6
  import { MemoryStore } from './store.js';
7
7
  import { ToolHost, TOOL_SPECS } from './tools.js';
8
8
  import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
9
+ class ExpressionSignal {
10
+ signal;
11
+ constructor(signal) {
12
+ this.signal = signal;
13
+ }
14
+ }
9
15
  export async function run(program, opts) {
10
16
  return new Interpreter(program, opts).run();
11
17
  }
@@ -45,7 +51,7 @@ class Interpreter {
45
51
  const signal = await this.execBlock(this.program.body, this.globals);
46
52
  if (signal?.k === 'return')
47
53
  return { status: 'success', value: signal.v, hasValue: signal.hasValue };
48
- if (signal)
54
+ if (signal && signal.k !== 'value')
49
55
  throw runtimeError(`'${signal.k}' outside of a loop`);
50
56
  return { status: 'success', value: null, hasValue: false };
51
57
  }
@@ -75,23 +81,33 @@ class Interpreter {
75
81
  }
76
82
  // ---- environments --------------------------------------------------
77
83
  lookup(name, env) {
78
- for (let e = env; e; e = e.parent) {
79
- if (e.vars.has(name))
80
- return e.vars.get(name);
81
- }
84
+ const scope = this.bindingScope(name, env);
85
+ if (scope)
86
+ return scope.vars.get(name);
82
87
  const b = this.builtins.get(name);
83
88
  if (b)
84
89
  return b;
85
90
  throw runtimeError(`name '${name}' is not defined`);
86
91
  }
92
+ bindingScope(name, env) {
93
+ for (let scope = env; scope; scope = scope.parent) {
94
+ if (scope.vars.has(name))
95
+ return scope;
96
+ }
97
+ return undefined;
98
+ }
99
+ childScope(parent) {
100
+ return { vars: new Map(), parent };
101
+ }
87
102
  // ---- statements ----------------------------------------------------
88
103
  async execBlock(stmts, env) {
104
+ let completion;
89
105
  for (const s of stmts) {
90
- const signal = await this.execStmt(s, env);
91
- if (signal)
92
- return signal;
106
+ completion = await this.execStmt(s, env);
107
+ if (completion && completion.k !== 'value')
108
+ return completion;
93
109
  }
94
- return undefined;
110
+ return completion;
95
111
  }
96
112
  async execStmt(s, env) {
97
113
  try {
@@ -99,6 +115,8 @@ class Interpreter {
99
115
  return await this.execStmtInner(s, env);
100
116
  }
101
117
  catch (e) {
118
+ if (e instanceof ExpressionSignal)
119
+ return e.signal;
102
120
  if (e instanceof Failure)
103
121
  throw e.locate(s.line, this.program.lines[s.line - 1]?.trim() ?? '');
104
122
  if (e instanceof RangeError) {
@@ -116,10 +134,9 @@ class Interpreter {
116
134
  await this.execAssign(s.target, s.op, s.value, env);
117
135
  return;
118
136
  case 'block':
119
- return this.execBlock(s.body, env);
137
+ return this.execBlock(s.body, this.childScope(env));
120
138
  case 'expr':
121
- await this.eval(s.e, env);
122
- return;
139
+ return { k: 'value', v: await this.eval(s.e, env) };
123
140
  case 'pass':
124
141
  return;
125
142
  case 'break':
@@ -131,13 +148,13 @@ class Interpreter {
131
148
  case 'if': {
132
149
  for (const b of s.branches) {
133
150
  if (truthy(await this.eval(b.cond, env)))
134
- return this.execBlock(b.body, env);
151
+ return this.execBlock(b.body, this.childScope(env));
135
152
  }
136
- return s.orelse ? this.execBlock(s.orelse, env) : undefined;
153
+ return s.orelse ? this.execBlock(s.orelse, this.childScope(env)) : undefined;
137
154
  }
138
155
  case 'while': {
139
156
  while (truthy(await this.eval(s.cond, env))) {
140
- const sig = await this.execBlock(s.body, env);
157
+ const sig = await this.execBlock(s.body, this.childScope(env));
141
158
  if (sig?.k === 'break')
142
159
  break;
143
160
  if (sig?.k === 'return')
@@ -148,8 +165,9 @@ class Interpreter {
148
165
  case 'for': {
149
166
  const items = iterate(await this.eval(s.iter, env));
150
167
  for (let i = 0; i < items.length; i++) {
151
- this.bindLoopVars(s.vars, items[i], env);
152
- const sig = await this.execBlock(s.body, env);
168
+ const scope = this.childScope(env);
169
+ this.bindLoopVars(s.vars, items[i], scope);
170
+ const sig = await this.execBlock(s.body, scope);
153
171
  if (sig?.k === 'break')
154
172
  break;
155
173
  if (sig?.k === 'return')
@@ -167,7 +185,8 @@ class Interpreter {
167
185
  const savedRestrictions = this.restrictions;
168
186
  const savedDepth = this.depth;
169
187
  try {
170
- return await this.execBlock(s.body, env);
188
+ const completion = await this.execBlock(s.body, this.childScope(env));
189
+ return completion?.k === 'value' ? undefined : completion;
171
190
  }
172
191
  catch (e) {
173
192
  if (!(e instanceof Failure))
@@ -176,8 +195,10 @@ class Interpreter {
176
195
  this.restrictions = savedRestrictions;
177
196
  this.depth = savedDepth;
178
197
  this.opts.logger.debug(`caught ${e.statusCode}: ${e.reason}`);
179
- env.vars.set(s.errName, newDict(Object.entries(e.payload())));
180
- return this.execBlock(s.handler, env);
198
+ const scope = this.childScope(env);
199
+ scope.vars.set(s.errName, newDict(Object.entries(e.payload())));
200
+ const completion = await this.execBlock(s.handler, scope);
201
+ return completion?.k === 'value' ? undefined : completion;
181
202
  }
182
203
  }
183
204
  case 'assert': {
@@ -195,7 +216,8 @@ class Interpreter {
195
216
  const saved = this.constraints;
196
217
  this.constraints = [...saved, { kind: s.kind, text }];
197
218
  try {
198
- return await this.execBlock(s.body, env);
219
+ const completion = await this.execBlock(s.body, this.childScope(env));
220
+ return completion?.k === 'value' ? undefined : completion;
199
221
  }
200
222
  finally {
201
223
  this.constraints = saved;
@@ -209,7 +231,8 @@ class Interpreter {
209
231
  this.restrictions = this.restrictions.with(restriction);
210
232
  this.opts.logger.debug(`restrict ${clause.kind}: ${restriction.values.join(', ')}`);
211
233
  }
212
- return await this.execBlock(s.body, env);
234
+ const completion = await this.execBlock(s.body, this.childScope(env));
235
+ return completion?.k === 'value' ? undefined : completion;
213
236
  }
214
237
  finally {
215
238
  this.restrictions = saved;
@@ -233,9 +256,10 @@ class Interpreter {
233
256
  return op === '=' ? rhs : this.binary(op.slice(0, -1), current(), rhs);
234
257
  };
235
258
  if (target.t === 'name') {
236
- if (!env.vars.has(target.name))
259
+ const scope = this.bindingScope(target.name, env);
260
+ if (!scope)
237
261
  throw runtimeError(`cannot assign '${target.name}': declare a local with let first`);
238
- env.vars.set(target.name, await compute(() => this.lookup(target.name, env)));
262
+ scope.vars.set(target.name, await compute(() => this.lookup(target.name, env)));
239
263
  return;
240
264
  }
241
265
  const obj = await this.eval(target.obj, env);
@@ -273,6 +297,22 @@ class Interpreter {
273
297
  case 'str': return this.text(e.parts, env);
274
298
  case 'bool': return e.v;
275
299
  case 'none': return null;
300
+ case 'block':
301
+ case 'if': {
302
+ const completion = await this.execStmt(e, env);
303
+ if (completion && completion.k !== 'value')
304
+ throw new ExpressionSignal(completion);
305
+ return completion?.v ?? null;
306
+ }
307
+ case 'log': {
308
+ const level = await this.eval(e.level, env);
309
+ if (level !== 'error' && level !== 'info' && level !== 'debug') {
310
+ throw runtimeError("log level must be 'error', 'info' or 'debug'");
311
+ }
312
+ const args = await this.evalArgs(e.args, env);
313
+ this.opts.logger[level](`log: ${args.map(str).join(' ')}`);
314
+ return null;
315
+ }
276
316
  case 'name': return this.lookup(e.name, env);
277
317
  case 'list': {
278
318
  const out = [];
@@ -373,7 +413,7 @@ class Interpreter {
373
413
  this.depth++;
374
414
  try {
375
415
  const sig = await this.execBlock(fn.body, local);
376
- if (sig && sig.k !== 'return')
416
+ if (sig && sig.k !== 'return' && sig.k !== 'value')
377
417
  throw runtimeError(`'${sig.k}' outside of a loop`);
378
418
  return sig?.k === 'return' ? sig.v : null;
379
419
  }
@@ -527,8 +567,9 @@ class Interpreter {
527
567
  const fmt = options.fmt ?? ('schema' in options ? 'json' : 'text');
528
568
  if (len !== undefined && len !== 'word' && len !== 'sentence' && len !== 'paragraph' && len !== 'long')
529
569
  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');
570
+ if (fmt !== 'integer' && fmt !== 'float' && fmt !== 'array' && fmt !== 'object' && fmt !== 'text' && fmt !== 'boolean' && fmt !== 'json') {
571
+ throw runtimeError('fmt must be integer, float, array, object, text, boolean, or json');
572
+ }
532
573
  if (reasoning !== undefined && reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high')
533
574
  throw runtimeError('reasoning must be low, medium, or high');
534
575
  if (typeof show !== 'boolean')
@@ -544,7 +585,7 @@ class Interpreter {
544
585
  const validate = 'schema' in options ? compileSchema(options.schema) : undefined;
545
586
  let prompt = buildPrompt({
546
587
  prompt: str(await this.eval(promptExpr, env)),
547
- mods: { len, fmt: fmt === 'object' ? 'object' : undefined },
588
+ mods: { len, fmt },
548
589
  thinking: reasoning !== undefined,
549
590
  constraints: this.constraints,
550
591
  restrictions: this.restrictions,
@@ -564,7 +605,7 @@ class Interpreter {
564
605
  tools: new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay'),
565
606
  });
566
607
  try {
567
- const value = fmt === 'text' ? text : parseJsonResponse(text, fmt === 'object');
608
+ const value = fmt === 'text' ? text : parseJsonResponse(text, fmt);
568
609
  validate?.(value);
569
610
  return value;
570
611
  }
package/dist/lexer.js CHANGED
@@ -16,6 +16,7 @@ export function tokenize(src, opts = {}) {
16
16
  let col = opts.col ?? 1;
17
17
  let depth = opts.inExpr ? 1 : 0;
18
18
  const brackets = [];
19
+ const braceDepths = [];
19
20
  const err = (msg, l = line, c = col) => {
20
21
  throw new SplSyntaxError(msg, l, c);
21
22
  };
@@ -83,7 +84,14 @@ export function tokenize(src, opts = {}) {
83
84
  if (!op)
84
85
  err(`unexpected character '${c}'`);
85
86
  advance(op.length);
86
- if (op === '(' || op === '[') {
87
+ if (op === '{') {
88
+ braceDepths.push(depth);
89
+ depth = 0;
90
+ }
91
+ else if (op === '}') {
92
+ depth = braceDepths.pop() ?? depth;
93
+ }
94
+ else if (op === '(' || op === '[') {
87
95
  brackets.push(op);
88
96
  depth++;
89
97
  }
package/dist/llm.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface LlmBackend {
31
31
  /** Raised by a backend for any non-script-level LLM problem; becomes `llm_error`. */
32
32
  export declare class LlmError extends Error {
33
33
  }
34
+ type JsonFormat = Exclude<NonNullable<AskMods['fmt']>, 'text'>;
34
35
  export declare function buildPrompt(opts: {
35
36
  prompt: string;
36
37
  mods?: AskMods;
@@ -38,4 +39,5 @@ export declare function buildPrompt(opts: {
38
39
  constraints: readonly Constraint[];
39
40
  restrictions: Restrictions;
40
41
  }): string;
41
- export declare function parseJsonResponse(text: string, objectOnly?: boolean): Value;
42
+ export declare function parseJsonResponse(text: string, format?: JsonFormat): Value;
43
+ export {};
package/dist/llm.js CHANGED
@@ -10,6 +10,13 @@ const LEN_HINTS = {
10
10
  long: 'Give a long, detailed answer.',
11
11
  };
12
12
  const JSON_HINT = 'Respond with ONLY valid JSON. No prose, no markdown, no code fences.';
13
+ const FORMAT_CONTRACTS = {
14
+ integer: { description: 'a JSON integer', accepts: (value) => typeof value === 'number' && Number.isInteger(value) },
15
+ float: { description: 'a finite JSON number', accepts: (value) => typeof value === 'number' && Number.isFinite(value) },
16
+ array: { description: 'a JSON array', accepts: isList },
17
+ object: { description: 'a JSON object', accepts: isDict },
18
+ boolean: { description: 'a JSON boolean (true or false)', accepts: (value) => typeof value === 'boolean' },
19
+ };
13
20
  export function buildPrompt(opts) {
14
21
  const sections = [];
15
22
  if (opts.constraints.length) {
@@ -23,12 +30,13 @@ export function buildPrompt(opts) {
23
30
  sections.push('Think carefully about the problem before giving the final answer.');
24
31
  if (opts.mods?.len)
25
32
  sections.push(LEN_HINTS[opts.mods.len]);
26
- if (opts.mods?.fmt === 'object')
27
- sections.push(`${JSON_HINT} Return an object or array.`);
33
+ if (opts.mods?.fmt && opts.mods.fmt !== 'text' && opts.mods.fmt !== 'json') {
34
+ sections.push(`${JSON_HINT} Return ${FORMAT_CONTRACTS[opts.mods.fmt].description}.`);
35
+ }
28
36
  sections.push(opts.prompt);
29
37
  return sections.join('\n\n');
30
38
  }
31
- export function parseJsonResponse(text, objectOnly = false) {
39
+ export function parseJsonResponse(text, format = 'json') {
32
40
  let value;
33
41
  try {
34
42
  value = fromJson(JSON.parse(text));
@@ -38,8 +46,8 @@ export function parseJsonResponse(text, objectOnly = false) {
38
46
  throw e;
39
47
  throw new Failure('invalid_format', `LLM response is not valid JSON: ${e.message}`);
40
48
  }
41
- if (objectOnly && !isDict(value) && !isList(value)) {
42
- throw new Failure('invalid_format', 'LLM response must be a JSON object or array');
49
+ if (format !== 'json' && !FORMAT_CONTRACTS[format].accepts(value)) {
50
+ throw new Failure('invalid_format', `LLM response must be ${FORMAT_CONTRACTS[format].description} (fmt='${format}')`);
43
51
  }
44
52
  return value;
45
53
  }
package/dist/parser.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { Program } from './ast.js';
2
2
  export declare const MODIFIER_NAMES: readonly ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
3
+ export declare const LOG_MODIFIER_NAMES: readonly ['level'];
3
4
  export declare function parse(source: string): Program;
package/dist/parser.js CHANGED
@@ -6,6 +6,7 @@ const KEYWORDS = new Set([
6
6
  'True', 'False', 'None',
7
7
  ]);
8
8
  export const MODIFIER_NAMES = ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
9
+ export const LOG_MODIFIER_NAMES = ['level'];
9
10
  const DIRECTIVE_SCHEMA = { model: 'string' };
10
11
  const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '//=', '%=']);
11
12
  export function parse(source) {
@@ -160,7 +161,11 @@ class Parser {
160
161
  }
161
162
  parseBlock() {
162
163
  this.expectOp('{');
163
- const body = [];
164
+ return this.finishBlock();
165
+ }
166
+ finishBlock(body = []) {
167
+ if (body.length)
168
+ this.endOfStatement();
164
169
  this.skipSeparators();
165
170
  while (!this.isOp('}')) {
166
171
  if (this.tok.type === 'eof')
@@ -180,7 +185,6 @@ class Parser {
180
185
  if (t.type === 'name') {
181
186
  switch (t.value) {
182
187
  case 'let': return this.parseLet();
183
- case 'if': return this.parseIf();
184
188
  case 'for': {
185
189
  this.next();
186
190
  const vars = [this.expectIdent('a loop variable')];
@@ -257,8 +261,6 @@ class Parser {
257
261
  break;
258
262
  }
259
263
  }
260
- if (t.type === 'op' && t.value === '{')
261
- return { t: 'block', line, body: this.parseBlock() };
262
264
  return this.parseSimpleStatement();
263
265
  }
264
266
  parseLet() {
@@ -353,7 +355,7 @@ class Parser {
353
355
  this.next();
354
356
  return { t: 'assign', line, target, op: t.value, value: this.parseExpr() };
355
357
  }
356
- return { t: 'expr', line, e };
358
+ return e.t === 'block' || e.t === 'if' ? e : { t: 'expr', line, e };
357
359
  }
358
360
  toTarget(e, at) {
359
361
  if (e.t === 'name')
@@ -465,17 +467,7 @@ class Parser {
465
467
  let e = this.parsePrimary();
466
468
  for (;;) {
467
469
  if (this.isOp('(')) {
468
- this.next();
469
- const args = [];
470
- while (!this.isOp(')')) {
471
- args.push(this.parseExpr());
472
- if (this.isOp(','))
473
- this.next();
474
- else if (!this.isOp(')'))
475
- this.fail("expected ',' or ')' in call");
476
- }
477
- this.next();
478
- e = { t: 'call', callee: e, args };
470
+ e = { t: 'call', callee: e, args: this.parseArgs() };
479
471
  }
480
472
  else if (this.isOp('[')) {
481
473
  this.next();
@@ -494,6 +486,19 @@ class Parser {
494
486
  }
495
487
  }
496
488
  }
489
+ parseArgs() {
490
+ this.expectOp('(');
491
+ const args = [];
492
+ while (!this.isOp(')')) {
493
+ args.push(this.parseExpr());
494
+ if (this.isOp(','))
495
+ this.next();
496
+ else if (!this.isOp(')'))
497
+ this.fail("expected ',' or ')' in call");
498
+ }
499
+ this.next();
500
+ return args;
501
+ }
497
502
  parseSubscript(obj) {
498
503
  const lo = this.isOp(':') ? undefined : this.parseExpr();
499
504
  if (lo && !this.isOp(':')) {
@@ -544,10 +549,19 @@ class Parser {
544
549
  }
545
550
  if (t.value === '{') {
546
551
  this.next();
547
- this.skipNl();
552
+ this.skipSeparators();
553
+ if (this.isOp('}')) {
554
+ this.next();
555
+ return { t: 'dict', entries: [] };
556
+ }
557
+ const first = this.parseStatement();
558
+ const key = first.t === 'expr' ? first.e : first.t === 'if' || first.t === 'block' ? first : undefined;
559
+ if (!key || !this.isOp(':')) {
560
+ return { t: 'block', line: t.line, body: this.finishBlock([first]) };
561
+ }
548
562
  const entries = [];
563
+ let k = key;
549
564
  while (!this.isOp('}')) {
550
- const k = this.parseExpr();
551
565
  this.expectOp(':');
552
566
  this.skipNl();
553
567
  entries.push([k, this.parseExpr()]);
@@ -559,6 +573,8 @@ class Parser {
559
573
  else if (!this.isOp('}')) {
560
574
  this.fail("expected ',' or '}' in dict");
561
575
  }
576
+ if (!this.isOp('}'))
577
+ k = this.parseExpr();
562
578
  }
563
579
  this.next();
564
580
  return { t: 'dict', entries };
@@ -579,6 +595,16 @@ class Parser {
579
595
  case 'None':
580
596
  this.next();
581
597
  return { t: 'none' };
598
+ case 'if': return this.parseIf();
599
+ case 'log':
600
+ if (this.isOp('{', this.peekAt(1))) {
601
+ this.next();
602
+ const mods = this.parseMods('log');
603
+ const level = mods.level ?? { t: 'str', parts: ['info'] };
604
+ return { t: 'log', level, args: this.parseArgs() };
605
+ }
606
+ this.next();
607
+ return { t: 'name', name: t.value, line: t.line };
582
608
  case 'think':
583
609
  case 'agent': {
584
610
  this.next();
@@ -601,19 +627,24 @@ class Parser {
601
627
  this.expectOp('{');
602
628
  this.skipNl();
603
629
  const out = {};
630
+ const allowed = cmd === 'log' ? LOG_MODIFIER_NAMES : MODIFIER_NAMES;
604
631
  while (!this.isOp('}')) {
605
632
  const nameTok = this.tok;
606
633
  if (nameTok.type !== 'name')
607
634
  this.fail('expected a modifier name');
608
635
  const name = nameTok.value;
609
636
  this.next();
610
- if (!MODIFIER_NAMES.some((allowed) => allowed === name)) {
611
- this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${MODIFIER_NAMES.join(', ')})`, nameTok);
637
+ if (!allowed.includes(name)) {
638
+ this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${allowed.join(', ')})`, nameTok);
612
639
  }
613
640
  if (name in out)
614
641
  this.fail(`duplicate modifier '${name}'`, nameTok);
615
642
  this.expectOp('=');
616
643
  out[name] = this.parseExpr();
644
+ const value = out[name];
645
+ if (cmd === 'log' && value.t === 'name' && ['error', 'info', 'debug'].includes(value.name)) {
646
+ out[name] = { t: 'str', parts: [value.name] };
647
+ }
617
648
  this.skipNl();
618
649
  if (this.isOp(',')) {
619
650
  this.next();
package/docs/syntax.ebnf CHANGED
@@ -24,10 +24,11 @@ guidance_stmt = ( "enforce" | "forbid" ) , expr , block ;
24
24
  restrict_stmt = "restrict" , restrict_clause , { "," , restrict_clause } , block ;
25
25
  restrict_clause = [ "fs" | "net" | "exec" | "mcp" ] , expr ;
26
26
 
27
- (* let declares/redeclares in the current function or script. All blocks share that scope.
28
- Name assignment requires a declared local; it cannot rebind an enclosing function/script.
29
- Functions read lexical enclosing scopes; collection mutations can share state.
30
- Loop variables and caught failure names are local bindings.
27
+ (* Every block creates a lexical child scope. let declares/redeclares locally and shadows parents.
28
+ Reads and assignments search outward for the first declaration; undeclared names are errors.
29
+ Each loop iteration has fresh locals; caught failure names belong to the handler scope.
30
+ Blocks/if yield their final expression, otherwise None. An unmatched if yields None.
31
+ {} remains an empty dict; { pass } is a block yielding None. Functions need explicit return.
31
32
  return exits the innermost function, or the script at top level.
32
33
  success exits the script, including when used in a function. *)
33
34
 
@@ -46,16 +47,20 @@ index = "[" , expr , "]" ;
46
47
  slice = "[" , [ expr ] , ":" , [ expr ] , "]" ;
47
48
  member = "." , NAME ;
48
49
  primary = NUMBER | string | "True" | "False" | "None" | NAME
49
- | "(" , expr , ")" | list | dict
50
+ | "(" , expr , ")" | list | dict | block | if_stmt | log_expr
50
51
  | ( "think" | "agent" ) , [ modifiers ] , postfix ;
51
52
  list = "[" , [ expr , { "," , expr } , [ "," ] ] , "]" ;
52
53
  dict = "{" , [ expr , ":" , expr , { "," , expr , ":" , expr } , [ "," ] ] , "}" ;
53
54
  modifiers = "{" , [ modifier , { "," , modifier } , [ "," ] ] , "}" ;
54
55
  modifier = ( "len" | "fmt" | "reasoning" | "show" | "schema" | "repairs" | "model" ) , "=" , expr ;
56
+ log_expr = "log" , "{" , [ "level" , "=" , log_level , [ "," ] ] , "}" , call ;
57
+ log_level = "error" | "info" | "debug" | expr ;
55
58
 
56
59
  (* Unknown/duplicate modifier names are syntax errors. Values are expressions, validated at runtime:
57
60
  len: word/sentence/paragraph/long (guidance only)
58
- fmt: text/json/object; schema implies json unless fmt is explicit
61
+ fmt: integer/float/array/object/text/boolean/json; schema implies json unless fmt is explicit
62
+ Non-text formats require strict JSON of the selected type; float accepts finite numbers,
63
+ object excludes arrays, and json accepts any JSON value.
59
64
  reasoning: low/medium/high; think and agent default to medium
60
65
  show: True/False; schema: synchronous strict JSON Schema draft-07 object or boolean
61
66
  repairs: integer 0..5 (additional generation attempts, default 0); agents require 0
@@ -63,6 +68,9 @@ modifier = ( "len" | "fmt" | "reasoning" | "show" | "schema" | "repairs" |
63
68
  Generation (? and think) has no tools. agent explicitly enables restricted runtime/MCP tools.
64
69
  Raw tools are ordinary positional functions: read_file(path), write_file(path, content),
65
70
  web_fetch(url), web_search(query), shell(command). They never invoke a model.
71
+ log{level=...}(args) selects error/info/debug severity; plain log(args) defaults to info.
72
+ Level values are bare names, strings or expressions, validated before evaluating arguments.
73
+ Debug messages are emitted only at debug log level; silent suppresses all logs.
66
74
  enforce/forbid are scoped prompt guidance.
67
75
  schema/assert validate data; restrict checks actions. *)
68
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spl-lang",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Semantic Prompt Language: a Python-like language with LLM calls built into the syntax",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",