@yolk-sdk/codemode 0.1.0-canary.100

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/catalog.d.mts +74 -0
  4. package/dist/catalog.d.mts.map +1 -0
  5. package/dist/catalog.mjs +164 -0
  6. package/dist/catalog.mjs.map +1 -0
  7. package/dist/classifier-tool.d.mts +78 -0
  8. package/dist/classifier-tool.d.mts.map +1 -0
  9. package/dist/classifier-tool.mjs +125 -0
  10. package/dist/classifier-tool.mjs.map +1 -0
  11. package/dist/executor.d.mts +108 -0
  12. package/dist/executor.d.mts.map +1 -0
  13. package/dist/executor.mjs +1 -0
  14. package/dist/index.d.mts +8 -0
  15. package/dist/index.mjs +7 -0
  16. package/dist/node.d.mts +35 -0
  17. package/dist/node.d.mts.map +1 -0
  18. package/dist/node.mjs +393 -0
  19. package/dist/node.mjs.map +1 -0
  20. package/dist/output.d.mts +42 -0
  21. package/dist/output.d.mts.map +1 -0
  22. package/dist/output.mjs +184 -0
  23. package/dist/output.mjs.map +1 -0
  24. package/dist/search.d.mts +23 -0
  25. package/dist/search.d.mts.map +1 -0
  26. package/dist/search.mjs +58 -0
  27. package/dist/search.mjs.map +1 -0
  28. package/dist/store.d.mts +77 -0
  29. package/dist/store.d.mts.map +1 -0
  30. package/dist/store.mjs +61 -0
  31. package/dist/store.mjs.map +1 -0
  32. package/dist/tool.d.mts +103 -0
  33. package/dist/tool.d.mts.map +1 -0
  34. package/dist/tool.mjs +392 -0
  35. package/dist/tool.mjs.map +1 -0
  36. package/package.json +63 -0
  37. package/src/catalog.ts +305 -0
  38. package/src/classifier-tool.ts +227 -0
  39. package/src/executor.ts +122 -0
  40. package/src/index.ts +87 -0
  41. package/src/node.ts +546 -0
  42. package/src/output.ts +287 -0
  43. package/src/search.ts +113 -0
  44. package/src/store.ts +135 -0
  45. package/src/tool.ts +766 -0
