spl-lang 0.5.0 → 1.0.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,4 +1,26 @@
1
+ import { type StatusCode } from './errors.js';
1
2
  import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
3
+ interface TraceRequest {
4
+ kind: LlmRequest['kind'];
5
+ prompt: string;
6
+ model: string | null;
7
+ reasoningEffort: LlmRequest['reasoningEffort'] | null;
8
+ showReasoning: boolean;
9
+ naturalLanguage?: boolean;
10
+ }
11
+ type Outcome = {
12
+ response: LlmResponse;
13
+ } | {
14
+ error: {
15
+ status_code: StatusCode;
16
+ reason: string;
17
+ };
18
+ };
19
+ export declare function traceRequest(req: LlmRequest | TraceRequest): TraceRequest;
20
+ export declare function isResponse(value: unknown): value is LlmResponse;
21
+ export declare function isTraceError(value: unknown): value is Extract<Outcome, {
22
+ error: unknown;
23
+ }>['error'];
2
24
  export declare class RecordingBackend implements LlmBackend {
3
25
  private readonly backend;
4
26
  private readonly file;
@@ -7,11 +29,20 @@ export declare class RecordingBackend implements LlmBackend {
7
29
  call(req: LlmRequest): Promise<LlmResponse>;
8
30
  close(): Promise<void>;
9
31
  }
32
+ export declare class TraceBackend implements LlmBackend {
33
+ private readonly backend;
34
+ private readonly file;
35
+ readonly mode = "trace";
36
+ constructor(backend: LlmBackend, file: string);
37
+ call(req: LlmRequest): Promise<LlmResponse>;
38
+ close(): Promise<void>;
39
+ }
10
40
  export declare class ReplayBackend implements LlmBackend {
11
- readonly mode = "replay";
41
+ readonly mode: 'replay' | 'workflow-replay';
12
42
  private readonly entries;
13
43
  private position;
14
44
  constructor(file: string);
15
45
  call(req: LlmRequest): Promise<LlmResponse>;
16
46
  close(): Promise<void>;
17
47
  }
48
+ export {};
package/dist/recording.js CHANGED
@@ -1,14 +1,39 @@
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
+ export 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
+ ...(req.naturalLanguage ? { naturalLanguage: true } : {}),
9
+ };
10
+ }
11
+ function requestKey(req) {
12
+ return JSON.stringify(traceRequest(req));
8
13
  }
9
14
  function isRecord(value) {
10
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
16
  }
