@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
package/src/output.ts ADDED
@@ -0,0 +1,287 @@
1
+ import {
2
+ ImagePart,
3
+ inlineBase64AttachmentSource,
4
+ TextPart,
5
+ type Content,
6
+ type NestedToolCallStatus
7
+ } from '@yolk-sdk/agent/protocol'
8
+ import type { CodeModeExecutionResult, CodeModeOutputItem } from './executor.ts'
9
+
10
+ /** Text or image piece of the model-visible result, in order. */
11
+ export type CodeModeResultSegment = CodeModeOutputItem
12
+
13
+ const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff
14
+
15
+ const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff
16
+
17
+ // Cut points never split a surrogate pair.
18
+ const headCut = (text: string, length: number) =>
19
+ length > 0 && length < text.length && isHighSurrogate(text.charCodeAt(length - 1))
20
+ ? length - 1
21
+ : length
22
+
23
+ const tailCut = (text: string, start: number) =>
24
+ start > 0 && start < text.length && isLowSurrogate(text.charCodeAt(start)) ? start + 1 : start
25
+
26
+ const textLength = (segments: ReadonlyArray<CodeModeResultSegment>) =>
27
+ segments.reduce(
28
+ (total, segment) => total + (segment.type === 'text' ? segment.text.length : 0),
29
+ 0
30
+ )
31
+
32
+ const omissionMarker = (characters: number, images: number) =>
33
+ `\n\n[… ${characters} characters${images > 0 ? ` and ${images} image${images === 1 ? '' : 's'}` : ''} omitted …]\n\n`
34
+
35
+ const markerReserve = 80
36
+
37
+ /**
38
+ * Keeps at most about `maxChars` characters of text: the head and the tail of the segments, with an
39
+ * omission marker in between. Images inside the omitted middle are dropped and counted.
40
+ */
41
+ export const boundCodeModeSegments = (
42
+ segments: ReadonlyArray<CodeModeResultSegment>,
43
+ maxChars: number
44
+ ): ReadonlyArray<CodeModeResultSegment> => {
45
+ const total = textLength(segments)
46
+
47
+ if (total <= maxChars) return segments
48
+
49
+ const budget = Math.max(0, maxChars - markerReserve)
50
+ let headBudget = Math.ceil(budget / 2)
51
+ let tailBudget = budget - headBudget
52
+
53
+ const head: Array<CodeModeResultSegment> = []
54
+ let headIndex = 0
55
+ let headPartial = 0
56
+
57
+ for (; headIndex < segments.length && headBudget > 0; headIndex++) {
58
+ const segment = segments[headIndex]
59
+
60
+ if (segment === undefined) break
61
+
62
+ if (segment.type === 'image') {
63
+ head.push(segment)
64
+ continue
65
+ }
66
+
67
+ if (segment.text.length <= headBudget) {
68
+ head.push(segment)
69
+ headBudget -= segment.text.length
70
+ continue
71
+ }
72
+
73
+ headPartial = headCut(segment.text, headBudget)
74
+ head.push({ type: 'text', text: segment.text.slice(0, headPartial) })
75
+ headBudget = 0
76
+ break
77
+ }
78
+
79
+ const tail: Array<CodeModeResultSegment> = []
80
+ let tailIndex = segments.length - 1
81
+ let tailStart: number | undefined
82
+
83
+ for (; tailIndex >= headIndex && tailBudget > 0; tailIndex--) {
84
+ const segment = segments[tailIndex]
85
+
86
+ if (segment === undefined) break
87
+
88
+ if (segment.type === 'image') {
89
+ if (tailIndex === headIndex) break
90
+ tail.unshift(segment)
91
+ continue
92
+ }
93
+
94
+ const available =
95
+ tailIndex === headIndex ? segment.text.length - headPartial : segment.text.length
96
+
97
+ if (available <= tailBudget && tailIndex !== headIndex) {
98
+ tail.unshift(segment)
99
+ tailBudget -= available
100
+ continue
101
+ }
102
+
103
+ tailStart = tailCut(segment.text, segment.text.length - Math.min(available, tailBudget))
104
+ tail.unshift({ type: 'text', text: segment.text.slice(tailStart) })
105
+ tailBudget = 0
106
+ break
107
+ }
108
+
109
+ const keptText = textLength(head) + textLength(tail)
110
+ const keptImages = [...head, ...tail].filter(segment => segment.type === 'image').length
111
+ const allImages = segments.filter(segment => segment.type === 'image').length
112
+
113
+ return [
114
+ ...head,
115
+ { type: 'text', text: omissionMarker(total - keptText, allImages - keptImages) },
116
+ ...tail
117
+ ]
118
+ }
119
+
120
+ const mergeText = (segments: ReadonlyArray<CodeModeResultSegment>) =>
121
+ segments.reduce<Array<CodeModeResultSegment>>((merged, segment) => {
122
+ const last = merged.at(-1)
123
+
124
+ if (segment.type === 'text' && last?.type === 'text') {
125
+ merged[merged.length - 1] = { type: 'text', text: last.text + segment.text }
126
+ } else if (segment.type === 'image' || segment.text.length > 0) {
127
+ merged.push(segment)
128
+ }
129
+
130
+ return merged
131
+ }, [])
132
+
133
+ /** Agent `Content`: a plain string without images, ordered text and image parts otherwise. */
134
+ export const codeModeSegmentsContent = (
135
+ segments: ReadonlyArray<CodeModeResultSegment>
136
+ ): Content => {
137
+ const merged = mergeText(segments)
138
+
139
+ if (merged.every(segment => segment.type === 'text')) {
140
+ return merged.map(segment => (segment.type === 'text' ? segment.text : '')).join('')
141
+ }
142
+
143
+ return merged.map(segment =>
144
+ segment.type === 'text'
145
+ ? TextPart.make({ text: segment.text })
146
+ : ImagePart.make({
147
+ source: inlineBase64AttachmentSource(segment.data),
148
+ mimeType: segment.mimeType
149
+ })
150
+ )
151
+ }
152
+
153
+ export type CodeModeCallSummary = {
154
+ readonly name: string
155
+ readonly status: NestedToolCallStatus
156
+ }
157
+
158
+ const statusOrder: ReadonlyArray<NestedToolCallStatus> = ['ok', 'error', 'cancelled']
159
+
160
+ /** `name: 2 ok, 1 error; other: 1 cancelled`, by first appearance. */
161
+ export const summarizeCodeModeCalls = (calls: ReadonlyArray<CodeModeCallSummary>): string => {
162
+ if (calls.length === 0) return 'none'
163
+
164
+ const byName = new Map<string, Map<NestedToolCallStatus, number>>()
165
+
166
+ for (const call of calls) {
167
+ const counts = byName.get(call.name) ?? new Map<NestedToolCallStatus, number>()
168
+
169
+ counts.set(call.status, (counts.get(call.status) ?? 0) + 1)
170
+ byName.set(call.name, counts)
171
+ }
172
+
173
+ return [...byName]
174
+ .map(
175
+ ([name, counts]) =>
176
+ `${name}: ${statusOrder
177
+ .flatMap(status => {
178
+ const count = counts.get(status)
179
+
180
+ return count === undefined ? [] : [`${count} ${status}`]
181
+ })
182
+ .join(', ')}`
183
+ )
184
+ .join('; ')
185
+ }
186
+
187
+ const stackFrames = (stack: string | undefined) =>
188
+ stack === undefined
189
+ ? []
190
+ : stack.split('\n').filter(line => /^\s+at\s/.test(line) || /^\s*\S+:\d+/.test(line))
191
+
192
+ const encodeValue = (value: unknown): string => {
193
+ try {
194
+ return JSON.stringify(value) ?? 'undefined'
195
+ } catch {
196
+ return '[unserializable value]'
197
+ }
198
+ }
199
+
200
+ /** Default cap of images kept in one result. */
201
+ export const defaultCodeModeMaxImages = 8
202
+
203
+ /** Default cap of base64 image data kept in one result, in characters (4 MiB). */
204
+ export const defaultCodeModeMaxImageBytes = 4 * 1024 * 1024
205
+
206
+ const imageOmissionNote = (dropped: number, maxImages: number, maxImageBytes: number) =>
207
+ `\n\n[… ${dropped} image${dropped === 1 ? '' : 's'} omitted: a result keeps at most ${maxImages} images and ${maxImageBytes} characters of base64 image data …]`
208
+
209
+ /**
210
+ * Model-visible segments of one execution: a `Script completed` / `Script failed` header with the
211
+ * wall time, the output in order, the JSON return value (omitted when undefined), and for failures
212
+ * the error and the tool calls already made. Images are kept in order while they fit `maxImages`
213
+ * (default 8) and `maxImageBytes` of base64 in total (default 4 MiB); an image that would exceed
214
+ * either is dropped and counted in an omission note after the output. The result is then bounded
215
+ * by `maxChars` with a head-and-tail cut, which also drops images in the omitted middle.
216
+ */
217
+ export const codeModeResultSegments = (input: {
218
+ readonly result: CodeModeExecutionResult
219
+ readonly wallTimeMs: number
220
+ readonly calls: ReadonlyArray<CodeModeCallSummary>
221
+ readonly maxChars: number
222
+ readonly maxImages?: number
223
+ readonly maxImageBytes?: number
224
+ }): ReadonlyArray<CodeModeResultSegment> => {
225
+ const { result } = input
226
+ const maxImages = Math.max(0, Math.floor(input.maxImages ?? defaultCodeModeMaxImages))
227
+ const maxImageBytes = Math.max(0, Math.floor(input.maxImageBytes ?? defaultCodeModeMaxImageBytes))
228
+ const ms = Math.round(input.wallTimeMs)
229
+ const segments: Array<CodeModeResultSegment> = []
230
+
231
+ const text = (value: string) => {
232
+ segments.push({ type: 'text', text: value })
233
+ }
234
+
235
+ text(result.ok ? `Script completed in ${ms} ms.` : `Script failed after ${ms} ms.`)
236
+
237
+ if (result.output.length > 0) {
238
+ text('\n\nOutput:\n')
239
+
240
+ let keptImages = 0
241
+ let keptImageBytes = 0
242
+ let droppedImages = 0
243
+
244
+ result.output.forEach((item, index) => {
245
+ const previous = result.output[index - 1]
246
+
247
+ if (item.type === 'text') {
248
+ text(previous?.type === 'text' ? `\n${item.text}` : item.text)
249
+ } else if (keptImages < maxImages && keptImageBytes + item.data.length <= maxImageBytes) {
250
+ keptImages++
251
+ keptImageBytes += item.data.length
252
+ segments.push(item)
253
+ } else {
254
+ droppedImages++
255
+ }
256
+ })
257
+
258
+ if (droppedImages > 0) text(imageOmissionNote(droppedImages, maxImages, maxImageBytes))
259
+ }
260
+
261
+ if (result.ok && result.value !== undefined) {
262
+ text(`\n\nReturn value:\n${encodeValue(result.value)}`)
263
+ }
264
+
265
+ if (!result.ok) {
266
+ const error = result.error ?? {
267
+ kind: 'sandbox',
268
+ message: 'The script failed without an error.'
269
+ }
270
+
271
+ text(`\n\nScript error: ${error.kind}: ${error.message}`)
272
+
273
+ const frames = stackFrames(error.stack)
274
+
275
+ if (frames.length > 0) text(`\n${frames.join('\n')}`)
276
+
277
+ text(
278
+ `\n\nTool calls made before the failure (they are not undone): ${summarizeCodeModeCalls(input.calls)}.`
279
+ )
280
+ }
281
+
282
+ if (result.ok && result.output.length === 0 && result.value === undefined) {
283
+ text(' No output and no return value.')
284
+ }
285
+
286
+ return boundCodeModeSegments(mergeText(segments), input.maxChars)
287
+ }
package/src/search.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { CodeModeCatalogTool } from './catalog.ts'
2
+
3
+ /** Default number of `searchTools` results. */
4
+ export const defaultCodeModeSearchLimit = 8
5
+
6
+ const k1 = 1.2
7
+
8
+ const b = 0.75
9
+
10
+ /** Lowercase word tokens; splits on non-alphanumerics and camelCase boundaries. */
11
+ export const codeModeSearchTokens = (text: string): ReadonlyArray<string> =>
12
+ text
13
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
14
+ .toLowerCase()
15
+ .split(/[^a-z0-9]+/)
16
+ .filter(token => token.length > 0)
17
+
18
+ type IndexedTool = {
19
+ readonly tool: CodeModeCatalogTool
20
+ readonly frequencies: ReadonlyMap<string, number>
21
+ readonly length: number
22
+ }
23
+
24
+ const indexTool = (tool: CodeModeCatalogTool): IndexedTool => {
25
+ const tokens = codeModeSearchTokens(
26
+ [
27
+ tool.identifier,
28
+ tool.name,
29
+ tool.description,
30
+ tool.namespace,
31
+ tool.namespaceDescription ?? ''
32
+ ].join(' ')
33
+ )
34
+
35
+ const frequencies = new Map<string, number>()
36
+
37
+ for (const token of tokens) {
38
+ frequencies.set(token, (frequencies.get(token) ?? 0) + 1)
39
+ }
40
+
41
+ return { tool, frequencies, length: tokens.length }
42
+ }
43
+
44
+ export type CodeModeSearchOptions = {
45
+ readonly limit?: number
46
+ readonly namespace?: string
47
+ }
48
+
49
+ export type CodeModeSearchHit = {
50
+ /** The identifier scripts call as `tools.<name>(args)`. */
51
+ readonly name: string
52
+ readonly description: string
53
+ }
54
+
55
+ /**
56
+ * BM25 over each tool's identifier, raw name, description, namespace, and namespace description. Ties keep catalog order.
57
+ * A query without words lists the (namespace-filtered) tools in catalog order.
58
+ */
59
+ export const searchCodeModeTools = (
60
+ catalog: ReadonlyArray<CodeModeCatalogTool>,
61
+ query: string,
62
+ options: CodeModeSearchOptions = {}
63
+ ): ReadonlyArray<CodeModeSearchHit> => {
64
+ const limit = Math.max(0, Math.floor(options.limit ?? defaultCodeModeSearchLimit))
65
+
66
+ const scoped =
67
+ options.namespace === undefined
68
+ ? catalog
69
+ : catalog.filter(tool => tool.namespace === options.namespace)
70
+
71
+ const documents = scoped.map(indexTool)
72
+ const terms = [...new Set(codeModeSearchTokens(query))]
73
+
74
+ const hit = (tool: CodeModeCatalogTool): CodeModeSearchHit => ({
75
+ name: tool.identifier,
76
+ description: tool.description
77
+ })
78
+
79
+ if (terms.length === 0) {
80
+ return documents.slice(0, limit).map(document => hit(document.tool))
81
+ }
82
+
83
+ const averageLength =
84
+ documents.reduce((total, document) => total + document.length, 0) /
85
+ Math.max(1, documents.length)
86
+
87
+ const inverse = new Map(
88
+ terms.map((term): readonly [string, number] => {
89
+ const containing = documents.filter(document => document.frequencies.has(term)).length
90
+
91
+ return [term, Math.log(1 + (documents.length - containing + 0.5) / (containing + 0.5))]
92
+ })
93
+ )
94
+
95
+ return documents
96
+ .map((document, index) => {
97
+ const score = terms.reduce((total, term) => {
98
+ const frequency = document.frequencies.get(term) ?? 0
99
+
100
+ if (frequency === 0) return total
101
+
102
+ const norm = k1 * (1 - b + (b * document.length) / Math.max(1, averageLength))
103
+
104
+ return total + ((inverse.get(term) ?? 0) * frequency * (k1 + 1)) / (frequency + norm)
105
+ }, 0)
106
+
107
+ return { document, index, score }
108
+ })
109
+ .filter(entry => entry.score > 0)
110
+ .sort((left, right) => right.score - left.score || left.index - right.index)
111
+ .slice(0, limit)
112
+ .map(entry => hit(entry.document.tool))
113
+ }
package/src/store.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { Option } from 'effect'
2
+ import * as Schema from 'effect/Schema'
3
+ import type { CodeModeStore, CodeModeStoreWrites } from './executor.ts'
4
+ import { codeModeToolName } from './tool.ts'
5
+
6
+ /** `structuredContent` of a code mode `ToolResult`. `storeWrites` is present only for successful
7
+ * scripts that changed the store.
8
+ */
9
+ export type CodeModeStructuredContent = {
10
+ readonly codemode: {
11
+ readonly ok: boolean
12
+ readonly storeWrites?: CodeModeStoreWrites
13
+ }
14
+ }
15
+
16
+ const StoreWritesSchema = Schema.Struct({
17
+ set: Schema.Record(Schema.String, Schema.Json),
18
+ delete: Schema.Array(Schema.String)
19
+ })
20
+
21
+ const CodeModeStructuredContentSchema = Schema.Struct({
22
+ codemode: Schema.Struct({
23
+ ok: Schema.Boolean,
24
+ storeWrites: Schema.optionalKey(StoreWritesSchema)
25
+ })
26
+ })
27
+
28
+ const decodeStructuredContent = Schema.decodeUnknownOption(CodeModeStructuredContentSchema)
29
+
30
+ /** Largest stored value: characters of JSON (256 KiB), as the pi engine counts them. */
31
+ export const maxCodeModeStoreValueChars = 256 * 1024
32
+
33
+ /** Largest store: characters of keys plus JSON values (1 MiB), as the pi engine counts them. */
34
+ export const maxCodeModeStoreTotalChars = 1024 * 1024
35
+
36
+ /** One prior tool result with the name of the tool that produced it. */
37
+ export type CodeModeToolResultEntry = {
38
+ readonly toolName: string
39
+ readonly result: { readonly structuredContent?: unknown }
40
+ }
41
+
42
+ /**
43
+ * Rebuilds the code mode store from prior tool results, oldest first: applies the `storeWrites` of
44
+ * each successful script of the code mode tool named `toolName` (default `codemode`) in order.
45
+ * Results of other tools (even when their `structuredContent` looks like a code mode result),
46
+ * failed scripts, and malformed contents are skipped. Transcript `ToolResultMessage`s carry no tool
47
+ * name; pair each with the name of its assistant tool call.
48
+ *
49
+ * The rebuilt store keeps the engine bounds: a write whose JSON value exceeds 256 Ki characters, or
50
+ * that would grow the store beyond 1 Mi characters (keys plus JSON values), is dropped and the key
51
+ * keeps its previous value. Deletions always apply. Pass the value from `loadStore`.
52
+ */
53
+ export const codeModeStoreFromToolResults = (
54
+ entries: ReadonlyArray<CodeModeToolResultEntry>,
55
+ options: { readonly toolName?: string } = {}
56
+ ): CodeModeStore => {
57
+ const toolName = options.toolName ?? codeModeToolName
58
+ const store = new Map<string, { readonly value: Schema.Json; readonly chars: number }>()
59
+ let total = 0
60
+
61
+ for (const entry of entries) {
62
+ if (entry.toolName !== toolName) continue
63
+
64
+ const content = decodeStructuredContent(entry.result.structuredContent)
65
+
66
+ if (Option.isNone(content) || !content.value.codemode.ok) continue
67
+
68
+ const writes = content.value.codemode.storeWrites
69
+
70
+ if (writes === undefined) continue
71
+
72
+ for (const key of writes.delete) {
73
+ total -= store.get(key)?.chars ?? 0
74
+ store.delete(key)
75
+ }
76
+
77
+ for (const [key, value] of Object.entries(writes.set)) {
78
+ const json = JSON.stringify(value)
79
+
80
+ if (json.length > maxCodeModeStoreValueChars) continue
81
+
82
+ const chars = key.length + json.length
83
+ const next = total - (store.get(key)?.chars ?? 0) + chars
84
+
85
+ if (next > maxCodeModeStoreTotalChars) continue
86
+
87
+ store.set(key, { value, chars })
88
+ total = next
89
+ }
90
+ }
91
+
92
+ return Object.fromEntries([...store].map(([key, stored]) => [key, stored.value]))
93
+ }