spl-lang 0.2.0 → 0.3.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/dist/schema.js ADDED
@@ -0,0 +1,40 @@
1
+ import { Ajv } from 'ajv';
2
+ import { Failure } from './errors.js';
3
+ import { isDict, runtimeError, toJson } from './values.js';
4
+ const ajv = new Ajv({ allErrors: true, strict: true });
5
+ function errorPath(error) {
6
+ const parts = error.instancePath.split('/').slice(1).map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
7
+ if (error.keyword === 'required')
8
+ parts.push(String(error.params.missingProperty));
9
+ if (error.keyword === 'additionalProperties')
10
+ parts.push(String(error.params.additionalProperty));
11
+ return parts.reduce((path, part) => {
12
+ if (/^(0|[1-9]\d*)$/.test(part))
13
+ return `${path}[${part}]`;
14
+ return /^[a-zA-Z_]\w*$/.test(part) ? `${path ? `${path}.` : ''}${part}` : `${path}[${JSON.stringify(part)}]`;
15
+ }, '') || '$';
16
+ }
17
+ export function compileSchema(schema) {
18
+ if (typeof schema !== 'boolean' && !isDict(schema))
19
+ throw runtimeError('schema must be a JSON Schema object or boolean');
20
+ const json = toJson(schema);
21
+ if (typeof json !== 'boolean' && (typeof json !== 'object' || json === null))
22
+ throw runtimeError('invalid schema');
23
+ let validate;
24
+ try {
25
+ validate = ajv.compile(json);
26
+ if ('$async' in validate && validate.$async)
27
+ throw new Error('asynchronous schemas are not supported');
28
+ }
29
+ catch (e) {
30
+ if (!(e instanceof Error))
31
+ throw e;
32
+ throw runtimeError(`invalid schema: ${e.message}`);
33
+ }
34
+ return (value) => {
35
+ if (!validate(value)) {
36
+ const details = (validate.errors ?? []).map((error) => `${errorPath(error)}: ${error.message}`).join('; ');
37
+ throw new Failure('validation_failed', details);
38
+ }
39
+ };
40
+ }
package/dist/tools.d.ts CHANGED
@@ -30,7 +30,9 @@ export declare function parseSearchResults(html: string): SearchResult[];
30
30
  export declare class ToolHost {
31
31
  private readonly restrictions;
32
32
  private readonly cwd;
33
- constructor(restrictions: Restrictions, cwd: string);
33
+ private readonly replay;
34
+ private readonly raw;
35
+ constructor(restrictions: Restrictions, cwd: string, replay?: boolean, raw?: boolean);
34
36
  invoke(name: ToolName, params: Record<string, unknown>): Promise<string>;
35
37
  private readFile;
36
38
  private writeFile;
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
+ import { StringDecoder } from 'node:string_decoder';
4
5
  import { Failure } from './errors.js';
5
6
  const MAX_TEXT = 200_000;
6
7
  const SHELL_TIMEOUT_MS = 120_000;
@@ -251,11 +252,17 @@ function isDirSwitch(executable, arg) {
251
252
  export class ToolHost {
252
253
  restrictions;
253
254
  cwd;
254
- constructor(restrictions, cwd) {
255
+ replay;
256
+ raw;
257
+ constructor(restrictions, cwd, replay = false, raw = false) {
255
258
  this.restrictions = restrictions;
256
259
  this.cwd = cwd;
260
+ this.replay = replay;
261
+ this.raw = raw;
257
262
  }
258
263
  async invoke(name, params) {
264
+ if (this.replay && name !== 'read_file')
265
+ throw new Failure('restriction_violation', `replay blocks ${name}; only local reads and recorded generation are allowed`);
259
266
  switch (name) {
260
267
  case 'read_file': return this.readFile(needString(name, params, 'path'));
261
268
  case 'write_file': {
@@ -272,7 +279,8 @@ export class ToolHost {
272
279
  async readFile(p) {
273
280
  const canonical = this.restrictions.checkPath(p, 'read');
274
281
  try {
275
- return truncate(await fs.readFile(canonical, 'utf8'));
282
+ const text = await fs.readFile(canonical, 'utf8');
283
+ return this.raw ? text : truncate(text);
276
284
  }
277
285
  catch (e) {
278
286
  throw new Failure('tool_error', `read_file '${p}': ${e.message}`);
@@ -303,7 +311,16 @@ export class ToolHost {
303
311
  current = this.restrictions.checkUrl(new URL(res.headers.get('location'), current).href);
304
312
  continue;
305
313
  }
306
- const body = truncate(await res.text());
314
+ let text;
315
+ try {
316
+ text = await res.text();
317
+ }
318
+ catch (e) {
319
+ if (!(e instanceof Error))
320
+ throw e;
321
+ throw new Failure('tool_error', `web_fetch '${current.href}': ${e.message}`);
322
+ }
323
+ const body = this.raw ? text : truncate(text);
307
324
  if (!res.ok)
308
325
  throw new Failure('tool_error', `web_fetch '${current.href}': HTTP ${res.status}\n${body}`);
309
326
  return body;
@@ -417,12 +434,15 @@ export class ToolHost {
417
434
  return new Promise((resolve, reject) => {
418
435
  const child = spawn(command, opts.args ?? [], { cwd: this.cwd, shell: opts.shell, windowsHide: true });
419
436
  let out = '';
420
- const collect = (d) => {
421
- if (out.length < MAX_TEXT * 2)
422
- out += d.toString('utf8');
437
+ const collect = (text) => {
438
+ if (this.raw || out.length < MAX_TEXT * 2)
439
+ out += text;
423
440
  };
424
- child.stdout.on('data', collect);
425
- child.stderr.on('data', collect);
441
+ for (const stream of [child.stdout, child.stderr]) {
442
+ const decoder = new StringDecoder('utf8');
443
+ stream.on('data', (data) => collect(decoder.write(data)));
444
+ stream.on('end', () => collect(decoder.end()));
445
+ }
426
446
  const timer = setTimeout(() => child.kill(), SHELL_TIMEOUT_MS);
427
447
  child.on('error', (e) => {
428
448
  clearTimeout(timer);
@@ -430,7 +450,7 @@ export class ToolHost {
430
450
  });
431
451
  child.on('close', (code, signal) => {
432
452
  clearTimeout(timer);
433
- const text = truncate(out);
453
+ const text = this.raw ? out : truncate(out);
434
454
  if (code === 0)
435
455
  resolve(text);
436
456
  else
package/dist/values.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface UserFunction {
17
17
  export interface BuiltinFunction {
18
18
  __fn: 'builtin';
19
19
  name: string;
20
- call: (args: Value[]) => Value;
20
+ call: (args: Value[]) => Value | Promise<Value>;
21
21
  }
22
22
  export type FunctionValue = UserFunction | BuiltinFunction;
23
23
  export declare const runtimeError: (msg: string) => Failure;
package/docs/syntax.ebnf CHANGED
@@ -1,98 +1,82 @@
1
- (* Semantic Prompt Language (SPL) - syntax in EBNF.
2
- Notation: ISO-style. { x } = zero or more, [ x ] = optional, ( ) = grouping, | = alternative.
3
- Statements are separated by newlines or ';'. Newlines are insignificant inside ( ) and [ ].
4
- '#' starts a comment that runs to the end of the line. *)
1
+ (* SPL 0.3. { x } means repetition; [ x ] means optional.
2
+ Statements end at newline, ';', '}' or EOF. Newlines inside () and [] are ignored.
3
+ '#' starts a comment. Opening block braces must be on the keyword's line. *)
5
4
 
6
- program = { directive , NL } , { statement , ( NL | ";" ) } ;
7
- directive = "@" , "{" , directive_item , { "," , directive_item } , "}" ;
8
- directive_item = "model" , "=" , plain_string ; (* only allowed before the first statement *)
5
+ program = { directive , separator } , { statement , separator } ;
6
+ separator = NL | ";" | end_of_block | EOF ;
7
+ directive = "@" , "{" , "model" , "=" , plain_string , [ "," ] , "}" ;
8
+ block = "{" , { statement , separator } , "}" ;
9
9
 
10
- block = "{" , { statement , ( NL | ";" ) } , "}" ; (* '{' must be on the same line as its keyword; as a statement it is a nested scope *)
11
-
12
- statement = let_stmt | if_stmt | for_stmt | while_stmt | def_stmt | block
13
- | "return" , [ expr ] (* anywhere: ends the function, or the script when at the top level *)
14
- | "break" | "continue" | "pass" (* break/continue only inside loops *)
15
- | try_stmt | assert_stmt | fail_stmt | success_stmt
16
- | constraint_stmt | restrict_stmt | expr ;
17
-
18
- (* Every modification of a variable starts with 'let'; a bare x = 1 is a syntax error.
19
- Scoping: 'let NAME = ...' binds in the current scope. A bare block { ... }, a function body and its parameters
20
- open a new scope, so 'let' there shadows an outer variable. if/elif/else, for, while, try/except, enforce, forbid
21
- and restrict bodies do NOT open a scope: 'let' inside them updates the enclosing variable, which is how results
22
- of LLM commands leave an enforce/forbid/restrict block. Reading a name searches the scopes outwards.
23
- Compound forms (let x += 1) read the visible value and bind the result in the current scope. *)
24
- let_stmt = "let" , target , ( "=" | "+=" | "-=" | "*=" | "/=" | "//=" | "%=" ) , expr ;
25
- target = NAME | postfix "." NAME | postfix "[" expr "]" ;
26
-
27
- if_stmt = "if" , expr , block , { NL* "elif" , expr , block } , [ NL* "else" , block ] ;
10
+ statement = declaration | assignment | if_stmt | for_stmt | while_stmt
11
+ | def_stmt | try_stmt | guidance_stmt | restrict_stmt | block
12
+ | "return" , [ expr ] | "success" , [ expr ]
13
+ | "assert" , expr , [ expr ] | "fail" , expr
14
+ | "break" | "continue" | "pass" | expr ;
15
+ declaration = "let" , NAME , "=" , expr ;
16
+ assignment = target , ( "=" | "+=" | "-=" | "*=" | "/=" | "//=" | "%=" ) , expr ;
17
+ target = NAME | postfix , "." , NAME | postfix , "[" , expr , "]" ;
18
+ if_stmt = "if" , expr , block , { NL* , "elif" , expr , block } , [ NL* , "else" , block ] ;
28
19
  for_stmt = "for" , NAME , { "," , NAME } , "in" , expr , block ;
29
20
  while_stmt = "while" , expr , block ;
30
- def_stmt = "def" , NAME , "(" , [ NAME , { "," , NAME } ] , ")" , block ;
21
+ def_stmt = "def" , NAME , "(" , [ NAME , { "," , NAME } , [ "," ] ] , ")" , block ;
31
22
  try_stmt = "try" , block , NL* , "except" , NAME , block ;
23
+ guidance_stmt = ( "enforce" | "forbid" ) , expr , block ;
24
+ restrict_stmt = "restrict" , restrict_clause , { "," , restrict_clause } , block ;
25
+ restrict_clause = [ "fs" | "net" | "exec" | "mcp" ] , expr ;
32
26
 
33
- assert_stmt = "assert" , expr , [ expr ] ; (* optional message *)
34
- fail_stmt = "fail" , expr ;
35
- success_stmt = "success" , [ expr ] ;
36
-
37
- constraint_stmt = ( "enforce" | "forbid" ) , expr , block ;
38
- restrict_stmt = "restrict" , restrict_clause , { "," , restrict_clause } , block ;
39
- restrict_clause = [ "fs" | "net" | "exec" | "mcp" ] , expr ; (* default kind: fs; clauses intersect *)
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.
31
+ return exits the innermost function, or the script at top level.
32
+ success exits the script, including when used in a function. *)
40
33
 
41
- (* Expressions, lowest to highest precedence *)
42
34
  expr = or_expr , [ "if" , or_expr , "else" , expr ] ;
43
35
  or_expr = and_expr , { "or" , and_expr } ;
44
36
  and_expr = not_expr , { "and" , not_expr } ;
45
37
  not_expr = "not" , not_expr | comparison ;
46
- comparison = sum , { ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not" "in" ) , sum } ;
38
+ comparison = sum , { ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not" , "in" ) , sum } ;
47
39
  sum = term , { ( "+" | "-" ) , term } ;
48
40
  term = unary , { ( "*" | "/" | "//" | "%" ) , unary } ;
49
- unary = ( "-" | "+" ) , unary | llm_call | power ;
41
+ unary = ( "-" | "+" ) , unary | "?" , [ modifiers ] , postfix | power ;
50
42
  power = postfix , [ "**" , unary ] ;
51
43
  postfix = primary , { call | index | slice | member } ;
52
- call = "(" , [ expr , { "," , expr } ] , ")" ;
44
+ call = "(" , [ expr , { "," , expr } , [ "," ] ] , ")" ;
53
45
  index = "[" , expr , "]" ;
54
46
  slice = "[" , [ expr ] , ":" , [ expr ] , "]" ;
55
47
  member = "." , NAME ;
56
-
57
48
  primary = NUMBER | string | "True" | "False" | "None" | NAME
58
49
  | "(" , expr , ")" | list | dict
59
- | think_call | tool_call ;
50
+ | ( "think" | "agent" ) , [ modifiers ] , postfix ;
60
51
  list = "[" , [ expr , { "," , expr } , [ "," ] ] , "]" ;
61
52
  dict = "{" , [ expr , ":" , expr , { "," , expr , ":" , expr } , [ "," ] ] , "}" ;
53
+ modifiers = "{" , [ modifier , { "," , modifier } , [ "," ] ] , "}" ;
54
+ modifier = ( "len" | "fmt" | "reasoning" | "show" | "schema" | "repairs" | "model" ) , "=" , expr ;
62
55
 
63
- (* LLM commands. '?' is a prefix operator (see 'unary'). The prompt operand is a postfix expression,
64
- so ?'a' + b is (?'a') + b and -?'n' is -(?'n').
65
- Every modifier and parameter is optional: ?'q', think 'q' and read_file 'what to do' run with defaults. *)
66
- llm_call = "?" , [ ask_mods ] , postfix ;
67
- think_call = "think" , [ think_mods ] , postfix ;
68
- tool_call = tool_name , [ tool_params ] , postfix ; (* the last operand holds the instructions *)
69
-
70
- ask_mods = "{" , ask_mod , { "," , ask_mod } , "}" ;
71
- ask_mod = "len" , "=" , ( "'word'" | "'sentence'" | "'paragraph'" | "'long'" )
72
- | "fmt" , "=" , ( "'text'" | "'object'" ) ;
73
- think_mods = "{" , think_mod , { "," , think_mod } , "}" ;
74
- think_mod = "level" , "=" , ( "'low'" | "'medium'" | "'high'" )
75
- | "show" , "=" , ( "True" | "False" ) ; (* each modifier at most once *)
76
-
77
- tool_name = "read_file" | "write_file" | "web_fetch" | "web_search" | "shell" ;
78
- tool_params = "{" , [ tool_param , { "," , tool_param } ] , "}" ;
79
- (* When every parameter the tool needs is given, the runtime runs the tool and passes its output to the instructions.
80
- When one is missing, the LLM calls the tool itself; the parameters that were given stay fixed. *)
81
- tool_param = "path" , "=" , expr (* read_file, write_file *)
82
- | "content" , "=" , expr (* write_file; the LLM writes the file when omitted *)
83
- | "url" , "=" , expr (* web_fetch *)
84
- | "query" , "=" , expr (* web_search *)
85
- | "command" , "=" , expr ; (* shell *)
56
+ (* Unknown/duplicate modifier names are syntax errors. Values are expressions, validated at runtime:
57
+ len: word/sentence/paragraph/long (guidance only)
58
+ fmt: text/json/object; schema implies json unless fmt is explicit
59
+ reasoning: low/medium/high; think and agent default to medium
60
+ show: True/False; schema: synchronous strict JSON Schema draft-07 object or boolean
61
+ repairs: integer 0..5 (additional generation attempts, default 0); agents require 0
62
+ model: non-empty string; CLI override > per-call model > header > SDK default
63
+ Generation (? and think) has no tools. agent explicitly enables restricted runtime/MCP tools.
64
+ Raw tools are ordinary positional functions: read_file(path), write_file(path, content),
65
+ web_fetch(url), web_search(query), shell(command). They never invoke a model.
66
+ enforce/forbid are scoped prompt guidance.
67
+ schema/assert validate data; restrict checks actions. *)
86
68
 
87
- (* Lexical rules *)
88
- string = [ "r" ] , ( "'" | '"' | "'''" | '"""' ) , { char | escape | interpolation } , closing_quote ;
89
- plain_string = string without interpolation ;
69
+ string = [ "r" ] , quote , { char | escape | interpolation } , closing_quote ;
70
+ quote = "'" | '"' | "'''" | '"""' ;
71
+ plain_string = string_without_interpolation ;
90
72
  escape = "\" , ( "n" | "t" | "r" | "0" | "\" | "'" | '"' | "{" | "}" ) ;
91
- interpolation = "{" , expr , "}" ; (* "{{" and "}}" are literal braces; raw strings (r'...') have no escapes or interpolation *)
73
+ interpolation = "{" , expr , "}" ;
92
74
  NUMBER = digit+ , [ "." , digit+ ] , [ ( "e" | "E" ) , [ "+" | "-" ] , digit+ ] ;
93
- NAME = ( letter | "_" ) , { letter | digit | "_" } ; (* not a reserved word *)
75
+ NAME = ( letter | "_" ) , { letter | digit | "_" } ;
94
76
  NL = newline ;
95
77
 
96
- (* Reserved words:
97
- let if elif else for in while def return try except assert fail success enforce forbid restrict
98
- think break continue pass and or not True False None read_file write_file web_fetch web_search shell *)
78
+ (* "{{"/"}}" are literal braces; raw strings have no interpolation or escapes.
79
+ Reserved words: let if elif else for in while def return try except assert fail success
80
+ enforce forbid restrict think agent break continue pass and or not True False None.
81
+ Removed syntax: think{level=...}, tool{param=...} 'instructions',
82
+ let x += ... and let d.key = ... . *)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spl-lang",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -29,7 +29,8 @@
29
29
  ],
30
30
  "license": "ISC",
31
31
  "dependencies": {
32
- "@github/copilot-sdk": "^1.0.22"
32
+ "@github/copilot-sdk": "^1.0.22",
33
+ "ajv": "^8.17.1"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^26.6.5",