@yolk-sdk/codemode 0.1.0-canary.96

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 +132 -0
  3. package/dist/catalog.d.mts +60 -0
  4. package/dist/catalog.d.mts.map +1 -0
  5. package/dist/catalog.mjs +143 -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 +89 -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 +34 -0
  17. package/dist/node.d.mts.map +1 -0
  18. package/dist/node.mjs +215 -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 +40 -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 +75 -0
  33. package/dist/tool.d.mts.map +1 -0
  34. package/dist/tool.mjs +282 -0
  35. package/dist/tool.mjs.map +1 -0
  36. package/package.json +63 -0
  37. package/src/catalog.ts +274 -0
  38. package/src/classifier-tool.ts +227 -0
  39. package/src/executor.ts +103 -0
  40. package/src/index.ts +76 -0
  41. package/src/node.ts +334 -0
  42. package/src/output.ts +287 -0
  43. package/src/search.ts +113 -0
  44. package/src/store.ts +93 -0
  45. package/src/tool.ts +547 -0
@@ -0,0 +1,103 @@
1
+ import type * as Schema from 'effect/Schema'
2
+ import type { ToolJsonSchema } from '@yolk-sdk/agent/protocol'
3
+
4
+ /** A JSON Schema document. Only shapes declarations; values are never validated against it. */
5
+ export type CodeModeJsonSchema = ToolJsonSchema
6
+
7
+ /** Store contents: small JSON values by key. */
8
+ export type CodeModeStore = Readonly<Record<string, Schema.Json>>
9
+
10
+ export type CodeModeToolContext = {
11
+ /** Aborted when the script ends (unawaited calls), times out, or the execution is aborted. */
12
+ readonly signal: AbortSignal
13
+ }
14
+
15
+ /**
16
+ * A function a script can call. Tools are called as `tools.<identifier>(args)` and
17
+ * `tools["<name>"](args)`; the identifier replaces characters that are not valid in a JavaScript
18
+ * identifier with `_`. Arguments and results make a JSON round trip; a rejection surfaces in the
19
+ * script as an `Error` with the same message.
20
+ */
21
+ export type CodeModeExecutorTool = {
22
+ readonly name: string
23
+ readonly description?: string
24
+ readonly inputSchema?: CodeModeJsonSchema
25
+ readonly outputSchema?: CodeModeJsonSchema
26
+ readonly execute: (args: unknown, context: CodeModeToolContext) => Promise<unknown>
27
+ }
28
+
29
+ /**
30
+ * A top-level function (`name`) or namespace member (`namespace.member`) for scripts. Globals are
31
+ * host helpers: they are not tools and are not recorded as nested calls.
32
+ */
33
+ export type CodeModeExecutorGlobal = CodeModeExecutorTool & {
34
+ /** `execute` receives every call argument as an array instead of the first one. */
35
+ readonly spread?: boolean
36
+ /** TypeScript parameter list and return type for declarations. */
37
+ readonly signature?: string
38
+ }
39
+
40
+ /** One item of script output, in the order the script produced it. `data` is base64. */
41
+ export type CodeModeOutputItem =
42
+ | { readonly type: 'text'; readonly text: string }
43
+ | { readonly type: 'image'; readonly data: string; readonly mimeType: string }
44
+
45
+ /**
46
+ * - `script`: the script threw, failed to parse, or its TypeScript could not be stripped.
47
+ * - `timeout`: the deadline expired.
48
+ * - `aborted`: the caller's signal fired.
49
+ * - `sandbox`: the engine failed outside the script's control.
50
+ */
51
+ export type CodeModeErrorKind = 'script' | 'timeout' | 'aborted' | 'sandbox'
52
+
53
+ export type CodeModeError = {
54
+ readonly kind: CodeModeErrorKind
55
+ readonly message: string
56
+ readonly stack?: string
57
+ }
58
+
59
+ /** Keys a successful script changed with `store()`; `delete` lists keys stored as `undefined`. */
60
+ export type CodeModeStoreWrites = {
61
+ readonly set: CodeModeStore
62
+ readonly delete: ReadonlyArray<string>
63
+ }
64
+
65
+ export type CodeModeExecutionResult = {
66
+ readonly ok: boolean
67
+ /** The script's return value (JSON); absent when it returned nothing. */
68
+ readonly value?: unknown
69
+ readonly error?: CodeModeError
70
+ /** Kept for failed executions too, up to the failure. */
71
+ readonly output: ReadonlyArray<CodeModeOutputItem>
72
+ /** Only successful executions report writes. */
73
+ readonly storeWrites?: CodeModeStoreWrites
74
+ }
75
+
76
+ export type CodeModeExecuteOptions = {
77
+ readonly tools: ReadonlyArray<CodeModeExecutorTool>
78
+ readonly globals: ReadonlyArray<CodeModeExecutorGlobal>
79
+ /** Overall deadline, including time spent in tools and waiting for a free execution slot. */
80
+ readonly timeoutMs: number
81
+ /** Heap cap of the script's VM. */
82
+ readonly memoryLimitBytes: number
83
+ /** Values the script reads with `load(key)`. */
84
+ readonly store: CodeModeStore
85
+ readonly signal: AbortSignal
86
+ }
87
+
88
+ /**
89
+ * Runs one script. `code` is the body of an async function (top-level `await` and `return`) and
90
+ * may carry TypeScript annotations: executors strip them or report a `script` error. The promise
91
+ * resolves for every script outcome, including failures, timeouts, and aborts; a rejection is
92
+ * treated as a `sandbox` failure. Running tool calls must be cancelled through their signal when
93
+ * the script ends.
94
+ *
95
+ * `@yolk-sdk/codemode/node` provides `makePiCodeModeExecutor`; hosts on other runtimes supply their
96
+ * own engine behind this interface.
97
+ */
98
+ export type CodeModeExecutor = {
99
+ readonly execute: (
100
+ code: string,
101
+ options: CodeModeExecuteOptions
102
+ ) => Promise<CodeModeExecutionResult>
103
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
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 { CodeModeStructuredContent, CodeModeToolResultEntry } from './store.ts'
55
+
56
+ export {
57
+ codeModeDeadlineMarginMs,
58
+ codeModeMinimumTimeoutMs,
59
+ codeModeToolName,
60
+ defaultCodeModeLimits,
61
+ makeCodeModeTool
62
+ } from './tool.ts'
63
+
64
+ export type { CodeModeLimits, MakeCodeModeToolOptions } from './tool.ts'
65
+
66
+ export {
67
+ ClassifierToolParams,
68
+ classifierToolName,
69
+ defaultClassifierProcessLimiter,
70
+ defaultClassifierProcessMaxConcurrency,
71
+ defaultClassifierToolMaxConcurrency,
72
+ makeClassifierConcurrencyLimiter,
73
+ makeClassifierTool
74
+ } from './classifier-tool.ts'
75
+
76
+ export type { ClassifierConcurrencyLimiter, MakeClassifierToolOptions } from './classifier-tool.ts'
package/src/node.ts ADDED
@@ -0,0 +1,334 @@
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 type {
12
+ CodeModeError,
13
+ CodeModeExecuteOptions,
14
+ CodeModeExecutionResult,
15
+ CodeModeExecutor,
16
+ CodeModeExecutorGlobal,
17
+ CodeModeExecutorTool,
18
+ CodeModeStoreWrites
19
+ } from './executor.ts'
20
+
21
+ /** Default cap of concurrent script executions per executor. */
22
+ export const defaultMaxConcurrentExecutions = 4
23
+
24
+ export type PiCodeModeExecutorOptions = {
25
+ /**
26
+ * Compiled `quickjs-wasi/quickjs.wasm` (see pi's `loadQuickJSWasm`). Default: the file of the
27
+ * installed `quickjs-wasi` package, compiled once per process.
28
+ */
29
+ readonly wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>
30
+ /**
31
+ * Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: pi's own worker file,
32
+ * which exists on disk when the package is not bundled (Next `serverExternalPackages`).
33
+ */
34
+ readonly workerUrl?: string | URL
35
+ /** Concurrent executions of this executor; more wait for a free slot. Default 4. */
36
+ readonly maxConcurrentExecutions?: number
37
+ }
38
+
39
+ type SlotOutcome = 'acquired' | 'aborted' | 'timeout'
40
+
41
+ type Waiter = (outcome: SlotOutcome) => void
42
+
43
+ /** Abort- and deadline-aware counting semaphore for executions. */
44
+ const makeExecutionSlots = (max: number) => {
45
+ let active = 0
46
+ const waiting: Array<Waiter> = []
47
+
48
+ const acquire = (signal: AbortSignal, timeoutMs: number): Promise<SlotOutcome> => {
49
+ if (signal.aborted) return Promise.resolve('aborted')
50
+
51
+ if (active < max) {
52
+ active++
53
+
54
+ return Promise.resolve('acquired')
55
+ }
56
+
57
+ return new Promise(resolve => {
58
+ let timer: ReturnType<typeof setTimeout> | undefined
59
+
60
+ const settle: Waiter = outcome => {
61
+ const index = waiting.indexOf(settle)
62
+
63
+ if (index !== -1) waiting.splice(index, 1)
64
+
65
+ if (timer !== undefined) clearTimeout(timer)
66
+ signal.removeEventListener('abort', onAbort)
67
+ resolve(outcome)
68
+ }
69
+
70
+ const onAbort = () => settle('aborted')
71
+
72
+ waiting.push(settle)
73
+ signal.addEventListener('abort', onAbort, { once: true })
74
+
75
+ if (Number.isFinite(timeoutMs)) {
76
+ timer = setTimeout(() => settle('timeout'), Math.max(0, timeoutMs))
77
+ }
78
+ })
79
+ }
80
+
81
+ const release = () => {
82
+ const next = waiting[0]
83
+
84
+ if (next === undefined) {
85
+ active--
86
+ } else {
87
+ // The slot passes straight to the next waiter.
88
+ next('acquired')
89
+ }
90
+ }
91
+
92
+ return { acquire, release }
93
+ }
94
+
95
+ const wrapperPrefix = 'async function __yolkCodeMode() {\n'
96
+
97
+ const wrapperSuffix = '\n}'
98
+
99
+ const ErrorWithCode = Schema.Struct({ code: Schema.String })
100
+
101
+ const errorCode = (error: unknown) =>
102
+ Option.getOrUndefined(Option.map(Schema.decodeUnknownOption(ErrorWithCode)(error), e => e.code))
103
+
104
+ const syntaxLocation = (error: Error) => {
105
+ const match = /^[^\n]*:(\d+)\n([^\n]*)\n([^\n]*)\n/.exec(error.stack ?? '')
106
+
107
+ if (match === null) return ''
108
+
109
+ const line = Number(match[1]) - 1
110
+
111
+ return line >= 1 ? ` (line ${line})\n${match[2] ?? ''}\n${match[3] ?? ''}` : ''
112
+ }
113
+
114
+ const typeStripMessage = (error: unknown) => {
115
+ const message = error instanceof Error ? error.message : String(error)
116
+
117
+ if (errorCode(error) === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
118
+ return `${message}. Write plain JavaScript: type annotations are stripped, but enums, namespaces, and parameter properties are not supported.`
119
+ }
120
+
121
+ const location = error instanceof Error ? syntaxLocation(error) : ''
122
+
123
+ return `The script could not be parsed: ${message}${location}`
124
+ }
125
+
126
+ type Stripped =
127
+ | { readonly ok: true; readonly code: string }
128
+ | { readonly ok: false; readonly error: CodeModeError }
129
+
130
+ /**
131
+ * Strips TypeScript annotations with Node's `stripTypeScriptTypes` (Node 22.13+; whitespace
132
+ * replacement keeps line and column numbers). The script is wrapped in an async function so
133
+ * top-level `return` and `await` parse. On Node without the API, code passes through unchanged.
134
+ */
135
+ const stripTypes = (code: string): Stripped => {
136
+ if (!Predicate.isFunction(nodeModule.stripTypeScriptTypes)) return { ok: true, code }
137
+
138
+ try {
139
+ const stripped = nodeModule.stripTypeScriptTypes(`${wrapperPrefix}${code}${wrapperSuffix}`, {
140
+ mode: 'strip'
141
+ })
142
+
143
+ if (!stripped.startsWith(wrapperPrefix) || !stripped.endsWith(wrapperSuffix)) {
144
+ return { ok: false, error: { kind: 'script', message: 'The script could not be parsed.' } }
145
+ }
146
+
147
+ return {
148
+ ok: true,
149
+ code: stripped.slice(wrapperPrefix.length, stripped.length - wrapperSuffix.length)
150
+ }
151
+ } catch (error) {
152
+ return { ok: false, error: { kind: 'script', message: typeStripMessage(error) } }
153
+ }
154
+ }
155
+
156
+ const piTool = (tool: CodeModeExecutorTool): CodemodeTool => {
157
+ const converted: CodemodeTool = {
158
+ name: tool.name,
159
+ execute: (args, context) => tool.execute(args, context)
160
+ }
161
+
162
+ if (tool.description !== undefined) converted.description = tool.description
163
+
164
+ if (tool.inputSchema !== undefined) converted.inputSchema = tool.inputSchema
165
+
166
+ if (tool.outputSchema !== undefined) converted.outputSchema = tool.outputSchema
167
+
168
+ return converted
169
+ }
170
+
171
+ const piGlobal = (global: CodeModeExecutorGlobal): CodemodeTool => {
172
+ const converted = piTool(global)
173
+
174
+ if (global.spread !== undefined) converted.spread = global.spread
175
+
176
+ if (global.signature !== undefined) converted.signature = global.signature
177
+
178
+ return converted
179
+ }
180
+
181
+ const failure = (error: CodeModeError): CodeModeExecutionResult => ({
182
+ ok: false,
183
+ error,
184
+ output: []
185
+ })
186
+
187
+ const StoreWrites = Schema.Struct({
188
+ set: Schema.Record(Schema.String, Schema.Json),
189
+ delete: Schema.Array(Schema.String)
190
+ })
191
+
192
+ const decodeStoreWrites = Schema.decodeUnknownOption(StoreWrites)
193
+
194
+ type MutableCodeModeError = {
195
+ kind: CodeModeError['kind']
196
+ message: string
197
+ stack?: string
198
+ }
199
+
200
+ type MutableExecutionResult = {
201
+ ok: boolean
202
+ value?: unknown
203
+ error?: CodeModeError
204
+ output: CodeModeExecutionResult['output']
205
+ storeWrites?: CodeModeStoreWrites
206
+ }
207
+
208
+ const fromPiResult = (result: CodemodeResult): CodeModeExecutionResult => {
209
+ if (result.ok) {
210
+ const storeWrites = decodeStoreWrites(result.storeWrites)
211
+
212
+ if (Option.isNone(storeWrites)) {
213
+ return failure({
214
+ kind: 'sandbox',
215
+ message: 'The script reported store writes that are not JSON.'
216
+ })
217
+ }
218
+
219
+ const converted: MutableExecutionResult = {
220
+ ok: true,
221
+ output: result.output,
222
+ storeWrites: storeWrites.value
223
+ }
224
+
225
+ if (result.value !== undefined) converted.value = result.value
226
+
227
+ return converted
228
+ }
229
+
230
+ const { kind, name, message, stack } = result.error
231
+ const named = name !== undefined && name.length > 0 && !message.startsWith(name)
232
+
233
+ const error: MutableCodeModeError = {
234
+ kind,
235
+ message: named ? `${name}: ${message}` : message
236
+ }
237
+
238
+ if (stack !== undefined) error.stack = stack
239
+
240
+ return { ok: false, error, output: result.output }
241
+ }
242
+
243
+ const describeError = (error: unknown) => (error instanceof Error ? error.message : String(error))
244
+
245
+ /**
246
+ * A `CodeModeExecutor` on `@earendil-works/pi-codemode`: every execution gets a fresh QuickJS VM
247
+ * (WebAssembly) in a fresh worker thread, closed when the execution ends. Applies the timeout,
248
+ * heap cap, abort signal, and store, strips TypeScript annotations first, and caps concurrent
249
+ * executions per executor; create one executor per process (module scope) so the cap is
250
+ * per process.
251
+ *
252
+ * Node only. The worker inherits a copy of `process.env` (scripts cannot read it: the VM has no
253
+ * `process`). Under Next.js, list `@yolk-sdk/codemode`, `@earendil-works/pi-codemode`, and
254
+ * `quickjs-wasi` in `serverExternalPackages` so the worker file and wasm stay on disk.
255
+ */
256
+ export const makePiCodeModeExecutor = (
257
+ options: PiCodeModeExecutorOptions = {}
258
+ ): CodeModeExecutor => {
259
+ const slots = makeExecutionSlots(
260
+ Math.max(1, Math.floor(options.maxConcurrentExecutions ?? defaultMaxConcurrentExecutions))
261
+ )
262
+
263
+ const run = async (code: string, execution: CodeModeExecuteOptions, queuedAt: number) => {
264
+ const remaining = () => execution.timeoutMs - (Date.now() - queuedAt)
265
+ const stripped = stripTypes(code)
266
+
267
+ if (!stripped.ok) return failure(stripped.error)
268
+
269
+ const timeoutMs = remaining()
270
+
271
+ if (timeoutMs <= 0) {
272
+ return failure({
273
+ kind: 'timeout',
274
+ message: `Execution timed out after ${execution.timeoutMs} ms`
275
+ })
276
+ }
277
+
278
+ const sandboxOptions: CodemodeSandboxOptions = {
279
+ tools: execution.tools.map(piTool),
280
+ globals: execution.globals.map(piGlobal),
281
+ timeoutMs,
282
+ memoryLimitBytes: execution.memoryLimitBytes
283
+ }
284
+
285
+ if (options.wasm !== undefined) sandboxOptions.wasm = options.wasm
286
+
287
+ if (options.workerUrl !== undefined) sandboxOptions.workerUrl = options.workerUrl
288
+
289
+ let sandbox: CodemodeSandbox
290
+
291
+ try {
292
+ sandbox = new CodemodeSandbox(sandboxOptions)
293
+ } catch (error) {
294
+ return failure({
295
+ kind: 'sandbox',
296
+ message: `Could not create the sandbox: ${describeError(error)}`
297
+ })
298
+ }
299
+
300
+ try {
301
+ return fromPiResult(
302
+ await sandbox.execute(stripped.code, {
303
+ signal: execution.signal,
304
+ store: execution.store,
305
+ timeoutMs
306
+ })
307
+ )
308
+ } finally {
309
+ await sandbox.close()
310
+ }
311
+ }
312
+
313
+ return {
314
+ execute: async (code, execution) => {
315
+ const queuedAt = Date.now()
316
+ const slot = await slots.acquire(execution.signal, execution.timeoutMs)
317
+
318
+ if (slot === 'aborted') return failure({ kind: 'aborted', message: 'Execution aborted' })
319
+
320
+ if (slot === 'timeout') {
321
+ return failure({
322
+ kind: 'timeout',
323
+ message: `Execution timed out after ${execution.timeoutMs} ms while waiting for a free execution slot`
324
+ })
325
+ }
326
+
327
+ try {
328
+ return await run(code, execution, queuedAt)
329
+ } finally {
330
+ slots.release()
331
+ }
332
+ }
333
+ }
334
+ }