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/README.md +73 -170
- package/dist/ast.d.ts +6 -15
- package/dist/cli.js +46 -12
- package/dist/copilot.d.ts +1 -0
- package/dist/copilot.js +10 -6
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/interpreter.js +99 -69
- package/dist/llm.d.ts +4 -9
- package/dist/llm.js +17 -26
- package/dist/parser.d.ts +2 -12
- package/dist/parser.js +21 -97
- package/dist/recording.d.ts +17 -0
- package/dist/recording.js +93 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +40 -0
- package/dist/tools.d.ts +3 -1
- package/dist/tools.js +29 -9
- package/dist/values.d.ts +1 -1
- package/docs/syntax.ebnf +54 -70
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
421
|
-
if (out.length < MAX_TEXT * 2)
|
|
422
|
-
out +=
|
|
437
|
+
const collect = (text) => {
|
|
438
|
+
if (this.raw || out.length < MAX_TEXT * 2)
|
|
439
|
+
out += text;
|
|
423
440
|
};
|
|
424
|
-
child.stdout
|
|
425
|
-
|
|
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
|
-
(*
|
|
2
|
-
|
|
3
|
-
|
|
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 ,
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
| "
|
|
14
|
-
| "break" | "continue" | "pass"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 |
|
|
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
|
-
|
|
|
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
|
-
(*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
plain_string =
|
|
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 , "}" ;
|
|
73
|
+
interpolation = "{" , expr , "}" ;
|
|
92
74
|
NUMBER = digit+ , [ "." , digit+ ] , [ ( "e" | "E" ) , [ "+" | "-" ] , digit+ ] ;
|
|
93
|
-
NAME = ( letter | "_" ) , { letter | digit | "_" } ;
|
|
75
|
+
NAME = ( letter | "_" ) , { letter | digit | "_" } ;
|
|
94
76
|
NL = newline ;
|
|
95
77
|
|
|
96
|
-
(*
|
|
97
|
-
let if elif else for in while def return try except assert fail success
|
|
98
|
-
think break continue pass and or not True False None
|
|
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.
|
|
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",
|