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,192 @@
1
+ import { CopilotClient, defineTool } from '@github/copilot-sdk';
2
+ import { loadToken } from './auth.js';
3
+ import { Failure } from './errors.js';
4
+ import { LlmError } from './llm.js';
5
+ import { TOOL_SPECS } from './tools.js';
6
+ const SYSTEM_MESSAGE = [
7
+ 'You are the execution engine of an automated script. Your reply is stored in a variable and used by the program.',
8
+ 'Reply with exactly what was asked for: no preamble, no sign-off, no commentary.',
9
+ 'Use the provided tools (read_file, write_file, web_fetch, web_search, shell) only when the task needs them. No other tools exist.',
10
+ ].join(' ');
11
+ const TOOL_NAMES = Object.keys(TOOL_SPECS);
12
+ export class CopilotBackend {
13
+ opts;
14
+ client;
15
+ models;
16
+ constructor(opts) {
17
+ this.opts = opts;
18
+ }
19
+ async getClient() {
20
+ if (!this.client) {
21
+ const token = loadToken();
22
+ const client = new CopilotClient(token ? { gitHubToken: token } : {});
23
+ try {
24
+ await client.start();
25
+ const status = await client.getAuthStatus();
26
+ if (!status.isAuthenticated) {
27
+ throw new Error(`not authenticated with GitHub Copilot${status.statusMessage ? ` (${status.statusMessage})` : ''}; run 'spl login'`);
28
+ }
29
+ }
30
+ catch (e) {
31
+ await client.stop().catch(() => undefined);
32
+ throw new LlmError(`cannot start the Copilot runtime: ${e.message}`);
33
+ }
34
+ this.client = client;
35
+ }
36
+ return this.client;
37
+ }
38
+ listModels(client) {
39
+ this.models ??= client.listModels().catch((e) => {
40
+ this.opts.logger.debug(`could not list models: ${e.message}`);
41
+ return undefined;
42
+ });
43
+ return this.models;
44
+ }
45
+ /** Maps the `think` level to the SDK reasoning effort, warning when the model cannot honor it. */
46
+ async resolveEffort(client, req) {
47
+ if (!req.reasoningEffort)
48
+ return undefined;
49
+ if (!req.model)
50
+ return req.reasoningEffort; // default model unknown; session creation is retried without it on error
51
+ const info = (await this.listModels(client))?.find((m) => m.id === req.model);
52
+ const supported = info?.supportedReasoningEfforts;
53
+ if (info && !supported?.includes(req.reasoningEffort)) {
54
+ this.opts.logger.warn(`model '${req.model}' does not support reasoning effort '${req.reasoningEffort}'; using the model default`);
55
+ return undefined;
56
+ }
57
+ return req.reasoningEffort;
58
+ }
59
+ /** Fails on a model id the runtime does not offer; it would otherwise silently fall back to the default model. */
60
+ async checkModel(client, model) {
61
+ if (!model)
62
+ return;
63
+ const models = await this.listModels(client);
64
+ if (models && !models.some((m) => m.id === model)) {
65
+ throw new LlmError(`unknown model '${model}' (available: ${models.map((m) => m.id).join(', ')})`);
66
+ }
67
+ }
68
+ async call(req) {
69
+ const client = await this.getClient();
70
+ await this.checkModel(client, req.model);
71
+ let effort = await this.resolveEffort(client, req);
72
+ let violation;
73
+ let session;
74
+ const block = (failure) => {
75
+ violation ??= failure;
76
+ void session?.abort().catch(() => undefined);
77
+ };
78
+ const tools = TOOL_NAMES.map((name) => {
79
+ const spec = TOOL_SPECS[name];
80
+ return defineTool(name, {
81
+ description: spec.description,
82
+ overridesBuiltInTool: true,
83
+ parameters: { type: 'object', properties: spec.properties, required: spec.required, additionalProperties: false },
84
+ handler: async (args) => {
85
+ const params = { ...args };
86
+ if (req.pinned?.tool === name)
87
+ Object.assign(params, req.pinned.params);
88
+ try {
89
+ return await req.tools.invoke(name, params);
90
+ }
91
+ catch (e) {
92
+ if (!(e instanceof Failure))
93
+ throw e;
94
+ if (e.statusCode === 'restriction_violation') {
95
+ block(e);
96
+ return { textResultForLlm: JSON.stringify(e.payload()), resultType: 'denied', error: e.reason };
97
+ }
98
+ return { textResultForLlm: e.reason, resultType: 'failure', error: e.reason };
99
+ }
100
+ },
101
+ });
102
+ });
103
+ const baseConfig = {
104
+ model: req.model,
105
+ tools,
106
+ availableTools: TOOL_NAMES,
107
+ systemMessage: { mode: 'append', content: SYSTEM_MESSAGE },
108
+ workingDirectory: this.opts.cwd,
109
+ onPermissionRequest: (r) => decidePermission(r, req.restrictions, block),
110
+ };
111
+ try {
112
+ try {
113
+ session = await client.createSession({ ...baseConfig, reasoningEffort: effort });
114
+ }
115
+ catch (e) {
116
+ if (!effort)
117
+ throw e;
118
+ this.opts.logger.warn(`reasoning effort '${effort}' was rejected (${e.message}); using the model default`);
119
+ effort = undefined;
120
+ session = await client.createSession(baseConfig);
121
+ }
122
+ const reasoning = [];
123
+ session.on('assistant.reasoning', (ev) => {
124
+ reasoning.push(ev.data.content);
125
+ if (req.showReasoning)
126
+ this.opts.logger.info(`reasoning: ${ev.data.content}`);
127
+ });
128
+ let answer;
129
+ try {
130
+ answer = await session.sendAndWait({ prompt: req.prompt }, this.opts.timeoutMs);
131
+ }
132
+ catch (e) {
133
+ if (violation)
134
+ throw violation;
135
+ throw new LlmError(e.message);
136
+ }
137
+ if (violation)
138
+ throw violation;
139
+ const text = answer?.data?.content;
140
+ if (typeof text !== 'string' || text.trim() === '')
141
+ throw new LlmError('the model returned an empty response');
142
+ const usedModel = answer?.data?.model;
143
+ return {
144
+ text,
145
+ reasoning: reasoning.length ? reasoning.join('\n') : undefined,
146
+ effective: {
147
+ backend: 'copilot-sdk',
148
+ model: usedModel ?? req.model ?? 'default',
149
+ reasoningEffort: effort ?? null,
150
+ },
151
+ };
152
+ }
153
+ finally {
154
+ await session?.disconnect().catch(() => undefined);
155
+ }
156
+ }
157
+ async close() {
158
+ await this.client?.stop().catch(() => undefined);
159
+ this.client = undefined;
160
+ }
161
+ }
162
+ /**
163
+ * Hard enforcement at the permission layer. Only the five runtime tools are exposed to the model, so any
164
+ * other request is denied; requests that would violate a restriction are additionally reported as violations.
165
+ */
166
+ export function decidePermission(r, restrictions, block) {
167
+ try {
168
+ if (r.kind === 'custom-tool' && TOOL_NAMES.includes(r.toolName)) {
169
+ return { kind: 'approve-once' }; // the tool itself performs the restriction checks
170
+ }
171
+ if (r.kind === 'read')
172
+ restrictions.checkPath(r.path, 'read');
173
+ else if (r.kind === 'write')
174
+ restrictions.checkPath(r.fileName, 'write');
175
+ else if (r.kind === 'url')
176
+ restrictions.checkUrl(r.url);
177
+ else if (r.kind === 'shell') {
178
+ const s = r;
179
+ for (const c of s.commands)
180
+ restrictions.checkExec(c.identifier);
181
+ for (const p of s.possiblePaths)
182
+ restrictions.checkPath(p, 'read');
183
+ }
184
+ }
185
+ catch (e) {
186
+ if (e instanceof Failure && e.statusCode === 'restriction_violation') {
187
+ block(e);
188
+ return { kind: 'reject', feedback: JSON.stringify(e.payload()) };
189
+ }
190
+ }
191
+ return { kind: 'reject', feedback: 'Only the read_file, write_file, web_fetch, web_search and shell tools may be used.' };
192
+ }
@@ -0,0 +1,31 @@
1
+ export type StatusCode = 'assertion_failed' | 'explicit_failure' | 'invalid_format' | 'restriction_violation' | 'llm_error' | 'tool_error' | 'runtime_error';
2
+ export interface FailurePayload {
3
+ status: 'failure';
4
+ reason: string;
5
+ command: string;
6
+ line: number;
7
+ status_code: StatusCode;
8
+ }
9
+ /** A script-level failure. Stops the script unless caught by try/except. */
10
+ export declare class Failure extends Error {
11
+ readonly statusCode: StatusCode;
12
+ reason: string;
13
+ command: string;
14
+ line: number;
15
+ located: boolean;
16
+ constructor(statusCode: StatusCode, reason: string);
17
+ locate(line: number, command: string): this;
18
+ payload(): FailurePayload;
19
+ }
20
+ /** Thrown by `success`: stops the script with a result. */
21
+ export declare class SuccessSignal {
22
+ readonly value: unknown;
23
+ constructor(value: unknown);
24
+ }
25
+ /** Strict syntax error found while parsing; never catchable by scripts. */
26
+ export declare class SplSyntaxError extends Error {
27
+ readonly line: number;
28
+ readonly col: number;
29
+ constructor(message: string, line: number, col: number);
30
+ format(file: string): string;
31
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ /** A script-level failure. Stops the script unless caught by try/except. */
2
+ export class Failure extends Error {
3
+ statusCode;
4
+ reason;
5
+ command = '';
6
+ line = 0;
7
+ located = false;
8
+ constructor(statusCode, reason) {
9
+ super(reason);
10
+ this.name = 'Failure';
11
+ this.statusCode = statusCode;
12
+ this.reason = reason;
13
+ }
14
+ locate(line, command) {
15
+ if (!this.located) {
16
+ this.line = line;
17
+ this.command = command;
18
+ this.located = true;
19
+ }
20
+ return this;
21
+ }
22
+ payload() {
23
+ return {
24
+ status: 'failure',
25
+ reason: this.reason,
26
+ command: this.command,
27
+ line: this.line,
28
+ status_code: this.statusCode,
29
+ };
30
+ }
31
+ }
32
+ /** Thrown by `success`: stops the script with a result. */
33
+ export class SuccessSignal {
34
+ value;
35
+ constructor(value) {
36
+ this.value = value;
37
+ }
38
+ }
39
+ /** Strict syntax error found while parsing; never catchable by scripts. */
40
+ export class SplSyntaxError extends Error {
41
+ line;
42
+ col;
43
+ constructor(message, line, col) {
44
+ super(message);
45
+ this.line = line;
46
+ this.col = col;
47
+ this.name = 'SplSyntaxError';
48
+ }
49
+ format(file) {
50
+ return `${file}:${this.line}:${this.col}: syntax error: ${this.message}`;
51
+ }
52
+ }
@@ -0,0 +1,9 @@
1
+ export { parse } from './parser.js';
2
+ export { run, type RunOptions, type RunResult } from './interpreter.js';
3
+ export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
4
+ export { Logger, type LogLevel } from './logger.js';
5
+ export { CopilotBackend } from './copilot.js';
6
+ export { login, logout, authStatus, AuthError } from './auth.js';
7
+ export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
8
+ export type { LlmBackend, LlmRequest, LlmResponse } from './llm.js';
9
+ export type { Program } from './ast.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { parse } from './parser.js';
2
+ export { run } from './interpreter.js';
3
+ export { Failure, SplSyntaxError } from './errors.js';
4
+ export { Logger } from './logger.js';
5
+ export { CopilotBackend } from './copilot.js';
6
+ export { login, logout, authStatus, AuthError } from './auth.js';
7
+ export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
@@ -0,0 +1,25 @@
1
+ import type { Program } from './ast.js';
2
+ import { type FailurePayload } from './errors.js';
3
+ import { type LlmBackend } from './llm.js';
4
+ import type { Logger } from './logger.js';
5
+ import { type VariableStore } from './store.js';
6
+ import { type Value } from './values.js';
7
+ export interface RunOptions {
8
+ backend: LlmBackend;
9
+ logger: Logger;
10
+ store?: VariableStore;
11
+ /** Working directory for relative paths, `restrict` folders and tools. */
12
+ cwd?: string;
13
+ /** Overrides the `@{model=...}` directive. */
14
+ model?: string;
15
+ maxCallDepth?: number;
16
+ }
17
+ export type RunResult = {
18
+ status: 'success';
19
+ value: Value;
20
+ hasValue: boolean;
21
+ } | {
22
+ status: 'failure';
23
+ failure: FailurePayload;
24
+ };
25
+ export declare function run(program: Program, opts: RunOptions): Promise<RunResult>;