spl-lang 0.1.1 → 0.3.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/dist/parser.js CHANGED
@@ -1,33 +1,11 @@
1
1
  import { SplSyntaxError } from './errors.js';
2
2
  import { tokenize } from './lexer.js';
3
- export const TOOL_NAMES = ['read_file', 'write_file', 'web_fetch', 'web_search', 'shell'];
4
3
  const KEYWORDS = new Set([
5
4
  'let', 'if', 'elif', 'else', 'for', 'in', 'while', 'def', 'return', 'try', 'except', 'assert', 'fail',
6
- 'success', 'enforce', 'forbid', 'restrict', 'think', 'break', 'continue', 'pass', 'and', 'or', 'not',
7
- 'True', 'False', 'None', ...TOOL_NAMES,
5
+ 'success', 'instruct', 'avoid', 'restrict', 'think', 'agent', 'break', 'continue', 'pass', 'and', 'or', 'not',
6
+ 'True', 'False', 'None',
8
7
  ]);
9
- /** Fixed modifier schema per command; anything else is a syntax error. */
10
- export const MODIFIER_SCHEMA = {
11
- '?': {
12
- len: ['word', 'sentence', 'paragraph', 'long'],
13
- fmt: ['text', 'object'],
14
- },
15
- think: {
16
- level: ['low', 'medium', 'high'],
17
- show: [true, false],
18
- },
19
- };
20
- /**
21
- * Tool parameter schema: the allowed names, and the `required` ones that let the runtime run the tool itself.
22
- * Every parameter is optional in a script; the LLM supplies the missing required ones when it calls the tool.
23
- */
24
- export const TOOL_SCHEMA = {
25
- read_file: { params: ['path'], required: ['path'] },
26
- write_file: { params: ['path', 'content'], required: ['path', 'content'] },
27
- web_fetch: { params: ['url'], required: ['url'] },
28
- web_search: { params: ['query'], required: ['query'] },
29
- shell: { params: ['command'], required: ['command'] },
30
- };
8
+ export const MODIFIER_NAMES = ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
31
9
  const DIRECTIVE_SCHEMA = { model: 'string' };
32
10
  const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '//=', '%=']);