17
+ export function isResponse(value) {
18
+ return isRecord(value) && typeof value.text === 'string' && isRecord(value.effective)
19
+ && (value.reasoning === undefined || typeof value.reasoning === 'string')
20
+ && (value.spl === undefined || typeof value.spl === 'string');
21
+ }
22
+ function isTraceRequest(value) {
23
+ return isRecord(value) && (value.kind === 'ask' || value.kind === 'think' || value.kind === 'agent')
24
+ && typeof value.prompt === 'string' && (value.model === null || typeof value.model === 'string')
25
+ && (value.reasoningEffort === null || value.reasoningEffort === 'low' || value.reasoningEffort === 'medium' || value.reasoningEffort === 'high')
26
+ && typeof value.showReasoning === 'boolean'
27
+ && (value.naturalLanguage === undefined || typeof value.naturalLanguage === 'boolean');
28
+ }
29
+ export function isTraceError(value) {
30
+ const codes = [
31
+ 'assertion_failed', 'explicit_failure', 'invalid_format', 'validation_failed',
32
+ 'restriction_violation', 'llm_error', 'tool_error', 'runtime_error',
33
+ ];
34
+ return isRecord(value) && typeof value.reason === 'string'
35
+ && codes.some((code) => code === value.status_code);
36
+ }
12
37
  function parseLine(line, number) {
13
38
  try {
14
39
  return JSON.parse(line);
@@ -29,7 +54,7 @@ export class RecordingBackend {
29
54
  fs.writeFileSync(file, JSON.stringify({ format: 'spl-generation', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
30
55
  }
31
56
  async call(req) {
32
- if (req.kind === 'agent')
57
+ if (req.kind === 'agent' && !req.naturalLanguage)
33
58
  throw new LlmError('agent actions cannot be recorded; record generation-only workflows');
34
59
  const request = requestKey(req);
35
60
  const response = await this.backend.call(req);
@@ -47,35 +72,81 @@ export class RecordingBackend {
47
72
  return this.backend.close();
48
73
  }
49
74
  }
75
+ export class TraceBackend {
76
+ backend;
77
+ file;
78
+ mode = 'trace';
79
+ constructor(backend, file) {
80
+ this.backend = backend;
81
+ this.file = file;
82
+ fs.writeFileSync(file, JSON.stringify({ format: 'spl-trace', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
83
+ }
84
+ async call(req) {
85
+ const request = traceRequest(req);
86
+ let outcome;
87
+ let failure;
88
+ try {
89
+ outcome = { response: await this.backend.call(req) };
90
+ }
91
+ catch (e) {
92
+ failure = e instanceof Error ? e : new LlmError(String(e));
93
+ outcome = { error: {
94
+ status_code: e instanceof Failure ? e.statusCode : 'llm_error',
95
+ reason: e instanceof Failure ? e.reason : e instanceof Error ? e.message : String(e),
96
+ } };
97
+ }
98
+ try {
99
+ fs.appendFileSync(this.file, JSON.stringify({ request, ...outcome }) + '\n');
100
+ }
101
+ catch (e) {
102
+ if (!(e instanceof Error))
103
+ throw e;
104
+ throw new LlmError(`could not save trace: ${e.message}`);
105
+ }
106
+ if ('error' in outcome)
107
+ throw failure;
108
+ return outcome.response;
109
+ }
110
+ close() {
111
+ return this.backend.close();
112
+ }
113
+ }
50
114
  export class ReplayBackend {
51
- mode = 'replay';
115
+ mode;
52
116
  entries;
53
117
  position = 0;
54
118
  constructor(file) {
55
119
  const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
56
120
  const header = parseLine(lines.shift() ?? '', 1);
57
- if (!isRecord(header) || header.format !== 'spl-generation' || header.version !== 1) {
121
+ if (!isRecord(header) || (header.format !== 'spl-generation' && header.format !== 'spl-trace') || header.version !== 1) {
58
122
  throw new LlmError('unsupported recording format/version');
59
123
  }
124
+ this.mode = header.format === 'spl-trace' ? 'workflow-replay' : 'replay';
60
125
  this.entries = lines.map((line, i) => {
61
126
  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')) {
127
+ if (!isRecord(entry))
128
+ throw new LlmError(`invalid recording entry at line ${i + 2}`);
129
+ let request;
130
+ if (this.mode === 'workflow-replay' && isTraceRequest(entry.request)) {
131
+ request = JSON.stringify(traceRequest(entry.request));
132
+ }
133
+ else if (this.mode === 'replay' && typeof entry.request === 'string') {
134
+ request = entry.request;
135
+ }
136
+ else {
65
137
  throw new LlmError(`invalid recording entry at line ${i + 2}`);
66
138
  }
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
- };
139
+ if (isResponse(entry.response) && entry.error === undefined) {
140
+ return { request, outcome: { response: entry.response } };
141
+ }
142
+ if (this.mode === 'workflow-replay' && entry.response === undefined && isTraceError(entry.error)) {
143
+ return { request, outcome: { error: entry.error } };
144
+ }
145
+ throw new LlmError(`invalid recording entry at line ${i + 2}`);
75
146
  });
76
147
  }
77
148
  async call(req) {
78
- if (req.kind === 'agent')
149
+ if (req.kind === 'agent' && !req.naturalLanguage && this.mode === 'replay')
79
150
  throw new LlmError('agent actions cannot be replayed');
80
151
  const entry = this.entries[this.position];
81
152
  if (!entry)
@@ -83,7 +154,11 @@ export class ReplayBackend {
83
154
  if (entry.request !== requestKey(req))
84
155
  throw new LlmError(`replay request mismatch at call ${this.position + 1}`);
85
156
  this.position++;
86
- return structuredClone(entry.response);
157
+ if ('error' in entry.outcome) {
158
+ const { status_code, reason } = entry.outcome.error;
159
+ throw new Failure(status_code, reason);
160
+ }
161
+ return structuredClone(entry.outcome.response);
87
162
  }
88
163
  async close() {
89
164
  if (this.position !== this.entries.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spl-lang",
3
- "version": "0.5.0",
3
+ "version": "1.0.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",
@@ -30,7 +30,9 @@
30
30
  "license": "ISC",
31
31
  "dependencies": {
32
32
  "@github/copilot-sdk": "^1.0.22",
33
- "ajv": "^8.17.1"
33
+ "ajv": "^8.17.1",
34
+ "marked": "^18.1.1",
35
+ "yaml": "^2.8.1"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@types/node": "^26.6.5",
package/docs/syntax.ebnf DELETED
@@ -1,97 +0,0 @@
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. *)
4
-
5
- program = { directive , separator } , { statement , separator } ;
6
- separator = NL | ";" | end_of_block | EOF ;
7
- directive = "@" , "{" , "model" , "=" , plain_string , [ "," ] , "}" ;
8
- block = "{" , { statement , separator } , "}" ;
9
-
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 ] ;
19
- for_stmt = "for" , NAME , { "," , NAME } , "in" , expr , block ;
20
- while_stmt = "while" , expr , block ;
21
- def_stmt = "def" , NAME , "(" , [ NAME , { "," , NAME } , [ "," ] ] , ")" , block ;
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 ;
26
-
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.
32
- return exits the innermost function, or the script at top level.
33
- success exits the script, including when used in a function. *)
34
-
35
- expr = or_expr , [ "if" , or_expr , "else" , expr ] ;
36
- or_expr = and_expr , { "or" , and_expr } ;
37
- and_expr = not_expr , { "and" , not_expr } ;
38
- not_expr = "not" , not_expr | comparison ;
39
- comparison = sum , { ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not" , "in" ) , sum } ;
40
- sum = term , { ( "+" | "-" ) , term } ;
41
- term = unary , { ( "*" | "/" | "//" | "%" ) , unary } ;
42
- unary = ( "-" | "+" ) , unary | "?" , [ modifiers ] , postfix | power ;
43
- power = postfix , [ "**" , unary ] ;
44
- postfix = primary , { call | index | slice | member } ;
45
- call = "(" , [ expr , { "," , expr } , [ "," ] ] , ")" ;
46
- index = "[" , expr , "]" ;
47
- slice = "[" , [ expr ] , ":" , [ expr ] , "]" ;
48
- member = "." , NAME ;
49
- primary = NUMBER | string | "True" | "False" | "None" | NAME | special_var
50
- | "(" , expr , ")" | list | dict | block | if_stmt | log_expr
51
- | ( "think" | "agent" ) , [ modifiers ] , postfix ;
52
- special_var = "$env" | "$args" ;
53
- list = "[" , [ expr , { "," , expr } , [ "," ] ] , "]" ;
54
- dict = "{" , [ expr , ":" , expr , { "," , expr , ":" , expr } , [ "," ] ] , "}" ;
55
- modifiers = "{" , [ modifier , { "," , modifier } , [ "," ] ] , "}" ;
56
- modifier = ( "len" | "fmt" | "reasoning" | "show" | "schema" | "repairs" | "model" ) , "=" , expr ;
57
- log_expr = "log" , "{" , [ "level" , "=" , log_level , [ "," ] ] , "}" , call ;
58
- log_level = "error" | "info" | "debug" | expr ;
59
-
60
- (* Unknown/duplicate modifier names are syntax errors. Values are expressions, validated at runtime:
61
- len: word/sentence/paragraph/long (guidance only)
62
- fmt: integer/float/array/object/text/boolean/json; schema implies json unless fmt is explicit
63
- Non-text formats require strict JSON of the selected type; float accepts finite numbers,
64
- object excludes arrays, and json accepts any JSON value.
65
- reasoning: low/medium/high; think and agent default to medium
66
- show: True/False; schema: synchronous strict JSON Schema draft-07 object or boolean
67
- repairs: integer 0..5 (additional generation attempts, default 0); agents require 0
68
- model: non-empty string; CLI override > per-call model > header > SDK default
69
- Generation (? and think) has no tools. agent explicitly enables restricted runtime/MCP tools.
70
- Raw tools are ordinary positional functions: read_file(path), write_file(path, content),
71
- web_fetch(url), web_search(query), shell(command). They never invoke a model.
72
- log{level=...}(args) selects error/info/debug severity; plain log(args) defaults to info.
73
- Level values are bare names, strings or expressions, validated before evaluating arguments.
74
- Debug messages are emitted only at debug log level; silent suppresses all logs.
75
- enforce/forbid are scoped prompt guidance.
76
- schema/assert validate data; restrict checks actions. *)
77
-
78
- string = [ "r" ] , quote , { char | escape | interpolation } , closing_quote ;
79
- quote = "'" | '"' | "'''" | '"""' ;
80
- plain_string = string_without_interpolation ;
81
- escape = "\" , ( "n" | "t" | "r" | "0" | "\" | "'" | '"' | "{" | "}" ) ;
82
- interpolation = "{" , expr , "}" ;
83
- NUMBER = digit+ , [ "." , digit+ ] , [ ( "e" | "E" ) , [ "+" | "-" ] , digit+ ] ;
84
- NAME = ( letter | "_" ) , { letter | digit | "_" } ;
85
- NL = newline ;
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.
94
- Reserved words: let if elif else for in while def return try except assert fail success
95
- enforce forbid restrict think agent break continue pass and or not True False None.
96
- Removed syntax: think{level=...}, tool{param=...} 'instructions',
97
- let x += ... and let d.key = ... . *)