spl-lang 0.4.1 → 0.6.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.
package/README.md CHANGED
@@ -42,6 +42,10 @@ under `~/.spl` (override with `SPL_HOME`); `spl logout` removes those saved cred
42
42
  `{}` remains an empty dict. Functions still require explicit `return`.
43
43
  - **Logging:** `log(...)` defaults to info. `log{level=debug}(...)` selects error/info/debug;
44
44
  bare level names, strings and expressions are accepted and checked at runtime.
45
+ - **Runtime inputs:** `$env.NAME` / `$env['NAME']` reads process environment strings;
46
+ `$args[i]` reads repeated CLI `--arg` strings in order (empty list by default).
47
+ Missing environment names and out-of-range argument indexes return `None`.
48
+ Both are read-only snapshots, including through aliases, and are not automatically persisted.
45
49
 
46
50
  Common generation modifiers are `len`, `fmt`, `reasoning`, `show`, `schema`, `repairs`,
47
51
  and `model`. Values are evaluated once, then validated before calling the model.
@@ -67,6 +71,7 @@ spl script.spl [--model id] [--mcp file.json] [--cwd dir] [--timeout seconds]
67
71
  [--store file.json | --store-module module]
68
72
  [--record file.jsonl | --replay file.jsonl]
69
73
  [--log-level error|info|debug] [--silent] [--check]
74
+ [--arg value ...]
70
75
  ```
71
76
 
72
77
  Results are printed on stdout, logs on stderr. Full prompts/responses and statement traces
@@ -87,6 +92,8 @@ The package exports `parse`, `run`, `Logger`, `CopilotBackend`, `RecordingBacken
87
92
  `ReplayBackend`, variable stores, authentication helpers, and related types.
88
93
  Call `close()` on the backend after running; replay also checks for unused entries there.
89
94
  Custom backends must honor the request kind: only `agent` may execute tools.