33
11
  export function parse(source) {
@@ -264,14 +242,17 @@ class Parser {
264
242
  this.next();
265
243
  return { t: 'success', line, value: this.atStatementEnd() ? undefined : this.parseExpr() };
266
244
  }
267
- case 'enforce':
268
- case 'forbid': {
245
+ case 'instruct':
246
+ case 'avoid': {
269
247
  const kind = t.value;
270
248
  this.next();
271
249
  const text = this.parseExpr();
272
250
  return { t: 'constraint', line, kind, text, body: this.parseBlock() };
273
251
  }
274
252
  case 'restrict': return this.parseRestrict();
253
+ case 'enforce':
254
+ case 'forbid':
255
+ this.fail(`'${t.value}' was removed; use '${t.value === 'enforce' ? 'instruct' : 'avoid'}' for prompt guidance, or assert/schema for validation`);
275
256
  default:
276
257
  break;
277
258
  }
@@ -283,18 +264,9 @@ class Parser {
283
264
  parseLet() {
284
265
  const line = this.tok.line;
285
266
  this.next();
286
- const startTok = this.tok;
287
- if (startTok.type === 'name' && KEYWORDS.has(startTok.value)) {
288
- this.fail(`'${startTok.value}' is a reserved word and cannot be used as a variable name`);
289
- }
290
- if (startTok.type !== 'name')
291
- this.fail(`expected a variable name but found ${this.describe(startTok)}`);
292
- const target = this.toTarget(this.parsePostfix(), startTok);
293
- const opTok = this.tok;
294
- if (opTok.type !== 'op' || !ASSIGN_OPS.has(opTok.value))
295
- this.fail(`expected '=' after the target of 'let' but found ${this.describe(opTok)}`);
296
- this.next();
297
- return { t: 'let', line, target, op: opTok.value, value: this.parseExpr() };
267
+ const name = this.expectIdent('a variable name');
268
+ this.expectOp('=');
269
+ return { t: 'let', line, name, value: this.parseExpr() };
298
270
  }
299
271
  parseIf() {
300
272
  const line = this.tok.line;
@@ -355,21 +327,31 @@ class Parser {
355
327
  parseRestrict() {
356
328
  const line = this.tok.line;
357
329
  this.next();
358
- let kind = 'fs';
359
- const t = this.tok;
360
- if (t.type === 'name' && (t.value === 'fs' || t.value === 'net' || t.value === 'exec')) {
361
- kind = t.value;
330
+ const clauses = [];
331
+ for (;;) {
332
+ let kind = 'fs';
333
+ const t = this.tok;
334
+ if (t.type === 'name' && (t.value === 'fs' || t.value === 'net' || t.value === 'exec' || t.value === 'mcp')) {
335
+ kind = t.value;
336
+ this.next();
337
+ }
338
+ clauses.push({ kind, arg: this.parseExpr() });
339
+ if (!this.isOp(','))
340
+ break;
362
341
  this.next();
342
+ this.skipNl();
363
343
  }
364
- const arg = this.parseExpr();
365
- return { t: 'restrict', line, kind, arg, body: this.parseBlock() };
344
+ return { t: 'restrict', line, clauses, body: this.parseBlock() };
366
345
  }
367
346
  parseSimpleStatement() {
368
- const line = this.tok.line;
347
+ const start = this.tok;
348
+ const line = start.line;
369
349
  const e = this.parseExpr();
370
350
  const t = this.tok;
371
351
  if (t.type === 'op' && ASSIGN_OPS.has(t.value)) {
372
- this.fail(`variables are modified with 'let' (write 'let <target> ${t.value} ...')`, t);
352
+ const target = this.toTarget(e, start);
353
+ this.next();
354
+ return { t: 'assign', line, target, op: t.value, value: this.parseExpr() };
373
355
  }
374
356
  return { t: 'expr', line, e };
375
357
  }
@@ -597,17 +579,12 @@ class Parser {
597
579
  case 'None':
598
580
  this.next();
599
581
  return { t: 'none' };
600
- case 'think': {
582
+ case 'think':
583
+ case 'agent': {
601
584
  this.next();
602
- const mods = this.isOp('{') ? this.parseMods('think') : {};
603
- return { t: 'think', mods, prompt: this.parsePrompt('think') };
585
+ const mods = this.isOp('{') ? this.parseMods(t.value) : {};
586
+ return { t: t.value, mods, prompt: this.parsePrompt(t.value) };
604
587
  }
605
- case 'read_file':
606
- case 'write_file':
607
- case 'web_fetch':
608
- case 'web_search':
609
- case 'shell':
610
- return this.parseToolCall(t.value);
611
588
  default:
612
589
  if (KEYWORDS.has(t.value))
613
590
  this.fail(`unexpected keyword '${t.value}'`);
@@ -621,7 +598,6 @@ class Parser {
621
598
  return this.parsePostfix();
622
599
  }
623
600
  parseMods(cmd) {
624
- const schema = MODIFIER_SCHEMA[cmd];
625
601
  this.expectOp('{');
626
602
  this.skipNl();
627
603
  const out = {};
@@ -631,25 +607,13 @@ class Parser {
631
607
  this.fail('expected a modifier name');
632
608
  const name = nameTok.value;
633
609
  this.next();
634
- if (!(name in schema)) {
635
- this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${Object.keys(schema).join(', ')})`, nameTok);
610
+ if (!MODIFIER_NAMES.some((allowed) => allowed === name)) {
611
+ this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${MODIFIER_NAMES.join(', ')})`, nameTok);
636
612
  }
637
613
  if (name in out)
638
614
  this.fail(`duplicate modifier '${name}'`, nameTok);
639
615
  this.expectOp('=');
640
- const valTok = this.tok;
641
- let value;
642
- if (valTok.type === 'str')
643
- value = this.parseLiteralString(`modifier '${name}'`);
644
- else if (this.isName('True') || this.isName('False'))
645
- value = this.next().value === 'True';
646
- else
647
- return this.fail(`modifier '${name}' requires a quoted string or True/False`);
648
- if (!schema[name].includes(value)) {
649
- const allowed = schema[name].map((v) => (typeof v === 'string' ? `'${v}'` : v ? 'True' : 'False'));
650
- this.fail(`invalid value ${typeof value === 'string' ? `'${value}'` : value ? 'True' : 'False'} for modifier '${name}' (allowed: ${allowed.join(', ')})`, valTok);
651
- }
652
- out[name] = value;
616
+ out[name] = this.parseExpr();
653
617
  this.skipNl();
654
618
  if (this.isOp(',')) {
655
619
  this.next();
@@ -662,39 +626,6 @@ class Parser {
662
626
  this.next();
663
627
  return out;
664
628
  }
665
- parseToolCall(tool) {
666
- this.next();
667
- const schema = TOOL_SCHEMA[tool];
668
- const params = {};
669
- if (this.isOp('{')) {
670
- this.next();
671
- this.skipNl();
672
- while (!this.isOp('}')) {
673
- const nameTok = this.tok;
674
- if (nameTok.type !== 'name')
675
- this.fail('expected a parameter name');
676
- const name = nameTok.value;
677
- this.next();
678
- if (!schema.params.includes(name)) {
679
- this.fail(`unknown parameter '${name}' for '${tool}' (allowed: ${schema.params.join(', ')})`, nameTok);
680
- }
681
- if (name in params)
682
- this.fail(`duplicate parameter '${name}'`, nameTok);
683
- this.expectOp('=');
684
- params[name] = this.parseExpr();
685
- this.skipNl();
686
- if (this.isOp(',')) {
687
- this.next();
688
- this.skipNl();
689
- }
690
- else if (!this.isOp('}')) {
691
- this.fail("expected ',' or '}' in tool parameters");
692
- }
693
- }
694
- this.next();
695
- }
696
- return { t: 'tool', tool, params, instructions: this.parsePrompt(tool) };
697
- }
698
629
  convertParts(parts) {
699
630
  return parts.map((p) => {
700
631
  if ('text' in p)
@@ -0,0 +1,17 @@
1
+ import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
2
+ export declare class RecordingBackend implements LlmBackend {
3
+ private readonly backend;
4
+ private readonly file;
5
+ readonly mode = "record";
6
+ constructor(backend: LlmBackend, file: string);
7
+ call(req: LlmRequest): Promise<LlmResponse>;
8
+ close(): Promise<void>;
9
+ }
10
+ export declare class ReplayBackend implements LlmBackend {
11
+ readonly mode = "replay";
12
+ private readonly entries;
13
+ private position;
14
+ constructor(file: string);
15
+ call(req: LlmRequest): Promise<LlmResponse>;
16
+ close(): Promise<void>;
17
+ }
@@ -0,0 +1,93 @@
1
+ import * as fs from 'node:fs';
2
+ import { LlmError } from './llm.js';
3
+ function requestKey(req) {
4
+ return JSON.stringify({
5
+ kind: req.kind, prompt: req.prompt, model: req.model ?? null,
6
+ reasoningEffort: req.reasoningEffort ?? null, showReasoning: req.showReasoning,
7
+ });
8
+ }
9
+ function isRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function parseLine(line, number) {
13
+ try {
14
+ return JSON.parse(line);
15
+ }
16
+ catch (e) {
17
+ if (!(e instanceof SyntaxError))
18
+ throw e;
19
+ throw new LlmError(`invalid recording JSON at line ${number}: ${e.message}`);
20
+ }
21
+ }
22
+ export class RecordingBackend {
23
+ backend;
24
+ file;
25
+ mode = 'record';
26
+ constructor(backend, file) {
27
+ this.backend = backend;
28
+ this.file = file;
29
+ fs.writeFileSync(file, JSON.stringify({ format: 'spl-generation', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
30
+ }
31
+ async call(req) {
32
+ if (req.kind === 'agent')
33
+ throw new LlmError('agent actions cannot be recorded; record generation-only workflows');
34
+ const request = requestKey(req);
35
+ const response = await this.backend.call(req);
36
+ try {
37
+ fs.appendFileSync(this.file, JSON.stringify({ request, response }) + '\n');
38
+ }
39
+ catch (e) {
40
+ if (!(e instanceof Error))
41
+ throw e;
42
+ throw new LlmError(`could not save recording: ${e.message}`);
43
+ }
44
+ return response;
45
+ }
46
+ close() {
47
+ return this.backend.close();
48
+ }
49
+ }
50
+ export class ReplayBackend {
51
+ mode = 'replay';
52
+ entries;
53
+ position = 0;
54
+ constructor(file) {
55
+ const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
56
+ const header = parseLine(lines.shift() ?? '', 1);
57
+ if (!isRecord(header) || header.format !== 'spl-generation' || header.version !== 1) {
58
+ throw new LlmError('unsupported recording format/version');
59
+ }
60
+ this.entries = lines.map((line, i) => {
61
+ 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')) {
65
+ throw new LlmError(`invalid recording entry at line ${i + 2}`);
66
+ }
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
+ };
75
+ });
76
+ }
77
+ async call(req) {
78
+ if (req.kind === 'agent')
79
+ throw new LlmError('agent actions cannot be replayed');
80
+ const entry = this.entries[this.position];
81
+ if (!entry)
82
+ throw new LlmError(`recording exhausted at call ${this.position + 1}`);
83
+ if (entry.request !== requestKey(req))
84
+ throw new LlmError(`replay request mismatch at call ${this.position + 1}`);
85
+ this.position++;
86
+ return structuredClone(entry.response);
87
+ }
88
+ async close() {
89
+ if (this.position !== this.entries.length) {
90
+ throw new LlmError(`replay ended with ${this.entries.length - this.position} unused recorded response(s)`);
91
+ }
92
+ }
93
+ }
@@ -20,6 +20,9 @@ export declare class Restrictions {
20
20
  checkUrl(url: string): URL;
21
21
  checkHost(host: string): void;
22
22
  checkExec(name: string): void;
23
+ checkMcp(server: string, tool: string): void;
24
+ /** Narrows configured tools before the SDK starts servers or advertises their tools. */
25
+ mcpTools(server: string, configured?: readonly string[]): string[];
23
26
  /** Human-readable guidance for the LLM (the first enforcement layer). */
24
27
  guidance(): string[];
25
28
  }
package/dist/restrict.js CHANGED
@@ -127,6 +127,24 @@ export class Restrictions {
127
127
  }
128
128
  }
129
129
  }
130
+ checkMcp(server, tool) {
131
+ for (const r of this.list) {
132
+ if (r.kind === 'mcp' && !r.values.some((v) => v === server || v === `${server}/*` || v === `${server}/${tool}`)) {
133
+ throw violation(`MCP tool '${server}/${tool}' blocked: allowed MCP servers/tools are ${r.values.join(', ')}`);
134
+ }
135
+ }
136
+ }
137
+ /** Narrows configured tools before the SDK starts servers or advertises their tools. */
138
+ mcpTools(server, configured = ['*']) {
139
+ let tools = [...configured];
140
+ for (const r of this.list) {
141
+ if (r.kind !== 'mcp' || r.values.includes(server) || r.values.includes(`${server}/*`))
142
+ continue;
143
+ const allowed = r.values.filter((v) => v.startsWith(`${server}/`)).map((v) => v.slice(server.length + 1));
144
+ tools = tools.includes('*') ? allowed : tools.filter((t) => allowed.includes(t));
145
+ }
146
+ return [...new Set(tools)];
147
+ }
130
148
  /** Human-readable guidance for the LLM (the first enforcement layer). */
131
149
  guidance() {
132
150
  return this.list.map((r) => {
@@ -134,7 +152,9 @@ export class Restrictions {
134
152
  return `File system: you may read and write only inside: ${r.values.join(', ')}. Any other path will be blocked.`;
135
153
  if (r.kind === 'net')
136
154
  return `Network: you may only access these hosts: ${r.values.join(', ')}. Any other host will be blocked.`;
137
- return `Shell: you may only run these executables: ${r.values.join(', ')}, one simple command at a time (no pipes, redirects or chaining). Anything else will be blocked.`;
155
+ if (r.kind === 'mcp')
156
+ return `MCP: you may only call these servers/tools: ${r.values.join(', ')}. Anything else will be blocked.`;
157
+ return `Shell: you may only run these executables: ${r.values.join(', ')}. Anything else will be blocked.`;
138
158
  });
139
159
  }
140
160
  }
@@ -154,5 +174,11 @@ export function makeRestriction(kind, raw, cwd) {
154
174
  return { kind, values: [...new Set(items.map((i) => canonicalPath(i, cwd)))] };
155
175
  if (kind === 'net')
156
176
  return { kind, values: [...new Set(items.map(normalizeHost))] };
177
+ if (kind === 'mcp') {
178
+ if (items.some((v) => !/^[A-Za-z0-9_.-]+(?:\/(?:[A-Za-z0-9_.-]+|\*))?$/.test(v))) {
179
+ throw new Failure('runtime_error', 'restrict mcp expects server names or server/tool selectors');
180
+ }
181
+ return { kind, values: [...new Set(items)] };
182
+ }
157
183
  return { kind, values: [...new Set(items.map(normalizeExec))] };
158
184
  }
@@ -0,0 +1,2 @@
1
+ import { type Value } from './values.js';
2
+ export declare function compileSchema(schema: Value): (value: Value) => void;
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
@@ -12,6 +12,10 @@ export interface ToolSpec {
12
12
  export declare const TOOL_SPECS: Record<ToolName, ToolSpec>;
13
13
  /** Splits a simple command line, honoring '...' and "..." quoting. No escapes, no expansion. */
14
14
  export declare function splitCommand(command: string): string[];
15
+ /** Splits a command string by chain/pipeline separators (&&, ||, |, ;, &, \n) outside quotes. */
16
+ export declare function splitChainedCommands(command: string): string[];
17
+ /** Finds nested $(...) or `...` command substitutions outside single quotes. */
18
+ export declare function extractSubstitutions(command: string): string[];
15
19
  export interface SearchResult {
16
20
  title: string;
17
21
  url: string;
@@ -26,7 +30,9 @@ export declare function parseSearchResults(html: string): SearchResult[];
26
30
  export declare class ToolHost {
27
31
  private readonly restrictions;
28
32
  private readonly cwd;
29
- constructor(restrictions: Restrictions, cwd: string);
33
+ private readonly replay;
34
+ private readonly raw;
35
+ constructor(restrictions: Restrictions, cwd: string, replay?: boolean, raw?: boolean);
30
36
  invoke(name: ToolName, params: Record<string, unknown>): Promise<string>;
31
37
  private readFile;
32
38
  private writeFile;
@@ -34,5 +40,7 @@ export declare class ToolHost {
34
40
  /** Searches DuckDuckGo's HTML endpoint (no API key); it is a network call, so `restrict net` applies to it. */
35
41
  private webSearch;
36
42
  private shell;
43
+ private checkCommandRestrictions;
44
+ private checkSubcommand;
37
45
  private run;
38
46
  }