package/src/index.ts ADDED
@@ -0,0 +1,87 @@
1
+ export type {
2
+ CodeModeError,
3
+ CodeModeErrorKind,
4
+ CodeModeExecuteOptions,
5
+ CodeModeExecutionResult,
6
+ CodeModeExecutor,
7
+ CodeModeExecutorGlobal,
8
+ CodeModeExecutorTool,
9
+ CodeModeJsonSchema,
10
+ CodeModeOutputItem,
11
+ CodeModeStore,
12
+ CodeModeStoreWrites,
13
+ CodeModeToolContext
14
+ } from './executor.ts'
15
+
16
+ export {
17
+ codeModeCatalog,
18
+ defaultCodeModeInlineBudget,
19
+ describeCodeModeTool,
20
+ estimateCodeModeTokens,
21
+ findCodeModeTool,
22
+ renderCodeModeDescription,
23
+ selectCodeModeListing
24
+ } from './catalog.ts'
25
+
26
+ export type {
27
+ CodeModeCatalogTool,
28
+ CodeModeDescriptionInput,
29
+ CodeModeListing,
30
+ CodeModeToolExposure
31
+ } from './catalog.ts'
32
+
33
+ export { codeModeSearchTokens, defaultCodeModeSearchLimit, searchCodeModeTools } from './search.ts'
34
+
35
+ export type { CodeModeSearchHit, CodeModeSearchOptions } from './search.ts'
36
+
37
+ export {
38
+ boundCodeModeSegments,
39
+ codeModeResultSegments,
40
+ codeModeSegmentsContent,
41
+ defaultCodeModeMaxImageBytes,
42
+ defaultCodeModeMaxImages,
43
+ summarizeCodeModeCalls
44
+ } from './output.ts'
45
+
46
+ export type { CodeModeCallSummary, CodeModeResultSegment } from './output.ts'
47
+
48
+ export {
49
+ codeModeStoreFromToolResults,
50
+ maxCodeModeStoreTotalChars,
51
+ maxCodeModeStoreValueChars
52
+ } from './store.ts'
53
+
54
+ export type {
55
+ CodeModeInterruptedCall,
56
+ CodeModeInterruptedCalls,
57
+ CodeModeInterruptedCallStatus,
58
+ CodeModeStructuredContent,
59
+ CodeModeToolResultEntry
60
+ } from './store.ts'
61
+
62
+ export {
63
+ codeModeDeadlineMarginMs,
64
+ codeModeMinimumTimeoutMs,
65
+ codeModeToolName,
66
+ defaultCodeModeLimits,
67
+ makeCodeModeTool
68
+ } from './tool.ts'
69
+
70
+ export type {
71
+ CodeModeAfterNestedCallInput,
72
+ CodeModeLimits,
73
+ CodeModeNestedCallOutcome,
74
+ MakeCodeModeToolOptions
75
+ } from './tool.ts'
76
+
77
+ export {
78
+ ClassifierToolParams,
79
+ classifierToolName,
80
+ defaultClassifierProcessLimiter,
81
+ defaultClassifierProcessMaxConcurrency,
82
+ defaultClassifierToolMaxConcurrency,
83
+ makeClassifierConcurrencyLimiter,
84
+ makeClassifierTool
85
+ } from './classifier-tool.ts'
86
+
87
+ export type { ClassifierConcurrencyLimiter, MakeClassifierToolOptions } from './classifier-tool.ts'
package/src/node.ts ADDED
@@ -0,0 +1,546 @@
1
+ import * as nodeModule from 'node:module'
2
+ import { Option, Predicate } from 'effect'
3
+ import * as Schema from 'effect/Schema'
4
+ import {
5
+ CodemodeSandbox,
6
+ type CodemodeResult,
7
+ type CodemodeSandboxOptions,
8
+ type CodemodeTool,
9
+ type CodemodeWasmModule
10
+ } from '@earendil-works/pi-codemode'
11
+ import { codeModeCallLabels } from './catalog.ts'
12
+ import type {
13
+ CodeModeError,
14
+ CodeModeExecuteOptions,
15
+ CodeModeExecutionResult,
16
+ CodeModeExecutor,
17
+ CodeModeExecutorGlobal,
18
+ CodeModeExecutorTool,
19
+ CodeModeStoreWrites
20
+ } from './executor.ts'
21
+
22
+ /** Default cap of concurrent script executions per executor. */
23
+ export const defaultMaxConcurrentExecutions = 4
24
+
25
+ export type PiCodeModeExecutorOptions = {
26
+ /**
27
+ * Compiled `quickjs-wasi/quickjs.wasm` (see pi's `loadQuickJSWasm`). Default: the file of the
28
+ * installed `quickjs-wasi` package, compiled once per process.
29
+ */
30
+ readonly wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>
31
+ /**
32
+ * Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: pi's own worker file,
33
+ * which exists on disk when the package is not bundled (Next `serverExternalPackages`).
34
+ */
35
+ readonly workerUrl?: string | URL
36
+ /** Concurrent executions of this executor; more wait for a free slot. Default 4. */
37
+ readonly maxConcurrentExecutions?: number
38
+ }
39
+
40
+ type SlotOutcome = 'acquired' | 'aborted' | 'timeout'
41
+
42
+ type Waiter = (outcome: SlotOutcome) => void
43
+
44
+ /** Abort- and deadline-aware counting semaphore for executions. */
45
+ const makeExecutionSlots = (max: number) => {
46
+ let active = 0
47
+ const waiting: Array<Waiter> = []
48
+
49
+ const acquire = (signal: AbortSignal, timeoutMs: number): Promise<SlotOutcome> => {
50
+ if (signal.aborted) return Promise.resolve('aborted')
51
+
52
+ if (active < max) {
53
+ active++
54
+
55
+ return Promise.resolve('acquired')
56
+ }
57
+
58
+ return new Promise(resolve => {
59
+ let timer: ReturnType<typeof setTimeout> | undefined
60
+
61
+ const settle: Waiter = outcome => {
62
+ const index = waiting.indexOf(settle)
63
+
64
+ if (index !== -1) waiting.splice(index, 1)
65
+
66
+ if (timer !== undefined) clearTimeout(timer)
67
+ signal.removeEventListener('abort', onAbort)
68
+ resolve(outcome)
69
+ }
70
+
71
+ const onAbort = () => settle('aborted')
72
+
73
+ waiting.push(settle)
74
+ signal.addEventListener('abort', onAbort, { once: true })
75
+
76
+ if (Number.isFinite(timeoutMs)) {
77
+ timer = setTimeout(() => settle('timeout'), Math.max(0, timeoutMs))
78
+ }
79
+ })
80
+ }
81
+
82
+ const release = () => {
83
+ const next = waiting[0]
84
+
85
+ if (next === undefined) {
86
+ active--
87
+ } else {
88
+ // The slot passes straight to the next waiter.
89
+ next('acquired')
90
+ }
91
+ }
92
+
93
+ return { acquire, release }
94
+ }
95
+
96
+ const wrapperPrefix = 'async function __yolkCodeMode() {\n'
97
+
98
+ const wrapperSuffix = '\n}'
99
+
100
+ /** Most values one argument may hold; a larger argument is rejected. */
101
+ const maxArgumentValues = 100_000
102
+
103
+ /** Deepest object/array nesting one argument may have; deeper arguments are rejected. */
104
+ const maxArgumentDepth = 64
105
+
106
+ /**
107
+ * Evaluated in the VM before the script runs: `(tools, labels) => guardedTools`, with `labels` the
108
+ * `[name, callLabel]` pairs of the tools in order (`codeModeCallLabels`). Each call walks its first
109
+ * argument once, the way `JSON.stringify` reads it (each own enumerable string key and each array
110
+ * index read once, so getters run once; a callable `toJSON` is called once with the key, `''` at
111
+ * the root, and its result is walked at the same path), and builds a fresh copy (null-prototype
112
+ * objects and arrays) that is what the call sends. It rejects with a `TypeError` naming the call
113
+ * label and path when JSON would change the value instead of carrying it: non-finite numbers,
114
+ * invalid `Date`s, `undefined`/function/symbol array items and holes, function/symbol/bigint
115
+ * values, cycles, and objects that are neither plain (prototype `Object.prototype` or `null`) nor
116
+ * arrays and have no `toJSON`. An argument with more than `maxArgumentValues` values or nested more
117
+ * than `maxArgumentDepth` levels is rejected too, never sent unchecked. `undefined`-valued keys are
118
+ * left out (absent, as JSON drops them); the same object twice (not a cycle) is copied twice. An
119
+ * error thrown by a getter or `toJSON` rejects the call with that error. A rejected call never
120
+ * reaches the host. Every member of pi's `tools` is wrapped; unknown members fall through to pi's
121
+ * proxy (close-match suggestions). Not a security boundary (`globalThis.tools` is unguarded):
122
+ * arguments are still decoded by the host.
123
+ */
124
+ const toolsGuardSource = `(function (tools, labels) {
125
+ // Captured before the script runs, so a script that changes built-ins cannot change the checks.
126
+ const { apply, getPrototypeOf, setPrototypeOf } = Reflect
127
+ const isFinite = Number.isFinite
128
+ const isArray = Array.isArray
129
+ const { keys, create, defineProperty } = Object
130
+ const getTime = Date.prototype.getTime
131
+ const TypeErrorCtor = TypeError
132
+ const SetCtor = Set
133
+ const setAdd = Set.prototype.add
134
+ const setHas = Set.prototype.has
135
+ const setDelete = Set.prototype.delete
136
+ const ObjectProto = Object.prototype
137
+ const { trunc, min } = Math
138
+ const stringify = JSON.stringify
139
+ const plainJson = 'pass plain JSON (objects, arrays, strings, finite numbers, booleans, null)'
140
+ const maxValues = ${maxArgumentValues}
141
+ const maxDepth = ${maxArgumentDepth}
142
+ class Rejected {
143
+ constructor(message) {
144
+ defineProperty(this, 'message', { value: message })
145
+ }
146
+ }
147
+ const keyPath = (path, key) =>
148
+ /^[A-Za-z_$][\\w$]*$/.test(key)
149
+ ? (path === '' ? key : path + '.' + key)
150
+ : path + '[' + stringify(key) + ']'
151
+ const typeName = value => {
152
+ const proto = getPrototypeOf(value)
153
+ const name = proto && typeof proto.constructor === 'function' ? proto.constructor.name : ''
154
+ return name ? (/^[AEIOU]/.test(name) ? 'an ' : 'a ') + name : 'a non-plain object'
155
+ }
156
+ // The JSON copy of a value already read from its holder at key (undefined: absent), or throws
157
+ // Rejected with why JSON would not carry it unchanged.
158
+ const snapshot = (read, key, path, slot, walk) => {
159
+ const at = path === '' ? 'is ' : 'at ' + path + ' is '
160
+ if (++walk.values > maxValues) {
161
+ throw new Rejected('has more than ' + maxValues + ' values; split the work across calls')
162
+ }
163
+ let value = read
164
+ const kind = typeof value
165
+ if ((kind === 'object' && value !== null) || kind === 'function' || kind === 'bigint') {
166
+ // The intrinsic getTime reads a Date's internal time (and throws for anything else).
167
+ let time
168
+ try {
169
+ time = apply(getTime, value, [])
170
+ } catch {
171
+ time = undefined
172
+ }
173
+ if (time !== undefined && !isFinite(time)) {
174
+ throw new Rejected(at + 'an invalid Date; pass a valid Date or an ISO string')
175
+ }
176
+ const toJSON = value.toJSON
177
+ if (typeof toJSON === 'function') value = apply(toJSON, value, [key])
178
+ }
179
+ switch (typeof value) {
180
+ case 'string':
181
+ case 'boolean':
182
+ return value
183
+ case 'number':
184
+ if (isFinite(value)) return value
185
+ throw new Rejected(
186
+ at + value + '; pass a finite number' + (slot === 'key' ? ' or omit the key' : '')
187
+ )
188
+ case 'undefined':
189
+ if (slot === 'item') throw new Rejected(at + 'undefined; arrays cannot hold undefined')
190
+ return undefined
191
+ case 'bigint':
192
+ case 'function':
193
+ case 'symbol':
194
+ throw new Rejected(at + 'a ' + typeof value + '; ' + plainJson)
195
+ }
196
+ if (value === null) return null
197
+ if (apply(setHas, walk.ancestors, [value])) {
198
+ throw new Rejected(at + 'a circular reference; ' + plainJson)
199
+ }
200
+ if (walk.depth >= maxDepth) {
201
+ throw new Rejected('at ' + path + ' is nested more than ' + maxDepth + ' levels deep')
202
+ }
203
+ const array = isArray(value)
204
+ if (!array) {
205
+ const proto = getPrototypeOf(value)
206
+ if (proto !== ObjectProto && proto !== null) {
207
+ throw new Rejected(at + typeName(value) + '; ' + plainJson)
208
+ }
209
+ }
210
+ apply(setAdd, walk.ancestors, [value])
211
+ walk.depth++
212
+ let copy
213
+ if (array) {
214
+ // LengthOfArrayLike, read once, as JSON.stringify does.
215
+ // Unary plus is ToNumber: one coercion, and it throws for a bigint as JSON.stringify does.
216
+ const raw = +value.length
217
+ const length = raw > 0 ? min(trunc(raw), 9007199254740991) : 0
218
+ // No prototype: no inherited toJSON or index setter can change the checked copy when pi
219
+ // serializes it (still an array to JSON.stringify).
220
+ copy = []
221
+ setPrototypeOf(copy, null)
222
+ for (let i = 0; i < length; i++) {
223
+ copy[i] = snapshot(value[i], '' + i, path + '[' + i + ']', 'item', walk)
224
+ }
225
+ } else {
226
+ copy = create(null)
227
+ // Indexed over the fresh key array: no iterator a script could replace.
228
+ const names = keys(value)
229
+ for (let i = 0; i < names.length; i++) {
230
+ const name = names[i]
231
+ const item = snapshot(value[name], name, keyPath(path, name), 'key', walk)
232
+ if (item !== undefined) copy[name] = item
233
+ }
234
+ }
235
+ walk.depth--
236
+ apply(setDelete, walk.ancestors, [value])
237
+ return copy
238
+ }
239
+ const wrap = (label, call) => async (...args) => {
240
+ let json
241
+ try {
242
+ json = snapshot(args[0], '', '', 'root', { values: 0, depth: 0, ancestors: new SetCtor() })
243
+ } catch (error) {
244
+ if (error instanceof Rejected) throw new TypeErrorCtor(label + ': argument ' + error.message)
245
+ throw error
246
+ }
247
+ return call(json)
248
+ }
249
+ // Each tool's function sits at tools[name] unless an earlier tool holds that key, in which case
250
+ // that earlier function is already wrapped.
251
+ const wrappers = new Map()
252
+ for (const [name, label] of labels) {
253
+ const call = name in tools ? tools[name] : undefined
254
+ if (typeof call === 'function' && !wrappers.has(call)) wrappers.set(call, wrap(label, call))
255
+ }
256
+ const guarded = Object.create(null)
257
+ for (const key of Object.keys(tools)) {
258
+ const call = tools[key]
259
+ if (!wrappers.has(call)) wrappers.set(call, wrap('tools[' + JSON.stringify(key) + ']', call))
260
+ guarded[key] = wrappers.get(call)
261
+ }
262
+ return new Proxy(Object.freeze(guarded), {
263
+ get: (target, key) => (key in target ? target[key] : tools[key])
264
+ })
265
+ })`
266
+
267
+ // pi runs `(async (tools, console) => {<code>\n})`. The script runs in an inner function of the
268
+ // same shape whose `tools` is guarded; the prefix shares line 1 with the script (only line-1
269
+ // columns shift) and the guard follows the script, so its line numbers are unchanged.
270
+ const guardPrefix = 'return (async (tools, console) => {'
271
+
272
+ /** JSON as a JavaScript expression (U+2028/U+2029 escaped for older parsers). */
273
+ const jsonSource = (value: unknown) =>
274
+ JSON.stringify(value).replace(/[\u2028\u2029]/g, char =>
275
+ char === '\u2028' ? '\\u2028' : '\\u2029'
276
+ )
277
+
278
+ const guardSuffix = (tools: ReadonlyArray<CodeModeExecutorTool>) => {
279
+ const names = tools.map(tool => tool.name)
280
+ const labels = codeModeCallLabels(names)
281
+
282
+ // `makeCodeModeTool` supplies each label; other callers get pi's binding rule.
283
+ const pairs = tools.map((tool, index) => [
284
+ tool.name,
285
+ tool.callLabel ?? labels[index] ?? `tools[${JSON.stringify(tool.name)}]`
286
+ ])
287
+
288
+ return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}), console)`
289
+ }
290
+
291
+ const lineTerminators = /\r\n?|[\n\u2028\u2029]/g
292
+
293
+ /** Lines of the script as QuickJS counts them (at least; never fewer). */
294
+ const scriptLineCount = (code: string) => (code.match(lineTerminators)?.length ?? 0) + 1
295
+
296
+ const scriptFrame = /codemode\.js:(\d+):\d+/
297
+
298
+ /** Drops stack frames of the wrapper and guard, which sit below the script's last line. */
299
+ const withoutWrapperFrames = (stack: string, scriptLines: number) =>
300
+ stack
301
+ .split('\n')
302
+ .filter(line => {
303
+ const frame = scriptFrame.exec(line)
304
+
305
+ return frame === null || Number(frame[1]) <= scriptLines
306
+ })
307
+ .join('\n')
308
+
309
+ const ErrorWithCode = Schema.Struct({ code: Schema.String })
310
+
311
+ const errorCode = (error: unknown) =>
312
+ Option.getOrUndefined(Option.map(Schema.decodeUnknownOption(ErrorWithCode)(error), e => e.code))
313
+
314
+ const syntaxLocation = (error: Error) => {
315
+ const match = /^[^\n]*:(\d+)\n([^\n]*)\n([^\n]*)\n/.exec(error.stack ?? '')
316
+
317
+ if (match === null) return ''
318
+
319
+ const line = Number(match[1]) - 1
320
+
321
+ return line >= 1 ? ` (line ${line})\n${match[2] ?? ''}\n${match[3] ?? ''}` : ''
322
+ }
323
+
324
+ const typeStripMessage = (error: unknown) => {
325
+ const message = error instanceof Error ? error.message : String(error)
326
+
327
+ if (errorCode(error) === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
328
+ return `${message}. Write plain JavaScript: type annotations are stripped, but enums, namespaces, and parameter properties are not supported.`
329
+ }
330
+
331
+ const location = error instanceof Error ? syntaxLocation(error) : ''
332
+
333
+ return `The script could not be parsed: ${message}${location}`
334
+ }
335
+
336
+ type Stripped =
337
+ | { readonly ok: true; readonly code: string }
338
+ | { readonly ok: false; readonly error: CodeModeError }
339
+
340
+ /**
341
+ * Strips TypeScript annotations with Node's `stripTypeScriptTypes` (Node 22.13+; whitespace
342
+ * replacement keeps line and column numbers). The script is wrapped in an async function so
343
+ * top-level `return` and `await` parse. On Node without the API, code passes through unchanged.
344
+ */
345
+ const stripTypes = (code: string): Stripped => {
346
+ if (!Predicate.isFunction(nodeModule.stripTypeScriptTypes)) return { ok: true, code }
347
+
348
+ try {
349
+ const stripped = nodeModule.stripTypeScriptTypes(`${wrapperPrefix}${code}${wrapperSuffix}`, {
350
+ mode: 'strip'
351
+ })
352
+
353
+ if (!stripped.startsWith(wrapperPrefix) || !stripped.endsWith(wrapperSuffix)) {
354
+ return { ok: false, error: { kind: 'script', message: 'The script could not be parsed.' } }
355
+ }
356
+
357
+ return {
358
+ ok: true,
359
+ code: stripped.slice(wrapperPrefix.length, stripped.length - wrapperSuffix.length)
360
+ }
361
+ } catch (error) {
362
+ return { ok: false, error: { kind: 'script', message: typeStripMessage(error) } }
363
+ }
364
+ }
365
+
366
+ const piTool = (tool: CodeModeExecutorTool): CodemodeTool => {
367
+ const converted: CodemodeTool = {
368
+ name: tool.name,
369
+ execute: (args, context) => tool.execute(args, context)
370
+ }
371
+
372
+ if (tool.description !== undefined) converted.description = tool.description
373
+
374
+ if (tool.inputSchema !== undefined) converted.inputSchema = tool.inputSchema
375
+
376
+ if (tool.outputSchema !== undefined) converted.outputSchema = tool.outputSchema
377
+
378
+ return converted
379
+ }
380
+
381
+ const piGlobal = (global: CodeModeExecutorGlobal): CodemodeTool => {
382
+ const converted = piTool(global)
383
+
384
+ if (global.spread !== undefined) converted.spread = global.spread
385
+
386
+ if (global.signature !== undefined) converted.signature = global.signature
387
+
388
+ return converted
389
+ }
390
+
391
+ const failure = (error: CodeModeError): CodeModeExecutionResult => ({
392
+ ok: false,
393
+ error,
394
+ output: []
395
+ })
396
+
397
+ const StoreWrites = Schema.Struct({
398
+ set: Schema.Record(Schema.String, Schema.Json),
399
+ delete: Schema.Array(Schema.String)
400
+ })
401
+
402
+ const decodeStoreWrites = Schema.decodeUnknownOption(StoreWrites)
403
+
404
+ type MutableCodeModeError = {
405
+ kind: CodeModeError['kind']
406
+ message: string
407
+ stack?: string
408
+ }
409
+
410
+ type MutableExecutionResult = {
411
+ ok: boolean
412
+ value?: unknown
413
+ error?: CodeModeError
414
+ output: CodeModeExecutionResult['output']
415
+ storeWrites?: CodeModeStoreWrites
416
+ }
417
+
418
+ const fromPiResult = (result: CodemodeResult, scriptLines: number): CodeModeExecutionResult => {
419
+ if (result.ok) {
420
+ const storeWrites = decodeStoreWrites(result.storeWrites)
421
+
422
+ if (Option.isNone(storeWrites)) {
423
+ return failure({
424
+ kind: 'sandbox',
425
+ message: 'The script reported store writes that are not JSON.'
426
+ })
427
+ }
428
+
429
+ const converted: MutableExecutionResult = {
430
+ ok: true,
431
+ output: result.output,
432
+ storeWrites: storeWrites.value
433
+ }
434
+
435
+ if (result.value !== undefined) converted.value = result.value
436
+
437
+ return converted
438
+ }
439
+
440
+ const { kind, name, message, stack } = result.error
441
+ const named = name !== undefined && name.length > 0 && !message.startsWith(name)
442
+
443
+ const error: MutableCodeModeError = {
444
+ kind,
445
+ message: named ? `${name}: ${message}` : message
446
+ }
447
+
448
+ if (stack !== undefined) error.stack = withoutWrapperFrames(stack, scriptLines)
449
+
450
+ return { ok: false, error, output: result.output }
451
+ }
452
+
453
+ const describeError = (error: unknown) => (error instanceof Error ? error.message : String(error))
454
+
455
+ /**
456
+ * A `CodeModeExecutor` on `@earendil-works/pi-codemode`: every execution gets a fresh QuickJS VM
457
+ * (WebAssembly) in a fresh worker thread, closed when the execution ends. Applies the timeout,
458
+ * heap cap, abort signal, and store, strips TypeScript annotations first, rejects tool arguments
459
+ * that JSON would silently change inside the script (see `CodeModeExecutorTool`), and caps concurrent
460
+ * executions per executor; create one executor per process (module scope) so the cap is
461
+ * per process.
462
+ *
463
+ * Node only. The worker inherits a copy of `process.env` (scripts cannot read it: the VM has no
464
+ * `process`). Under Next.js, list `@yolk-sdk/codemode`, `@earendil-works/pi-codemode`, and
465
+ * `quickjs-wasi` in `serverExternalPackages` so the worker file and wasm stay on disk.
466
+ */
467
+ export const makePiCodeModeExecutor = (
468
+ options: PiCodeModeExecutorOptions = {}
469
+ ): CodeModeExecutor => {
470
+ const slots = makeExecutionSlots(
471
+ Math.max(1, Math.floor(options.maxConcurrentExecutions ?? defaultMaxConcurrentExecutions))
472
+ )
473
+
474
+ const run = async (code: string, execution: CodeModeExecuteOptions, queuedAt: number) => {
475
+ const remaining = () => execution.timeoutMs - (Date.now() - queuedAt)
476
+ const stripped = stripTypes(code)
477
+
478
+ if (!stripped.ok) return failure(stripped.error)
479
+
480
+ const timeoutMs = remaining()
481
+
482
+ if (timeoutMs <= 0) {
483
+ return failure({
484
+ kind: 'timeout',
485
+ message: `Execution timed out after ${execution.timeoutMs} ms`
486
+ })
487
+ }
488
+
489
+ const sandboxOptions: CodemodeSandboxOptions = {
490
+ tools: execution.tools.map(piTool),
491
+ globals: execution.globals.map(piGlobal),
492
+ timeoutMs,
493
+ memoryLimitBytes: execution.memoryLimitBytes
494
+ }
495
+
496
+ if (options.wasm !== undefined) sandboxOptions.wasm = options.wasm
497
+
498
+ if (options.workerUrl !== undefined) sandboxOptions.workerUrl = options.workerUrl
499
+
500
+ let sandbox: CodemodeSandbox
501
+
502
+ try {
503
+ sandbox = new CodemodeSandbox(sandboxOptions)
504
+ } catch (error) {
505
+ return failure({
506
+ kind: 'sandbox',
507
+ message: `Could not create the sandbox: ${describeError(error)}`
508
+ })
509
+ }
510
+
511
+ try {
512
+ return fromPiResult(
513
+ await sandbox.execute(`${guardPrefix}${stripped.code}${guardSuffix(execution.tools)}`, {
514
+ signal: execution.signal,
515
+ store: execution.store,
516
+ timeoutMs
517
+ }),
518
+ scriptLineCount(stripped.code)
519
+ )
520
+ } finally {
521
+ await sandbox.close()
522
+ }
523
+ }
524
+
525
+ return {
526
+ execute: async (code, execution) => {
527
+ const queuedAt = Date.now()
528
+ const slot = await slots.acquire(execution.signal, execution.timeoutMs)
529
+
530
+ if (slot === 'aborted') return failure({ kind: 'aborted', message: 'Execution aborted' })
531
+
532
+ if (slot === 'timeout') {
533
+ return failure({
534
+ kind: 'timeout',
535
+ message: `Execution timed out after ${execution.timeoutMs} ms while waiting for a free execution slot`
536
+ })
537
+ }
538
+
539
+ try {
540
+ return await run(code, execution, queuedAt)
541
+ } finally {
542
+ slots.release()
543
+ }
544
+ }
545
+ }
546
+ }