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.
package/dist/cli.js CHANGED
@@ -2,20 +2,23 @@
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { CopilotBackend } from './copilot.js';
6
7
  import { AuthError, login, logout } from './auth.js';
7
8
  import { Failure, SplSyntaxError } from './errors.js';
8
9
  import { LlmError } from './llm.js';
9
10
  import { Logger } from './logger.js';
10
- import { parse } from './parser.js';
11
+ import { usesHuman } from './parser.js';
12
+ import { parseMarkdown } from './markdown.js';
11
13
  import { run } from './interpreter.js';
12
14
  import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
13
15
  import { toJson } from './values.js';
14
16
  import { loadMcpConfig } from './mcp.js';
15
- import { RecordingBackend, ReplayBackend } from './recording.js';
17
+ import { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
18
+ import { Checkpoint, CheckpointError, checkpointFingerprint } from './checkpoint.js';
16
19
  const HELP = `spl - Semantic Prompt Language interpreter
17
20
 
18
- Usage: spl <script.spl> [options]
21
+ Usage: spl <workflow.md> [options]
19
22
  spl login authenticate with GitHub Copilot (device flow in the browser)
20
23
  spl logout remove the credentials saved by 'spl login'
21
24
 
@@ -23,21 +26,32 @@ Options:
23
26
  --log-level <level> error | info | debug (default: info). Logs go to stderr.
24
27
  All levels include LLM transcripts and [deterministic] statement traces.
25
28
  --silent suppress all logs; only the result is printed
26
- --model <id> model override (takes precedence over @{model=...})
29
+ --model <id> model override (takes precedence over frontmatter)
27
30
  --arg <value> script argument exposed in $args (repeatable; also --arg=value)
28
31
  --mcp <file.json> MCP servers in VS Code mcp.json format
29
32
  --store <file.json> persist top-level variables in a JSON file
30
33
  --store-module <path> custom variable store: module exporting createStore() -> { load(), save(vars) }
31
34
  --timeout <seconds> per LLM call timeout (default: 300)
35
+ --trace <file.jsonl> trace all LLM requests, responses, and failures (new file)
32
36
  --record <file.jsonl> record generation responses (new file; no agents)
33
- --replay <file.jsonl> offline generation replay; only local reads allowed
37
+ --replay <file.jsonl> replay LLM results without Copilot/MCP; explicit script tools still run
38
+ Legacy --record files allow only local reads and no stores.
39
+ --checkpoint <file> start a new resumable run; with --resume, continue that journal
40
+ New runs never overwrite an existing file.
41
+ --resume [file] inspect pending input, or resume with an answer
42
+ Omit file when using --checkpoint <file>.
43
+ --answer-for <id> ID of the pending human request being answered
44
+ --answer <text> exact free-text answer (requires --resume and --answer-for)
45
+ --answer-file <file> read the answer from a UTF-8 file instead of --answer
34
46
  --cwd <dir> base directory for relative paths and restrictions (default: current directory)
35
47
  --check validate script syntax and any --mcp config without executing
36
48
  -v, --version print version
37
49
  -h, --help print this help
38
50
 
39
51
  The script result (the value of 'success' or 'return', or the failure payload) is written to stdout.
40
- Exit codes: 0 success, 1 uncaught script failure, 2 syntax or usage error.`;
52
+ Resumable runs print JSON envelopes and never read stdin.
53
+ Exit codes: 0 success, 1 uncaught script failure, 2 syntax or usage error,
54
+ 3 waiting for human input, 70 internal/infrastructure error.`;
41
55
  function version() {
42
56
  const pkg = new URL('../package.json', import.meta.url);
43
57
  return JSON.parse(fs.readFileSync(pkg, 'utf8')).version;
@@ -46,6 +60,71 @@ function usageError(msg) {
46
60
  process.stderr.write(`spl: ${msg}\nTry 'spl --help'.\n`);
47
61
  process.exit(2);
48
62
  }
63
+ async function checkpointCommand(source, program, values, cwd, logger, mcpServers) {
64
+ let checkpoint;
65
+ try {
66
+ const directory = fileURLToPath(new URL('.', import.meta.url));
67
+ const runtime = checkpointFingerprint([
68
+ version(), process.version,
69
+ ...fs.readdirSync(directory).filter((name) => name.endsWith('.js')).sort()
70
+ .map((name) => [name, fs.readFileSync(path.join(directory, name), 'utf8')]),
71
+ ]);
72
+ const context = {
73
+ source, runtime, cwd, args: values.arg,
74
+ configuration: { model: values.model ?? null, mcp: mcpServers ?? null },
75
+ };
76
+ checkpoint = values.resume !== undefined
77
+ ? Checkpoint.resume(values.resume, context)
78
+ : Checkpoint.create(values.checkpoint, context);
79
+ if (values.resume !== undefined) {
80
+ if (values.answer === undefined && values['answer-file'] === undefined) {
81
+ const envelope = { status: 'waiting_for_input', ...checkpoint.request };
82
+ checkpoint.close();
83
+ process.stdout.write(JSON.stringify(envelope) + '\n');
84
+ return 3;
85
+ }
86
+ let answer = values.answer;
87
+ if (values['answer-file'] !== undefined) {
88
+ try {
89
+ answer = fs.readFileSync(values['answer-file'], 'utf8');
90
+ }
91
+ catch (error) {
92
+ throw new CheckpointError(`cannot read answer file: ${error.message}`);
93
+ }
94
+ }
95
+ checkpoint.answer(values['answer-for'], answer);
96
+ }
97
+ const backend = new CopilotBackend({ logger, cwd, timeoutMs: Number(values.timeout ?? 300) * 1000, mcpServers });
98
+ let result;
99
+ try {
100
+ result = await run(program, { backend, logger, cwd, model: values.model, checkpoint });
101
+ }
102
+ finally {
103
+ await backend.close();
104
+ }
105
+ if (result.status === 'waiting_for_input') {
106
+ checkpoint.close();
107
+ process.stdout.write(JSON.stringify({ status: result.status, ...result.request }) + '\n');
108
+ return 3;
109
+ }
110
+ checkpoint.finish(result.status);
111
+ const envelope = result.status === 'success'
112
+ ? { status: result.status, run_id: checkpoint.runId, has_value: result.hasValue, value: toJson(result.value) }
113
+ : { status: result.status, run_id: checkpoint.runId, failure: result.failure };
114
+ checkpoint.close();
115
+ process.stdout.write(JSON.stringify(envelope) + '\n');
116
+ return result.status === 'success' ? 0 : 1;
117
+ }
118
+ catch (error) {
119
+ if (!(error instanceof CheckpointError) || error.kind !== 'usage')
120
+ throw error;
121
+ process.stderr.write(`spl: ${error.message}\n`);
122
+ return 2;
123
+ }
124
+ finally {
125
+ checkpoint?.close();
126
+ }
127
+ }
49
128
  async function loginCommand() {
50
129
  try {
51
130
  const status = await login((line) => process.stdout.write(line + '\n'));
@@ -73,25 +152,48 @@ function logoutCommand() {
73
152
  }
74
153
  async function main() {
75
154
  let parsed;
155
+ let resumeWithCheckpoint = false;
76
156
  try {
157
+ const options = {
158
+ 'log-level': { type: 'string' },
159
+ silent: { type: 'boolean' },
160
+ model: { type: 'string' },
161
+ arg: { type: 'string', multiple: true },
162
+ mcp: { type: 'string' },
163
+ store: { type: 'string' },
164
+ 'store-module': { type: 'string' },
165
+ timeout: { type: 'string' },
166
+ trace: { type: 'string' },
167
+ record: { type: 'string' },
168
+ replay: { type: 'string' },
169
+ checkpoint: { type: 'string' },
170
+ resume: { type: 'string' },
171
+ 'answer-for': { type: 'string' },
172
+ answer: { type: 'string' },
173
+ 'answer-file': { type: 'string' },
174
+ cwd: { type: 'string' },
175
+ check: { type: 'boolean' },
176
+ version: { type: 'boolean', short: 'v' },
177
+ help: { type: 'boolean', short: 'h' },
178
+ };
179
+ const args = process.argv.slice(2);
180
+ // parseArgs has no optional string options; locate bare --resume using tokens.
181
+ const { tokens } = parseArgs({
182
+ args, options: { ...options, resume: { type: 'boolean' } },
183
+ allowPositionals: true, strict: false, tokens: true,
184
+ });
185
+ const checkpointHasScript = tokens.some((token) => token.kind === 'option' && token.name === 'checkpoint')
186
+ && tokens.filter((token) => token.kind === 'positional').length === 1;
187
+ const bareResume = new Set(tokens
188
+ .filter((token) => token.kind === 'option' && token.name === 'resume'
189
+ && token.value === undefined
190
+ && (checkpointHasScript || args[token.index + 1] === undefined || args[token.index + 1].startsWith('-')))
191
+ .map((token) => token.index));
192
+ resumeWithCheckpoint = bareResume.size > 0;
77
193
  parsed = parseArgs({
194
+ args: args.filter((_, index) => !bareResume.has(index)),
78
195
  allowPositionals: true,
79
- options: {
80
- 'log-level': { type: 'string' },
81
- silent: { type: 'boolean' },
82
- model: { type: 'string' },
83
- arg: { type: 'string', multiple: true },
84
- mcp: { type: 'string' },
85
- store: { type: 'string' },
86
- 'store-module': { type: 'string' },
87
- timeout: { type: 'string' },
88
- record: { type: 'string' },
89
- replay: { type: 'string' },
90
- cwd: { type: 'string' },
91
- check: { type: 'boolean' },
92
- version: { type: 'boolean', short: 'v' },
93
- help: { type: 'boolean', short: 'h' },
94
- },
196
+ options,
95
197
  });
96
198
  }
97
199
  catch (e) {
@@ -122,12 +224,40 @@ async function main() {
122
224
  usageError('--timeout must be a positive number');
123
225
  if (values.store && values['store-module'])
124
226
  usageError('--store and --store-module are mutually exclusive');
125
- if (values.record && values.replay)
126
- usageError('--record and --replay are mutually exclusive');
127
- if (values.replay && (values.store || values['store-module']))
128
- usageError('--replay cannot use a persistent variable store');
227
+ if ([values.trace, values.record, values.replay].filter((value) => value !== undefined).length > 1) {
228
+ usageError('--trace, --record, and --replay are mutually exclusive');
229
+ }
230
+ if ([values.trace, values.record, values.replay].some((value) => value !== undefined && value.trim() === '')) {
231
+ usageError('--trace, --record, and --replay require a non-empty file path');
232
+ }
233
+ if (resumeWithCheckpoint) {
234
+ if (values.resume !== undefined)
235
+ usageError('use either --resume <file> or --resume --checkpoint <file>');
236
+ if (values.checkpoint === undefined)
237
+ usageError('--resume requires a file: use --resume <file> or --resume --checkpoint <file>');
238
+ values.resume = values.checkpoint;
239
+ delete values.checkpoint;
240
+ }
241
+ const resumable = values.checkpoint !== undefined || values.resume !== undefined;
242
+ if (values.checkpoint !== undefined && values.resume !== undefined)
243
+ usageError('--checkpoint and --resume are mutually exclusive');
244
+ if ([values.checkpoint, values.resume, values['answer-for'], values['answer-file']].some((value) => value !== undefined && !value.trim())) {
245
+ usageError('checkpoint, resume, answer-for, and answer-file require non-empty values');
246
+ }
247
+ if (resumable && [values.store, values['store-module'], values.trace, values.record, values.replay].some((value) => value !== undefined)) {
248
+ usageError('resumable runs cannot use --store, --store-module, --trace, --record, or --replay');
249
+ }
250
+ const answering = values.answer !== undefined || values['answer-file'] !== undefined;
251
+ if (values.answer !== undefined && values['answer-file'] !== undefined)
252
+ usageError('--answer and --answer-file are mutually exclusive');
253
+ if ((answering || values['answer-for'] !== undefined) && values.resume === undefined)
254
+ usageError('answer flags require --resume');
255
+ if (answering !== (values['answer-for'] !== undefined))
256
+ usageError('an answer requires --answer-for, and --answer-for requires an answer');
129
257
  const cwd = path.resolve(values.cwd ?? process.cwd());
130
258
  const file = positionals[0];
259
+ if (!/\.md$/i.test(file))
260
+ usageError('only .md workflows are executable; wrap SPL in a fenced spl block and add extension/version frontmatter');
131
261
  let source;
132
262
  try {
133
263
  source = fs.readFileSync(file, 'utf8');
@@ -137,7 +267,7 @@ async function main() {
137
267
  }
138
268
  let program;
139
269
  try {
140
- program = parse(source);
270
+ program = parseMarkdown(source);
141
271
  }
142
272
  catch (e) {
143
273
  if (e instanceof SplSyntaxError) {
@@ -155,13 +285,10 @@ async function main() {
155
285
  }
156
286
  if (values.check)
157
287
  return 0;
158
- let store;
159
- try {
160
- store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
161
- }
162
- catch (e) {
163
- return usageError(e.message);
164
- }
288
+ if (resumable)
289
+ return checkpointCommand(source, program, values, cwd, logger, mcpServers);
290
+ if (usesHuman(program))
291
+ usageError('human input requires --checkpoint or --resume');
165
292
  let backend;
166
293
  try {
167
294
  if (values.replay)
@@ -170,6 +297,8 @@ async function main() {
170
297
  backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000, mcpServers });
171
298
  if (values.record)
172
299
  backend = new RecordingBackend(backend, values.record);
300
+ if (values.trace)
301
+ backend = new TraceBackend(backend, values.trace);
173
302
  }
174
303
  }
175
304
  catch (e) {
@@ -177,9 +306,19 @@ async function main() {
177
306
  throw e;
178
307
  return usageError(`cannot open recording: ${e.message}`);
179
308
  }
309
+ if (backend.mode === 'replay' && (values.store || values['store-module'])) {
310
+ return usageError('--replay of legacy --record files cannot use a persistent variable store');
311
+ }
312
+ let store;
313
+ try {
314
+ store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
315
+ }
316
+ catch (e) {
317
+ return usageError(e.message);
318
+ }
180
319
  let result;
181
320
  try {
182
- result = await run(program, { backend, logger, store: values.replay ? undefined : store, cwd, model: values.model, args: values.arg });
321
+ result = await run(program, { backend, logger, store: backend.mode === 'replay' ? undefined : store, cwd, model: values.model, args: values.arg });
183
322
  }
184
323
  finally {
185
324
  try {
@@ -197,6 +336,8 @@ async function main() {
197
336
  process.stdout.write(JSON.stringify(result.failure, null, 4) + '\n');
198
337
  return 1;
199
338
  }
339
+ if (result.status === 'waiting_for_input')
340
+ throw new CheckpointError('unexpected human suspension without a checkpoint', 'infrastructure');
200
341
  if (result.hasValue) {
201
342
  const v = result.value;
202
343
  process.stdout.write((typeof v === 'string' ? v : JSON.stringify(toJson(v), null, 2)) + '\n');
package/dist/copilot.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CopilotClient, defineTool } from '@github/copilot-sdk';
2
2
  import { loadToken } from './auth.js';
3
- import { Failure } from './errors.js';
3
+ import { Failure, SplSyntaxError } from './errors.js';
4
+ import { parse } from './parser.js';
4
5
  import { LlmError } from './llm.js';
5
6
  import { TOOL_SPECS } from './tools.js';
6
7
  import { configuredMcpTool, filterMcpServers } from './mcp.js';
@@ -81,7 +82,8 @@ export class CopilotBackend {
81
82
  violation ??= failure;
82
83
  void session?.abort().catch(() => undefined);
83
84
  };
84
- const agent = req.kind === 'agent';
85
+ const agent = req.kind === 'agent' && !req.naturalLanguage;
86
+ let submitted;
85
87
  const tools = (agent ? TOOL_NAMES : []).map((name) => {
86
88
  const spec = TOOL_SPECS[name];
87
89
  return defineTool(name, {
@@ -105,15 +107,50 @@ export class CopilotBackend {
105
107
  },
106
108
  });
107
109
  });
110
+ if (req.naturalLanguage) {
111
+ tools.push(defineTool('submit_spl', {
112
+ description: 'Submit one complete SPL program to execute after this request, in shared document scope. No actions occur during submission.',
113
+ parameters: {
114
+ type: 'object', properties: { source: { type: 'string' } },
115
+ required: ['source'], additionalProperties: false,
116
+ },
117
+ handler: async (args) => {
118
+ if (!args || typeof args !== 'object' || !('source' in args) ||
119
+ typeof args.source !== 'string' || !args.source.trim()) {
120
+ return { resultType: 'failure', textResultForLlm: 'source must be non-empty SPL code' };
121
+ }
122
+ if (submitted !== undefined) {
123
+ return { resultType: 'failure', textResultForLlm: 'a program has already been submitted' };
124
+ }
125
+ try {
126
+ const program = parse(args.source);
127
+ if (program.directives.model !== undefined) {
128
+ return { resultType: 'failure', textResultForLlm: 'use model modifiers, not a model header' };
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (!(error instanceof SplSyntaxError))
133
+ throw error;
134
+ return { resultType: 'failure', textResultForLlm: error.message };
135
+ }
136
+ submitted = args.source;
137
+ return 'Program accepted for execution after this request. Do not submit again.';
138
+ },
139
+ }));
140
+ }
108
141
  const mcpServers = agent ? filterMcpServers(this.opts.mcpServers ?? {}, req.restrictions) : {};
109
142
  const baseConfig = {
110
143
  model: req.model,
111
144
  tools,
112
- availableTools: [...(agent ? TOOL_NAMES : []), ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
145
+ availableTools: [...tools.map((tool) => tool.name), ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
113
146
  mcpServers,
114
147
  systemMessage: { mode: 'append', content: SYSTEM_MESSAGE },
115
148
  workingDirectory: this.opts.cwd,
116
- onPermissionRequest: (r) => agent ? decidePermission(r, req.restrictions, block, mcpServers) : this.denyGenerationPermission(block),
149
+ onPermissionRequest: (r) => {
150
+ if (req.naturalLanguage && r.kind === 'custom-tool' && r.toolName === 'submit_spl')
151
+ return { kind: 'approve-once' };
152
+ return agent ? decidePermission(r, req.restrictions, block, mcpServers) : this.denyGenerationPermission(block);
153
+ },
117
154
  };
118
155
  try {
119
156
  try {
@@ -143,12 +180,15 @@ export class CopilotBackend {
143
180
  }
144
181
  if (violation)
145
182
  throw violation;
146
- const text = answer?.data?.content;
183
+ if (req.naturalLanguage && submitted === undefined)
184
+ throw new LlmError('natural-language request did not call submit_spl');
185
+ const text = answer?.data?.content ?? (submitted !== undefined ? 'SPL program submitted.' : undefined);
147
186
  if (typeof text !== 'string' || text.trim() === '')
148
187
  throw new LlmError('the model returned an empty response');
149
188
  const usedModel = answer?.data?.model;
150
189
  return {
151
190
  text,
191
+ ...(submitted !== undefined ? { spl: submitted } : {}),
152
192
  reasoning: reasoning.length ? reasoning.join('\n') : undefined,
153
193
  effective: {
154
194
  backend: 'copilot-sdk',
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- export { parse } from './parser.js';
1
+ export { parse, usesHuman } from './parser.js';
2
+ export { parseMarkdown, extractMarkdown, isWorkflowMarkdown } from './markdown.js';
2
3
  export { run, type RunOptions, type RunResult } from './interpreter.js';
3
4
  export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
4
5
  export { Logger, type LogLevel } from './logger.js';
5
6
  export { CopilotBackend } from './copilot.js';
6
- export { RecordingBackend, ReplayBackend } from './recording.js';
7
+ export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
8
+ export { Checkpoint, CheckpointError, checkpointFingerprint, type CheckpointContext, type HumanRequest } from './checkpoint.js';
7
9
  export { loadMcpConfig, type McpServers } from './mcp.js';
8
10
  export { login, logout, authStatus, AuthError } from './auth.js';
9
11
  export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
- export { parse } from './parser.js';
1
+ export { parse, usesHuman } from './parser.js';
2
+ export { parseMarkdown, extractMarkdown, isWorkflowMarkdown } from './markdown.js';
2
3
  export { run } from './interpreter.js';
3
4
  export { Failure, SplSyntaxError } from './errors.js';
4
5
  export { Logger } from './logger.js';
5
6
  export { CopilotBackend } from './copilot.js';
6
- export { RecordingBackend, ReplayBackend } from './recording.js';
7
+ export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
8
+ export { Checkpoint, CheckpointError, checkpointFingerprint } from './checkpoint.js';
7
9
  export { loadMcpConfig } from './mcp.js';
8
10
  export { login, logout, authStatus, AuthError } from './auth.js';
9
11
  export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
@@ -3,6 +3,7 @@ import { type FailurePayload } from './errors.js';
3
3
  import { type LlmBackend } from './llm.js';
4
4
  import type { Logger } from './logger.js';
5
5
  import { type VariableStore } from './store.js';
6
+ import { type Checkpoint, type HumanRequest } from './checkpoint.js';
6
7
  import { type Value } from './values.js';
7
8
  export interface RunOptions {
8
9
  backend: LlmBackend;
@@ -10,11 +11,12 @@ export interface RunOptions {
10
11
  store?: VariableStore;
11
12
  /** Working directory for relative paths, `restrict` folders and tools. */
12
13
  cwd?: string;
13
- /** Overrides the `@{model=...}` directive. */
14
+ /** Overrides the `@(model=...)` directive. */
14
15
  model?: string;
15
16
  /** Script arguments exposed as read-only `$args`; defaults to an empty list. */
16
17
  args?: readonly string[];
17
18
  maxCallDepth?: number;
19
+ checkpoint?: Checkpoint;
18
20
  }
19
21
  export type RunResult = {
20
22
  status: 'success';
@@ -23,5 +25,8 @@ export type RunResult = {
23
25
  } | {
24
26
  status: 'failure';
25
27
  failure: FailurePayload;
28
+ } | {
29
+ status: 'waiting_for_input';
30
+ request: HumanRequest;
26
31
  };
27
32
  export declare function run(program: Program, opts: RunOptions): Promise<RunResult>;