spl-lang 0.0.0-stage → 0.1.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.
@@ -0,0 +1,546 @@
1
+ import { callMethod, iterate, makeBuiltins } from './builtins.js';
2
+ import { Failure, SuccessSignal } from './errors.js';
3
+ import { LlmError, buildPrompt, parseObjectResponse } from './llm.js';
4
+ import { Restrictions, makeRestriction } from './restrict.js';
5
+ import { MemoryStore } from './store.js';
6
+ import { ToolHost, TOOL_SPECS } from './tools.js';
7
+ import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, truthy, typeName, } from './values.js';
8
+ export async function run(program, opts) {
9
+ return new Interpreter(program, opts).run();
10
+ }
11
+ class Interpreter {
12
+ program;
13
+ opts;
14
+ globals = { vars: new Map() };
15
+ builtins;
16
+ store;
17
+ cwd;
18
+ model;
19
+ maxDepth;
20
+ constraints = [];
21
+ restrictions;
22
+ depth = 0;
23
+ constructor(program, opts) {
24
+ this.program = program;
25
+ this.opts = opts;
26
+ this.cwd = opts.cwd ?? process.cwd();
27
+ this.store = opts.store ?? new MemoryStore();
28
+ this.model = opts.model ?? program.directives.model;
29
+ this.maxDepth = opts.maxCallDepth ?? 200;
30
+ this.restrictions = new Restrictions(this.cwd);
31
+ this.builtins = makeBuiltins({ log: (m) => opts.logger.info(`log: ${m}`) });
32
+ }
33
+ async run() {
34
+ const { logger } = this.opts;
35
+ try {
36
+ for (const [k, v] of Object.entries(this.store.load()))
37
+ this.globals.vars.set(k, v);
38
+ const signal = await this.execBlock(this.program.body, this.globals);
39
+ if (signal?.k === 'return')
40
+ return { status: 'success', value: signal.v, hasValue: signal.hasValue };
41
+ if (signal)
42
+ throw runtimeError(`'${signal.k}' outside of a loop`);
43
+ return { status: 'success', value: null, hasValue: false };
44
+ }
45
+ catch (e) {
46
+ if (e instanceof SuccessSignal)
47
+ return { status: 'success', value: e.value, hasValue: true };
48
+ if (e instanceof Failure) {
49
+ logger.error(`${e.statusCode}: ${e.reason} (line ${e.line})`);
50
+ return { status: 'failure', failure: e.payload() };
51
+ }
52
+ throw e;
53
+ }
54
+ finally {
55
+ try {
56
+ const snapshot = {};
57
+ for (const [k, v] of this.globals.vars)
58
+ if (!isFunction(v))
59
+ snapshot[k] = v;
60
+ this.store.save(snapshot);
61
+ }
62
+ catch (e) {
63
+ logger.error(`could not save variable store: ${e.message}`);
64
+ }
65
+ }
66
+ }
67
+ // ---- environments --------------------------------------------------
68
+ lookup(name, env) {
69
+ for (let e = env; e; e = e.parent) {
70
+ if (e.vars.has(name))
71
+ return e.vars.get(name);
72
+ }
73
+ const b = this.builtins.get(name);
74
+ if (b)
75
+ return b;
76
+ throw runtimeError(`name '${name}' is not defined`);
77
+ }
78
+ // ---- statements ----------------------------------------------------
79
+ async execBlock(stmts, env) {
80
+ for (const s of stmts) {
81
+ const signal = await this.execStmt(s, env);
82
+ if (signal)
83
+ return signal;
84
+ }
85
+ return undefined;
86
+ }
87
+ async execStmt(s, env) {
88
+ try {
89
+ return await this.execStmtInner(s, env);
90
+ }
91
+ catch (e) {
92
+ if (e instanceof Failure)
93
+ throw e.locate(s.line, this.program.lines[s.line - 1]?.trim() ?? '');
94
+ if (e instanceof RangeError) {
95
+ throw new Failure('runtime_error', e.message).locate(s.line, this.program.lines[s.line - 1]?.trim() ?? '');
96
+ }
97
+ throw e;
98
+ }
99
+ }
100
+ async execStmtInner(s, env) {
101
+ switch (s.t) {
102
+ case 'let':
103
+ await this.execLet(s.target, s.op, s.value, env);
104
+ return;
105
+ case 'block':
106
+ return this.execBlock(s.body, { vars: new Map(), parent: env });
107
+ case 'expr':
108
+ await this.eval(s.e, env);
109
+ return;
110
+ case 'pass':
111
+ return;
112
+ case 'break':
113
+ return { k: 'break' };
114
+ case 'continue':
115
+ return { k: 'continue' };
116
+ case 'return':
117
+ return { k: 'return', v: s.value ? await this.eval(s.value, env) : null, hasValue: s.value !== undefined };
118
+ case 'if': {
119
+ for (const b of s.branches) {
120
+ if (truthy(await this.eval(b.cond, env)))
121
+ return this.execBlock(b.body, env);
122
+ }
123
+ return s.orelse ? this.execBlock(s.orelse, env) : undefined;
124
+ }
125
+ case 'while': {
126
+ while (truthy(await this.eval(s.cond, env))) {
127
+ const sig = await this.execBlock(s.body, env);
128
+ if (sig?.k === 'break')
129
+ break;
130
+ if (sig?.k === 'return')
131
+ return sig;
132
+ }
133
+ return;
134
+ }
135
+ case 'for': {
136
+ const items = iterate(await this.eval(s.iter, env));
137
+ for (let i = 0; i < items.length; i++) {
138
+ this.bindLoopVars(s.vars, items[i], env);
139
+ const sig = await this.execBlock(s.body, env);
140
+ if (sig?.k === 'break')
141
+ break;
142
+ if (sig?.k === 'return')
143
+ return sig;
144
+ }
145
+ return;
146
+ }
147
+ case 'def': {
148
+ const fn = { __fn: 'user', name: s.name, params: s.params, body: s.body, closure: env };
149
+ env.vars.set(s.name, fn);
150
+ return;
151
+ }
152
+ case 'try': {
153
+ const savedConstraints = this.constraints;
154
+ const savedRestrictions = this.restrictions;
155
+ const savedDepth = this.depth;
156
+ try {
157
+ return await this.execBlock(s.body, env);
158
+ }
159
+ catch (e) {
160
+ if (!(e instanceof Failure))
161
+ throw e;
162
+ this.constraints = savedConstraints;
163
+ this.restrictions = savedRestrictions;
164
+ this.depth = savedDepth;
165
+ this.opts.logger.debug(`caught ${e.statusCode}: ${e.reason}`);
166
+ env.vars.set(s.errName, newDict(Object.entries(e.payload())));
167
+ return this.execBlock(s.handler, env);
168
+ }
169
+ }
170
+ case 'assert': {
171
+ if (truthy(await this.eval(s.cond, env)))
172
+ return;
173
+ const msg = s.message ? str(await this.eval(s.message, env)) : 'assertion failed';
174
+ throw new Failure('assertion_failed', msg);
175
+ }
176
+ case 'fail':
177
+ throw new Failure('explicit_failure', str(await this.eval(s.reason, env)));
178
+ case 'success':
179
+ throw new SuccessSignal(s.value ? await this.eval(s.value, env) : null);
180
+ case 'constraint': {
181
+ const text = str(await this.eval(s.text, env));
182
+ const saved = this.constraints;
183
+ this.constraints = [...saved, { kind: s.kind, text }];
184
+ try {
185
+ return await this.execBlock(s.body, env);
186
+ }
187
+ finally {
188
+ this.constraints = saved;
189
+ }
190
+ }
191
+ case 'restrict': {
192
+ const restriction = makeRestriction(s.kind, await this.eval(s.arg, env), this.cwd);
193
+ const saved = this.restrictions;
194
+ this.restrictions = saved.with(restriction);
195
+ this.opts.logger.debug(`restrict ${s.kind}: ${restriction.values.join(', ')}`);
196
+ try {
197
+ return await this.execBlock(s.body, env);
198
+ }
199
+ finally {
200
+ this.restrictions = saved;
201
+ }
202
+ }
203
+ }
204
+ }
205
+ bindLoopVars(vars, item, env) {
206
+ if (vars.length === 1) {
207
+ env.vars.set(vars[0], item);
208
+ return;
209
+ }
210
+ if (!isList(item) || item.length !== vars.length) {
211
+ throw runtimeError(`cannot unpack ${typeName(item)} into ${vars.length} variables`);
212
+ }
213
+ vars.forEach((v, i) => env.vars.set(v, item[i]));
214
+ }
215
+ async execLet(target, op, valueExpr, env) {
216
+ const compute = async (current) => {
217
+ const rhs = await this.eval(valueExpr, env);
218
+ return op === '=' ? rhs : this.binary(op.slice(0, -1), current(), rhs);
219
+ };
220
+ if (target.t === 'name') {
221
+ // Binds in the current scope, which is what lets a nested block shadow an outer variable.
222
+ env.vars.set(target.name, await compute(() => this.lookup(target.name, env)));
223
+ return;
224
+ }
225
+ const obj = await this.eval(target.obj, env);
226
+ if (target.t === 'member') {
227
+ if (!isDict(obj))
228
+ throw runtimeError(`cannot set member '${target.name}' on ${typeName(obj)}`);
229
+ const value = await compute(() => this.getKey(obj, target.name));
230
+ setKey(obj, target.name, value);
231
+ return;
232
+ }
233
+ const index = await this.eval(target.index, env);
234
+ if (isList(obj)) {
235
+ const i = this.listIndex(obj, index);
236
+ obj[i] = await compute(() => obj[i]);
237
+ }
238
+ else if (isDict(obj)) {
239
+ if (typeof index !== 'string')
240
+ throw runtimeError(`dict keys must be strings, got ${typeName(index)}`);
241
+ setKey(obj, index, await compute(() => this.getKey(obj, index)));
242
+ }
243
+ else {
244
+ throw runtimeError(`${typeName(obj)} does not support item assignment`);
245
+ }
246
+ }
247
+ // ---- expressions ---------------------------------------------------
248
+ async text(parts, env) {
249
+ let out = '';
250
+ for (const p of parts)
251
+ out += typeof p === 'string' ? p : str(await this.eval(p, env));
252
+ return out;
253
+ }
254
+ async eval(e, env) {
255
+ switch (e.t) {
256
+ case 'num': return e.v;
257
+ case 'str': return this.text(e.parts, env);
258
+ case 'bool': return e.v;
259
+ case 'none': return null;
260
+ case 'name': return this.lookup(e.name, env);
261
+ case 'list': {
262
+ const out = [];
263
+ for (const it of e.items)
264
+ out.push(await this.eval(it, env));
265
+ return out;
266
+ }
267
+ case 'dict': {
268
+ const d = newDict();
269
+ for (const [k, v] of e.entries) {
270
+ const key = await this.eval(k, env);
271
+ if (typeof key !== 'string')
272
+ throw runtimeError(`dict keys must be strings, got ${typeName(key)}`);
273
+ setKey(d, key, await this.eval(v, env));
274
+ }
275
+ return d;
276
+ }
277
+ case 'unary': {
278
+ const v = await this.eval(e.e, env);
279
+ if (e.op === 'not')
280
+ return !truthy(v);
281
+ if (typeof v !== 'number')
282
+ throw runtimeError(`bad operand for unary ${e.op}: ${typeName(v)}`);
283
+ return e.op === '-' ? -v : v;
284
+ }
285
+ case 'binary':
286
+ return this.binary(e.op, await this.eval(e.l, env), await this.eval(e.r, env));
287
+ case 'logic': {
288
+ const l = await this.eval(e.l, env);
289
+ if (e.op === 'and')
290
+ return truthy(l) ? this.eval(e.r, env) : l;
291
+ return truthy(l) ? l : this.eval(e.r, env);
292
+ }
293
+ case 'compare': {
294
+ let left = await this.eval(e.operands[0], env);
295
+ for (let i = 0; i < e.ops.length; i++) {
296
+ const right = await this.eval(e.operands[i + 1], env);
297
+ if (!this.compare(e.ops[i], left, right))
298
+ return false;
299
+ left = right;
300
+ }
301
+ return true;
302
+ }
303
+ case 'ternary':
304
+ return truthy(await this.eval(e.cond, env)) ? this.eval(e.then, env) : this.eval(e.else, env);
305
+ case 'call': return this.evalCall(e.callee, e.args, env);
306
+ case 'member': {
307
+ const obj = await this.eval(e.obj, env);
308
+ if (!isDict(obj))
309
+ throw runtimeError(`${typeName(obj)} has no member '${e.name}'`);
310
+ return this.getKey(obj, e.name);
311
+ }
312
+ case 'index': return this.index(await this.eval(e.obj, env), await this.eval(e.index, env));
313
+ case 'slice': {
314
+ const obj = await this.eval(e.obj, env);
315
+ const lo = e.lo ? await this.eval(e.lo, env) : undefined;
316
+ const hi = e.hi ? await this.eval(e.hi, env) : undefined;
317
+ return this.slice(obj, lo, hi);
318
+ }
319
+ case 'ask': {
320
+ const prompt = str(await this.eval(e.prompt, env));
321
+ const text = await this.callLlm({ kind: 'ask', prompt, mods: e.mods });
322
+ return e.mods.fmt === 'object' ? parseObjectResponse(text) : text;
323
+ }
324
+ case 'think': {
325
+ const prompt = str(await this.eval(e.prompt, env));
326
+ return this.callLlm({
327
+ kind: 'think',
328
+ prompt,
329
+ effort: e.mods.level ?? 'medium',
330
+ show: e.mods.show ?? false,
331
+ });
332
+ }
333
+ case 'tool': return this.evalTool(e.tool, e.params, e.instructions, env);
334
+ }
335
+ }
336
+ getKey(d, k) {
337
+ if (!hasKey(d, k))
338
+ throw runtimeError(`key '${k}' not found`);
339
+ return d[k];
340
+ }
341
+ async evalCall(callee, argExprs, env) {
342
+ if (callee.t === 'member') {
343
+ const obj = await this.eval(callee.obj, env);
344
+ const args = await this.evalArgs(argExprs, env);
345
+ return callMethod(obj, callee.name, args);
346
+ }
347
+ const fn = await this.eval(callee, env);
348
+ const args = await this.evalArgs(argExprs, env);
349
+ if (!isFunction(fn))
350
+ throw runtimeError(`${typeName(fn)} is not callable`);
351
+ if (fn.__fn === 'builtin')
352
+ return fn.call(args);
353
+ return this.callUser(fn, args);
354
+ }
355
+ async evalArgs(exprs, env) {
356
+ const out = [];
357
+ for (const a of exprs)
358
+ out.push(await this.eval(a, env));
359
+ return out;
360
+ }
361
+ async callUser(fn, args) {
362
+ if (args.length !== fn.params.length) {
363
+ throw runtimeError(`${fn.name}() takes ${fn.params.length} argument(s) but ${args.length} were given`);
364
+ }
365
+ if (this.depth >= this.maxDepth)
366
+ throw runtimeError(`maximum call depth (${this.maxDepth}) exceeded`);
367
+ const local = { vars: new Map(fn.params.map((p, i) => [p, args[i]])), parent: fn.closure };
368
+ this.depth++;
369
+ try {
370
+ const sig = await this.execBlock(fn.body, local);
371
+ if (sig && sig.k !== 'return')
372
+ throw runtimeError(`'${sig.k}' outside of a loop`);
373
+ return sig?.k === 'return' ? sig.v : null;
374
+ }
375
+ finally {
376
+ this.depth--;
377
+ }
378
+ }
379
+ // ---- operators -----------------------------------------------------
380
+ binary(op, l, r) {
381
+ if (op === '+') {
382
+ if (typeof l === 'number' && typeof r === 'number')
383
+ return l + r;
384
+ if (typeof l === 'string' && typeof r === 'string')
385
+ return l + r;
386
+ if (isList(l) && isList(r))
387
+ return [...l, ...r];
388
+ throw runtimeError(`unsupported operand types for +: ${typeName(l)} and ${typeName(r)} (use "{...}" interpolation or str())`);
389
+ }
390
+ if (op === '*') {
391
+ if (typeof l === 'number' && typeof r === 'number')
392
+ return l * r;
393
+ const [seq, n] = typeof l === 'number' ? [r, l] : [l, r];
394
+ if ((typeof seq === 'string' || isList(seq)) && typeof n === 'number' && Number.isInteger(n)) {
395
+ if (typeof seq === 'string')
396
+ return seq.repeat(Math.max(0, n));
397
+ return Array.from({ length: Math.max(0, n) }, () => seq).flat();
398
+ }
399
+ throw runtimeError(`unsupported operand types for *: ${typeName(l)} and ${typeName(r)}`);
400
+ }
401
+ if (typeof l !== 'number' || typeof r !== 'number') {
402
+ throw runtimeError(`unsupported operand types for ${op}: ${typeName(l)} and ${typeName(r)}`);
403
+ }
404
+ switch (op) {
405
+ case '-': return l - r;
406
+ case '/':
407
+ if (r === 0)
408
+ throw runtimeError('division by zero');
409
+ return l / r;
410
+ case '//':
411
+ if (r === 0)
412
+ throw runtimeError('division by zero');
413
+ return Math.floor(l / r);
414
+ case '%':
415
+ if (r === 0)
416
+ throw runtimeError('modulo by zero');
417
+ return ((l % r) + r) % r;
418
+ case '**': return l ** r;
419
+ }
420
+ throw runtimeError(`unknown operator ${op}`);
421
+ }
422
+ compare(op, l, r) {
423
+ switch (op) {
424
+ case '==': return equals(l, r);
425
+ case '!=': return !equals(l, r);
426
+ case '<': return compareValues(l, r) < 0;
427
+ case '<=': return compareValues(l, r) <= 0;
428
+ case '>': return compareValues(l, r) > 0;
429
+ case '>=': return compareValues(l, r) >= 0;
430
+ case 'in': return this.contains(r, l);
431
+ case 'not in': return !this.contains(r, l);
432
+ }
433
+ throw runtimeError(`unknown comparison ${op}`);
434
+ }
435
+ contains(container, item) {
436
+ if (typeof container === 'string') {
437
+ if (typeof item !== 'string')
438
+ throw runtimeError(`'in <string>' requires a string, got ${typeName(item)}`);
439
+ return container.includes(item);
440
+ }
441
+ if (isList(container))
442
+ return container.some((x) => equals(x, item));
443
+ if (isDict(container))
444
+ return typeof item === 'string' && hasKey(container, item);
445
+ throw runtimeError(`argument of type ${typeName(container)} is not a container`);
446
+ }
447
+ listIndex(list, index) {
448
+ if (typeof index !== 'number' || !Number.isInteger(index))
449
+ throw runtimeError(`indices must be integers, got ${typeName(index)}`);
450
+ const i = index < 0 ? list.length + index : index;
451
+ if (i < 0 || i >= list.length)
452
+ throw runtimeError(`index ${index} out of range`);
453
+ return i;
454
+ }
455
+ index(obj, index) {
456
+ if (isList(obj))
457
+ return obj[this.listIndex(obj, index)];
458
+ if (typeof obj === 'string') {
459
+ const chars = Array.from(obj);
460
+ return chars[this.listIndex(chars, index)];
461
+ }
462
+ if (isDict(obj)) {
463
+ if (typeof index !== 'string')
464
+ throw runtimeError(`dict keys must be strings, got ${typeName(index)}`);
465
+ return this.getKey(obj, index);
466
+ }
467
+ throw runtimeError(`${typeName(obj)} is not subscriptable`);
468
+ }
469
+ slice(obj, lo, hi) {
470
+ if (typeof obj !== 'string' && !isList(obj))
471
+ throw runtimeError(`${typeName(obj)} cannot be sliced`);
472
+ const items = typeof obj === 'string' ? Array.from(obj) : obj;
473
+ const bound = (v, dflt) => {
474
+ if (v === undefined)
475
+ return dflt;
476
+ if (typeof v !== 'number' || !Number.isInteger(v))
477
+ throw runtimeError('slice indices must be integers');
478
+ return Math.min(Math.max(v < 0 ? items.length + v : v, 0), items.length);
479
+ };
480
+ const part = items.slice(bound(lo, 0), bound(hi, items.length));
481
+ return typeof obj === 'string' ? part.join('') : part;
482
+ }
483
+ // ---- LLM and tools -------------------------------------------------
484
+ async callLlm(c) {
485
+ const { logger, backend } = this.opts;
486
+ const req = {
487
+ kind: c.kind,
488
+ prompt: buildPrompt({
489
+ prompt: c.prompt,
490
+ mods: c.mods,
491
+ thinking: c.kind === 'think',
492
+ constraints: this.constraints,
493
+ restrictions: this.restrictions,
494
+ }),
495
+ model: this.model,
496
+ reasoningEffort: c.effort ?? (c.kind === 'tool' ? 'medium' : undefined),
497
+ showReasoning: c.show ?? false,
498
+ restrictions: this.restrictions,
499
+ tools: new ToolHost(this.restrictions, this.cwd),
500
+ pinned: c.pinned,
501
+ };
502
+ logger.debug(`llm request (${c.kind}):\n${req.prompt}`);
503
+ const started = Date.now();
504
+ try {
505
+ const res = await backend.call(req);
506
+ logger.info(`llm ${c.kind} call: ${JSON.stringify(res.effective)} (${Date.now() - started} ms)`);
507
+ logger.debug(`llm response:\n${res.text}`);
508
+ return res.text.trim();
509
+ }
510
+ catch (e) {
511
+ if (e instanceof Failure)
512
+ throw e;
513
+ if (e instanceof LlmError)
514
+ throw new Failure('llm_error', e.message);
515
+ throw new Failure('llm_error', e.message ?? String(e));
516
+ }
517
+ }
518
+ async evalTool(tool, paramExprs, instrExpr, env) {
519
+ const params = {};
520
+ for (const [k, ex] of Object.entries(paramExprs)) {
521
+ const v = await this.eval(ex, env);
522
+ if (k !== 'content' && typeof v !== 'string')
523
+ throw runtimeError(`${tool}: parameter '${k}' must be a string, got ${typeName(v)}`);
524
+ params[k] = str(v);
525
+ }
526
+ const instructions = str(await this.eval(instrExpr, env));
527
+ const host = new ToolHost(this.restrictions, this.cwd);
528
+ // When the script leaves a required parameter out, the LLM calls the tool itself with the fixed ones pinned.
529
+ if (TOOL_SPECS[tool].required.some((p) => !(p in params))) {
530
+ if (tool === 'write_file' && params.path !== undefined)
531
+ this.restrictions.checkPath(params.path, 'write');
532
+ const fixed = Object.keys(params).length ? ` with these fixed parameters (set by the script): ${JSON.stringify(params)}` : '';
533
+ return this.callLlm({
534
+ kind: 'tool',
535
+ prompt: `Use the ${tool} tool${fixed}. Instructions: ${instructions}`,
536
+ pinned: { tool, params },
537
+ });
538
+ }
539
+ const output = await host.invoke(tool, params);
540
+ this.opts.logger.debug(`tool ${tool} ${JSON.stringify(params)} -> ${output.length} chars`);
541
+ return this.callLlm({
542
+ kind: 'tool',
543
+ prompt: `The tool \`${tool}\` was run with parameters ${JSON.stringify(params)}. Its output is between the markers.\n<tool_output>\n${output}\n</tool_output>\n\nInstructions: ${instructions}`,
544
+ });
545
+ }
546
+ }
@@ -0,0 +1,43 @@
1
+ export type StringPart = {
2
+ text: string;
3
+ } | {
4
+ expr: string;
5
+ line: number;
6
+ col: number;
7
+ };
8
+ export type Token = {
9
+ type: 'num';
10
+ value: number;
11
+ line: number;
12
+ col: number;
13
+ } | {
14
+ type: 'str';
15
+ parts: StringPart[];
16
+ line: number;
17
+ col: number;
18
+ } | {
19
+ type: 'name';
20
+ value: string;
21
+ line: number;
22
+ col: number;
23
+ } | {
24
+ type: 'op';
25
+ value: string;
26
+ line: number;
27
+ col: number;
28
+ } | {
29
+ type: 'nl';
30
+ line: number;
31
+ col: number;
32
+ } | {
33
+ type: 'eof';
34
+ line: number;
35
+ col: number;
36
+ };
37
+ export interface LexOptions {
38
+ /** Lexing the body of a `{...}` interpolation: newlines are insignificant. */
39
+ inExpr?: boolean;
40
+ line?: number;
41
+ col?: number;
42
+ }
43
+ export declare function tokenize(src: string, opts?: LexOptions): Token[];