95
+ Embedding callers can pass `args: ['first', 'second']` in `RunOptions`; omitted
96
+ arguments default to `[]`, not the host's `process.argv`. Each run snapshots `process.env`.
90
97
  See [embedding](../docs/internals.html#embedding).
91
98
 
92
99
  ## Migration
package/dist/ast.d.ts CHANGED
@@ -14,6 +14,9 @@ export type Expr = {
14
14
  t: 'name';
15
15
  name: string;
16
16
  line: number;
17
+ } | {
18
+ t: 'special';
19
+ name: 'env' | 'args';
17
20
  } | {
18
21
  t: 'list';
19
22
  items: Expr[];
package/dist/builtins.js CHANGED
@@ -1,4 +1,4 @@
1
- import { compareValues, equals, fromJson, hasKey, isDict, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
1
+ import { assertMutable, compareValues, equals, fromJson, hasKey, isDict, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
2
2
  function arity(name, args, min, max = min) {
3
3
  if (args.length < min || args.length > max) {
4
4
  const expected = min === max ? `${min}` : `${min}-${max}`;
@@ -346,5 +346,10 @@ export function callMethod(self, name, args) {
346
346
  if (!table || !Object.prototype.hasOwnProperty.call(table, name)) {
347
347
  throw runtimeError(`${typeName(self)} has no method '${name}'`);
348
348
  }
349
+ const mutating = isList(self)
350
+ ? ['append', 'extend', 'insert', 'pop', 'remove', 'reverse', 'sort', 'clear']
351
+ : isDict(self) ? ['pop', 'update', 'clear'] : [];
352
+ if (mutating.includes(name))
353
+ assertMutable(self);
349
354
  return table[name](self, args);
350
355
  }
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { run } from './interpreter.js';
12
12
  import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
13
13
  import { toJson } from './values.js';
14
14
  import { loadMcpConfig } from './mcp.js';
15
- import { RecordingBackend, ReplayBackend } from './recording.js';
15
+ import { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
16
16
  const HELP = `spl - Semantic Prompt Language interpreter
17
17
 
18
18
  Usage: spl <script.spl> [options]
@@ -24,12 +24,15 @@ Options:
24
24
  All levels include LLM transcripts and [deterministic] statement traces.
25
25
  --silent suppress all logs; only the result is printed
26
26
  --model <id> model override (takes precedence over @{model=...})
27
+ --arg <value> script argument exposed in $args (repeatable; also --arg=value)
27
28
  --mcp <file.json> MCP servers in VS Code mcp.json format
28
29
  --store <file.json> persist top-level variables in a JSON file
29
30
  --store-module <path> custom variable store: module exporting createStore() -> { load(), save(vars) }
30
31
  --timeout <seconds> per LLM call timeout (default: 300)
32
+ --trace <file.jsonl> trace all LLM requests, responses, and failures (new file)
31
33
  --record <file.jsonl> record generation responses (new file; no agents)
32
- --replay <file.jsonl> offline generation replay; only local reads allowed
34
+ --replay <file.jsonl> replay LLM results without Copilot/MCP; explicit script tools still run
35
+ Legacy --record files allow only local reads and no stores.
33
36
  --cwd <dir> base directory for relative paths and restrictions (default: current directory)
34
37
  --check validate script syntax and any --mcp config without executing
35
38
  -v, --version print version
@@ -79,10 +82,12 @@ async function main() {
79
82
  'log-level': { type: 'string' },
80
83
  silent: { type: 'boolean' },
81
84
  model: { type: 'string' },
85
+ arg: { type: 'string', multiple: true },
82
86
  mcp: { type: 'string' },
83
87
  store: { type: 'string' },
84
88
  'store-module': { type: 'string' },
85
89
  timeout: { type: 'string' },
90
+ trace: { type: 'string' },
86
91
  record: { type: 'string' },
87
92
  replay: { type: 'string' },
88
93
  cwd: { type: 'string' },
@@ -120,10 +125,12 @@ async function main() {
120
125
  usageError('--timeout must be a positive number');
121
126
  if (values.store && values['store-module'])
122
127
  usageError('--store and --store-module are mutually exclusive');
123
- if (values.record && values.replay)
124
- usageError('--record and --replay are mutually exclusive');
125
- if (values.replay && (values.store || values['store-module']))
126
- usageError('--replay cannot use a persistent variable store');
128
+ if ([values.trace, values.record, values.replay].filter((value) => value !== undefined).length > 1) {
129
+ usageError('--trace, --record, and --replay are mutually exclusive');
130
+ }
131
+ if ([values.trace, values.record, values.replay].some((value) => value !== undefined && value.trim() === '')) {
132
+ usageError('--trace, --record, and --replay require a non-empty file path');
133
+ }
127
134
  const cwd = path.resolve(values.cwd ?? process.cwd());
128
135
  const file = positionals[0];
129
136
  let source;
@@ -153,13 +160,6 @@ async function main() {
153
160
  }
154
161
  if (values.check)
155
162
  return 0;
156
- let store;
157
- try {
158
- store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
159
- }
160
- catch (e) {
161
- return usageError(e.message);
162
- }
163
163
  let backend;
164
164
  try {
165
165
  if (values.replay)
@@ -168,6 +168,8 @@ async function main() {
168
168
  backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000, mcpServers });
169
169
  if (values.record)
170
170
  backend = new RecordingBackend(backend, values.record);
171
+ if (values.trace)
172
+ backend = new TraceBackend(backend, values.trace);
171
173
  }
172
174
  }
173
175
  catch (e) {
@@ -175,9 +177,19 @@ async function main() {
175
177
  throw e;
176
178
  return usageError(`cannot open recording: ${e.message}`);
177
179
  }
180
+ if (backend.mode === 'replay' && (values.store || values['store-module'])) {
181
+ return usageError('--replay of legacy --record files cannot use a persistent variable store');
182
+ }
183
+ let store;
184
+ try {
185
+ store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
186
+ }
187
+ catch (e) {
188
+ return usageError(e.message);
189
+ }
178
190
  let result;
179
191
  try {
180
- result = await run(program, { backend, logger, store: values.replay ? undefined : store, cwd, model: values.model });
192
+ result = await run(program, { backend, logger, store: backend.mode === 'replay' ? undefined : store, cwd, model: values.model, args: values.arg });
181
193
  }
182
194
  finally {
183
195
  try {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { run, type RunOptions, type RunResult } from './interpreter.js';
3
3
  export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
4
4
  export { Logger, type LogLevel } from './logger.js';
5
5
  export { CopilotBackend } from './copilot.js';
6
- export { RecordingBackend, ReplayBackend } from './recording.js';
6
+ export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
7
7
  export { loadMcpConfig, type McpServers } from './mcp.js';
8
8
  export { login, logout, authStatus, AuthError } from './auth.js';
9
9
  export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { run } from './interpreter.js';
3
3
  export { Failure, SplSyntaxError } from './errors.js';
4
4
  export { Logger } from './logger.js';
5
5
  export { CopilotBackend } from './copilot.js';
6
- export { RecordingBackend, ReplayBackend } from './recording.js';
6
+ export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
7
7
  export { loadMcpConfig } from './mcp.js';
8
8
  export { login, logout, authStatus, AuthError } from './auth.js';
9
9
  export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
@@ -12,6 +12,8 @@ export interface RunOptions {
12
12
  cwd?: string;
13
13
  /** Overrides the `@{model=...}` directive. */
14
14
  model?: string;
15
+ /** Script arguments exposed as read-only `$args`; defaults to an empty list. */
16
+ args?: readonly string[];
15
17
  maxCallDepth?: number;
16
18
  }
17
19
  export type RunResult = {
@@ -5,7 +5,7 @@ import { compileSchema } from './schema.js';
5
5
  import { Restrictions, makeRestriction } from './restrict.js';
6
6
  import { MemoryStore } from './store.js';
7
7
  import { ToolHost, TOOL_SPECS } from './tools.js';
8
- import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
8
+ import { assertMutable, compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
9
9
  class ExpressionSignal {
10
10
  signal;
11
11
  constructor(signal) {
@@ -24,6 +24,8 @@ class Interpreter {
24
24
  cwd;
25
25
  model;
26
26
  maxDepth;
27
+ runtimeEnv = newDict();
28
+ runtimeArgs;
27
29
  constraints = [];
28
30
  restrictions;
29
31
  depth = 0;
@@ -34,6 +36,13 @@ class Interpreter {
34
36
  this.store = opts.store ?? new MemoryStore();
35
37
  this.model = opts.model ?? program.directives.model;
36
38
  this.maxDepth = opts.maxCallDepth ?? 200;
39
+ for (const [name, value] of Object.entries(process.env)) {
40
+ if (value !== undefined)
41
+ setKey(this.runtimeEnv, name, value);
42
+ }
43
+ this.runtimeArgs = [...(opts.args ?? [])];
44
+ Object.freeze(this.runtimeEnv);
45
+ Object.freeze(this.runtimeArgs);
37
46
  this.restrictions = new Restrictions(this.cwd);
38
47
  this.builtins = makeBuiltins({ log: (m) => opts.logger.info(`log: ${m}`) });
39
48
  for (const tool of Object.keys(TOOL_SPECS)) {
@@ -263,6 +272,7 @@ class Interpreter {
263
272
  return;
264
273
  }
265
274
  const obj = await this.eval(target.obj, env);
275
+ assertMutable(obj);
266
276
  if (target.t === 'member') {
267
277
  if (!isDict(obj))
268
278
  throw runtimeError(`cannot set member '${target.name}' on ${typeName(obj)}`);
@@ -314,6 +324,7 @@ class Interpreter {
314
324
  return null;
315
325
  }
316
326
  case 'name': return this.lookup(e.name, env);
327
+ case 'special': return e.name === 'env' ? this.runtimeEnv : this.runtimeArgs;
317
328
  case 'list': {
318
329
  const out = [];
319
330
  for (const it of e.items)
@@ -379,8 +390,11 @@ class Interpreter {
379
390
  }
380
391
  }
381
392
  getKey(d, k) {
382
- if (!hasKey(d, k))
393
+ if (!hasKey(d, k)) {
394
+ if (d === this.runtimeEnv)
395
+ return null;
383
396
  throw runtimeError(`key '${k}' not found`);
397
+ }
384
398
  return d[k];
385
399
  }
386
400
  async evalCall(callee, argExprs, env) {
@@ -493,13 +507,13 @@ class Interpreter {
493
507
  if (typeof index !== 'number' || !Number.isInteger(index))
494
508
  throw runtimeError(`indices must be integers, got ${typeName(index)}`);
495
509
  const i = index < 0 ? list.length + index : index;
496
- if (i < 0 || i >= list.length)
510
+ if ((i < 0 || i >= list.length) && list !== this.runtimeArgs)
497
511
  throw runtimeError(`index ${index} out of range`);
498
512
  return i;
499
513
  }
500
514
  index(obj, index) {
501
515
  if (isList(obj))
502
- return obj[this.listIndex(obj, index)];
516
+ return obj[this.listIndex(obj, index)] ?? null;
503
517
  if (typeof obj === 'string') {
504
518
  const chars = Array.from(obj);
505
519
  return chars[this.listIndex(chars, index)];
@@ -557,7 +571,7 @@ class Interpreter {
557
571
  return new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay', true).invoke(tool, params);
558
572
  }
559
573
  async evalGeneration(kind, mods, promptExpr, env) {
560
- if (kind === 'agent' && this.opts.backend.mode) {
574
+ if (kind === 'agent' && (this.opts.backend.mode === 'record' || this.opts.backend.mode === 'replay')) {
561
575
  throw new Failure('restriction_violation', 'agents cannot run in record/replay mode; record generation-only workflows');
562
576
  }
563
577
  const options = {};
package/dist/lexer.js CHANGED
@@ -71,8 +71,10 @@ export function tokenize(src, opts = {}) {
71
71
  tokens.push(readString(raw));
72
72
  continue;
73
73
  }
74
- if (isNameStart(c)) {
75
- let j = i;
74
+ if (isNameStart(c) || c === '$') {
75
+ if (c === '$' && (i + 1 >= src.length || !isNameStart(src[i + 1])))
76
+ err("unexpected character '$'");
77
+ let j = i + (c === '$' ? 1 : 0);
76
78
  while (j < src.length && isNameChar(src[j]))
77
79
  j++;
78
80
  const value = src.slice(i, j);
package/dist/llm.d.ts CHANGED
@@ -24,7 +24,7 @@ export interface LlmResponse {
24
24
  effective: Record<string, unknown>;
25
25
  }
26
26
  export interface LlmBackend {
27
- readonly mode?: 'record' | 'replay';
27
+ readonly mode?: 'record' | 'replay' | 'trace' | 'workflow-replay';
28
28
  call(req: LlmRequest): Promise<LlmResponse>;
29
29
  close(): Promise<void>;
30
30
  }
package/dist/parser.js CHANGED
@@ -69,6 +69,8 @@ class Parser {
69
69
  if (t.type !== 'name')
70
70
  this.fail(`expected ${what} but found ${this.describe(t)}`);
71
71
  const name = t.value;
72
+ if (name.startsWith('$'))
73
+ this.fail(`special variable '${name}' cannot be used as ${what}`);
72
74
  if (KEYWORDS.has(name))
73
75
  this.fail(`'${name}' is a reserved word and cannot be used as ${what}`);
74
76
  this.next();
@@ -476,7 +478,7 @@ class Parser {
476
478
  else if (this.isOp('.')) {
477
479
  this.next();
478
480
  const t = this.tok;
479
- if (t.type !== 'name')
481
+ if (t.type !== 'name' || t.value.startsWith('$'))
480
482
  this.fail("expected a member name after '.'");
481
483
  this.next();
482
484
  e = { t: 'member', obj: e, name: t.value };
@@ -585,6 +587,12 @@ class Parser {
585
587
  return this.fail(`unexpected ${this.describe(t)}`);
586
588
  }
587
589
  parseNamePrimary(t) {
590
+ if (t.value.startsWith('$')) {
591
+ if (t.value !== '$env' && t.value !== '$args')
592
+ this.fail(`unknown special variable '${t.value}'`);
593
+ this.next();
594
+ return { t: 'special', name: t.value === '$env' ? 'env' : 'args' };
595
+ }
588
596
  switch (t.value) {
589
597
  case 'True':
590
598
  this.next();
@@ -7,8 +7,16 @@ export declare class RecordingBackend implements LlmBackend {
7
7
  call(req: LlmRequest): Promise<LlmResponse>;
8
8
  close(): Promise<void>;
9
9
  }
10
+ export declare class TraceBackend implements LlmBackend {
11
+ private readonly backend;
12
+ private readonly file;
13
+ readonly mode = "trace";
14
+ constructor(backend: LlmBackend, file: string);
15
+ call(req: LlmRequest): Promise<LlmResponse>;
16
+ close(): Promise<void>;
17
+ }
10
18
  export declare class ReplayBackend implements LlmBackend {
11
- readonly mode = "replay";
19
+ readonly mode: 'replay' | 'workflow-replay';
12
20
  private readonly entries;
13
21
  private position;
14
22
  constructor(file: string);
package/dist/recording.js CHANGED
@@ -1,14 +1,36 @@
1
1
  import * as fs from 'node:fs';
2
+ import { Failure } from './errors.js';
2
3
  import { LlmError } from './llm.js';
3
- function requestKey(req) {
4
- return JSON.stringify({
4
+ function traceRequest(req) {
5
+ return {
5
6
  kind: req.kind, prompt: req.prompt, model: req.model ?? null,
6
7
  reasoningEffort: req.reasoningEffort ?? null, showReasoning: req.showReasoning,
7
- });
8
+ };
9
+ }
10
+ function requestKey(req) {
11
+ return JSON.stringify(traceRequest(req));
8
12
  }
9
13
  function isRecord(value) {
10
14
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
15
  }
16
+ function isResponse(value) {
17
+ return isRecord(value) && typeof value.text === 'string' && isRecord(value.effective)
18
+ && (value.reasoning === undefined || typeof value.reasoning === 'string');
19
+ }
20
+ function isTraceRequest(value) {
21
+ return isRecord(value) && (value.kind === 'ask' || value.kind === 'think' || value.kind === 'agent')
22
+ && typeof value.prompt === 'string' && (value.model === null || typeof value.model === 'string')
23
+ && (value.reasoningEffort === null || value.reasoningEffort === 'low' || value.reasoningEffort === 'medium' || value.reasoningEffort === 'high')
24
+ && typeof value.showReasoning === 'boolean';
25
+ }
26
+ function isTraceError(value) {
27
+ const codes = [
28
+ 'assertion_failed', 'explicit_failure', 'invalid_format', 'validation_failed',
29
+ 'restriction_violation', 'llm_error', 'tool_error', 'runtime_error',
30
+ ];
31
+ return isRecord(value) && typeof value.reason === 'string'
32
+ && codes.some((code) => code === value.status_code);
33
+ }
12
34
  function parseLine(line, number) {
13
35
  try {
14
36
  return JSON.parse(line);
@@ -47,35 +69,81 @@ export class RecordingBackend {
47
69
  return this.backend.close();
48
70
  }
49
71
  }
72
+ export class TraceBackend {
73
+ backend;
74
+ file;
75
+ mode = 'trace';
76
+ constructor(backend, file) {
77
+ this.backend = backend;
78
+ this.file = file;
79
+ fs.writeFileSync(file, JSON.stringify({ format: 'spl-trace', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
80
+ }
81
+ async call(req) {
82
+ const request = traceRequest(req);
83
+ let outcome;
84
+ let failure;
85
+ try {
86
+ outcome = { response: await this.backend.call(req) };
87
+ }
88
+ catch (e) {
89
+ failure = e instanceof Error ? e : new LlmError(String(e));
90
+ outcome = { error: {
91
+ status_code: e instanceof Failure ? e.statusCode : 'llm_error',
92
+ reason: e instanceof Failure ? e.reason : e instanceof Error ? e.message : String(e),
93
+ } };
94
+ }
95
+ try {
96
+ fs.appendFileSync(this.file, JSON.stringify({ request, ...outcome }) + '\n');
97
+ }
98
+ catch (e) {
99
+ if (!(e instanceof Error))
100
+ throw e;
101
+ throw new LlmError(`could not save trace: ${e.message}`);
102
+ }
103
+ if ('error' in outcome)
104
+ throw failure;
105
+ return outcome.response;
106
+ }
107
+ close() {
108
+ return this.backend.close();
109
+ }
110
+ }
50
111
  export class ReplayBackend {
51
- mode = 'replay';
112
+ mode;
52
113
  entries;
53
114
  position = 0;
54
115
  constructor(file) {
55
116
  const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
56
117
  const header = parseLine(lines.shift() ?? '', 1);
57
- if (!isRecord(header) || header.format !== 'spl-generation' || header.version !== 1) {
118
+ if (!isRecord(header) || (header.format !== 'spl-generation' && header.format !== 'spl-trace') || header.version !== 1) {
58
119
  throw new LlmError('unsupported recording format/version');
59
120
  }
121
+ this.mode = header.format === 'spl-trace' ? 'workflow-replay' : 'replay';
60
122
  this.entries = lines.map((line, i) => {
61
123
  const entry = parseLine(line, i + 2);
62
- if (!isRecord(entry) || typeof entry.request !== 'string' || !isRecord(entry.response)
63
- || typeof entry.response.text !== 'string' || !isRecord(entry.response.effective)
64
- || (entry.response.reasoning !== undefined && typeof entry.response.reasoning !== 'string')) {
124
+ if (!isRecord(entry))
125
+ throw new LlmError(`invalid recording entry at line ${i + 2}`);
126
+ let request;
127
+ if (this.mode === 'workflow-replay' && isTraceRequest(entry.request)) {
128
+ request = JSON.stringify(traceRequest(entry.request));
129
+ }
130
+ else if (this.mode === 'replay' && typeof entry.request === 'string') {
131
+ request = entry.request;
132
+ }
133
+ else {
65
134
  throw new LlmError(`invalid recording entry at line ${i + 2}`);
66
135
  }
67
- return {
68
- request: entry.request,
69
- response: {
70
- text: entry.response.text,
71
- effective: entry.response.effective,
72
- reasoning: entry.response.reasoning,
73
- },
74
- };
136
+ if (isResponse(entry.response) && entry.error === undefined) {
137
+ return { request, outcome: { response: entry.response } };
138
+ }
139
+ if (this.mode === 'workflow-replay' && entry.response === undefined && isTraceError(entry.error)) {
140
+ return { request, outcome: { error: entry.error } };
141
+ }
142
+ throw new LlmError(`invalid recording entry at line ${i + 2}`);
75
143
  });
76
144
  }
77
145
  async call(req) {
78
- if (req.kind === 'agent')
146
+ if (req.kind === 'agent' && this.mode === 'replay')
79
147
  throw new LlmError('agent actions cannot be replayed');
80
148
  const entry = this.entries[this.position];
81
149
  if (!entry)
@@ -83,7 +151,11 @@ export class ReplayBackend {
83
151
  if (entry.request !== requestKey(req))
84
152
  throw new LlmError(`replay request mismatch at call ${this.position + 1}`);
85
153
  this.position++;
86
- return structuredClone(entry.response);
154
+ if ('error' in entry.outcome) {
155
+ const { status_code, reason } = entry.outcome.error;
156
+ throw new Failure(status_code, reason);
157
+ }
158
+ return structuredClone(entry.outcome.response);
87
159
  }
88
160
  async close() {
89
161
  if (this.position !== this.entries.length) {
package/dist/values.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface BuiltinFunction {
21
21
  }
22
22
  export type FunctionValue = UserFunction | BuiltinFunction;
23
23
  export declare const runtimeError: (msg: string) => Failure;
24
+ export declare function assertMutable(v: Value): void;
24
25
  export declare function isFunction(v: unknown): v is FunctionValue;
25
26
  export declare function isList(v: unknown): v is Value[];
26
27
  export declare function isDict(v: unknown): v is {
package/dist/values.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Failure } from './errors.js';
2
2
  export const runtimeError = (msg) => new Failure('runtime_error', msg);
3
+ export function assertMutable(v) {
4
+ if (typeof v === 'object' && v !== null && Object.isFrozen(v)) {
5
+ throw runtimeError('cannot modify a read-only value');
6
+ }
7
+ }
3
8
  export function isFunction(v) {
4
9
  return typeof v === 'object' && v !== null && '__fn' in v && (v.__fn === 'user' || v.__fn === 'builtin');
5
10
  }
package/docs/syntax.ebnf CHANGED
@@ -46,9 +46,10 @@ call = "(" , [ expr , { "," , expr } , [ "," ] ] , ")" ;
46
46
  index = "[" , expr , "]" ;
47
47
  slice = "[" , [ expr ] , ":" , [ expr ] , "]" ;
48
48
  member = "." , NAME ;
49
- primary = NUMBER | string | "True" | "False" | "None" | NAME
49
+ primary = NUMBER | string | "True" | "False" | "None" | NAME | special_var
50
50
  | "(" , expr , ")" | list | dict | block | if_stmt | log_expr
51
51
  | ( "think" | "agent" ) , [ modifiers ] , postfix ;
52
+ special_var = "$env" | "$args" ;
52
53
  list = "[" , [ expr , { "," , expr } , [ "," ] ] , "]" ;
53
54
  dict = "{" , [ expr , ":" , expr , { "," , expr , ":" , expr } , [ "," ] ] , "}" ;
54
55
  modifiers = "{" , [ modifier , { "," , modifier } , [ "," ] ] , "}" ;
@@ -84,6 +85,12 @@ NAME = ( letter | "_" ) , { letter | digit | "_" } ;
84
85
  NL = newline ;
85
86
 
86
87
  (* "{{"/"}}" are literal braces; raw strings have no interpolation or escapes.
88
+ $env is a read-only process-environment dict snapshot; missing keys return None.
89
+ $args is a read-only list of repeated CLI --arg strings (empty by default);
90
+ out-of-range integer indexes return None, and negative indexes count from the end.
91
+ Both are available in every scope and interpolation, cannot be rebound or mutated
92
+ (including through aliases), and are not automatically persisted.
93
+ Other $-prefixed names are syntax errors. Ordinary dict/list missing reads still fail.
87
94
  Reserved words: let if elif else for in while def return try except assert fail success
88
95
  enforce forbid restrict think agent break continue pass and or not True False None.
89
96
  Removed syntax: think{level=...}, tool{param=...} 'instructions',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spl-lang",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
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",