picocode-core 0.9.171 → 0.9.172

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 (3) hide show
  1. package/package.json +2 -1
  2. package/src/agent.js +18 -29
  3. package/src/images.js +127 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocode-core",
3
- "version": "0.9.171",
3
+ "version": "0.9.172",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "diff": "^7.0.0",
30
30
  "globby": "^16.2.4",
31
31
  "proper-lockfile": "^4.1.2",
32
+ "sharp": "^0.34.5",
32
33
  "unpdf": "^1.8.1"
33
34
  }
34
35
  }
package/src/agent.js CHANGED
@@ -1,8 +1,8 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { prepareImages } from './images.js'
2
2
  import { execFile } from 'node:child_process'
3
3
  import { promisify } from 'node:util'
4
4
  import { compose, scope, model, noToolsCalled, Inherit, getText } from '@prsm/ai'
5
- import { commitLabel, elementLabel, fileLabel, mediaTypeFor, selectionLabel } from './attachments.js'
5
+ import { commitLabel, elementLabel, fileLabel, selectionLabel } from './attachments.js'
6
6
 
7
7
  const exec = promisify(execFile)
8
8
  // a whole patch rides along only while it is small; a bigger commit
@@ -38,31 +38,16 @@ async function hydratePart(part) {
38
38
  if (part.type === 'selection') return { type: 'text', text: selectionLabel(part) }
39
39
  if (part.type === 'commit') return hydrateCommit(part)
40
40
  if (part.type === 'element') return { type: 'text', text: elementLabel(part) }
41
- if (part.type !== 'image' || part.source?.kind !== 'path') return part
42
- const mediaType = part.source.mediaType || mediaTypeFor(part.source.path)
43
- if (!mediaType) return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
44
- try {
45
- const data = await readFile(part.source.path)
46
- // an empty file is not an image; sent as one it fails every request
47
- // that carries this history from then on
48
- if (!data.length) return { type: 'text', text: `[image unavailable: ${part.source.path} is empty]` }
49
- return {
50
- type: 'image',
51
- source: { kind: 'base64', mediaType, data: data.toString('base64') },
52
- }
53
- } catch {
54
- return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
55
- }
41
+ return part
56
42
  }
57
43
 
58
- export function hydrateImages(history) {
59
- return Promise.all(
60
- history.map(async (message) =>
61
- Array.isArray(message.content)
62
- ? { ...message, content: await Promise.all(message.content.map(hydratePart)) }
63
- : message,
64
- ),
65
- )
44
+ export async function hydrateImages(history) {
45
+ const hydrated = await Promise.all(history.map(async (message) =>
46
+ Array.isArray(message.content)
47
+ ? { ...message, content: await Promise.all(message.content.map(hydratePart)) }
48
+ : message,
49
+ ))
50
+ return prepareImages(hydrated)
66
51
  }
67
52
 
68
53
  // reasoning models with large contexts can sit minutes before the first
@@ -172,13 +157,17 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
172
157
  const step = compose(
173
158
  scope(
174
159
  { inherit: Inherit.Conversation, system, tools, until: noToolsCalled(), stream },
175
- (ctx) =>
176
- model({
160
+ async (ctx) => {
161
+ const out = await model({
177
162
  model: modelName,
178
163
  ...(effort && { effort }),
179
164
  ...(auth?.apiKey && { apiKey: auth.apiKey }),
180
165
  ...(auth?.headers && { headers: auth.headers }),
181
- })({ ...ctx, abortSignal: internal.signal }),
166
+ })({ ...ctx, history: await hydrateImages(ctx.history), abortSignal: internal.signal })
167
+ // Prepared bytes and omission notices belong only to the request, not
168
+ // the conversation returned by the provider or subsequent tool rounds.
169
+ return { ...out, history: [...ctx.history, ...out.history.slice(ctx.history.length)] }
170
+ },
182
171
  ),
183
172
  )
184
173
 
@@ -190,7 +179,7 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
190
179
 
191
180
  arm()
192
181
  try {
193
- const out = await step({ history: await hydrateImages(history), tools: [] })
182
+ const out = await step({ history, tools: [] })
194
183
  const interrupted = !!signal?.aborted || stalled
195
184
  if (interrupted) {
196
185
  return { messages: partialMessages(), usage: usageSeen, lastPromptTokens, interrupted, stalled }
package/src/images.js ADDED
@@ -0,0 +1,127 @@
1
+ import { open } from 'node:fs/promises'
2
+ import { constants } from 'node:fs'
3
+ import sharp from 'sharp'
4
+
5
+ // Conservative application budgets, not claims about every provider's limits.
6
+ // OpenAI documents 32px patches and model/detail-specific resizing budgets:
7
+ // https://developers.openai.com/api/docs/guides/images-vision
8
+ // Claude documents 32MB requests and a 2000px side limit
9
+ // above 20 images: https://platform.claude.com/docs/en/build-with-claude/vision
10
+ // Apply the same envelope across models, including Codex (whose endpoint
11
+ // must not be relied on to perform the documented API's automatic resizing).
12
+ export const IMAGE_LIMITS = Object.freeze({
13
+ side: 2000, patches: 1536, count: 20, requestPatches: 12000,
14
+ bytes: 4_000_000, requestBase64Bytes: 20_000_000,
15
+ inputBytes: 64_000_000, inputPixels: 100_000_000,
16
+ })
17
+ const patches = (w, h) => Math.ceil(w / 32) * Math.ceil(h / 32)
18
+
19
+ export function imageUnavailable(part, reason) {
20
+ const label = part.source?.kind === 'path' ? part.source.path : 'attached image'
21
+ return { type: 'text', text: `[image unavailable: ${label}; ${reason}. Original unchanged. Use a smaller crop or paginated screenshot, or attach the image again.]` }
22
+ }
23
+
24
+ export function imageDimensions(width, height) {
25
+ let scale = Math.min(1, IMAGE_LIMITS.side / width, IMAGE_LIMITS.side / height,
26
+ Math.sqrt(IMAGE_LIMITS.patches * 32 * 32 / (width * height)))
27
+ let w = Math.max(1, Math.floor(width * scale))
28
+ let h = Math.max(1, Math.floor(height * scale))
29
+ // Rounding UP to patches matters, especially for long, narrow screenshots.
30
+ while (patches(w, h) > IMAGE_LIMITS.patches) {
31
+ scale *= 0.99
32
+ w = Math.max(1, Math.floor(width * scale))
33
+ h = Math.max(1, Math.floor(height * scale))
34
+ }
35
+ return { width: w, height: h }
36
+ }
37
+
38
+ async function imageBytes(source) {
39
+ if (source?.kind === 'path') {
40
+ const file = await open(source.path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
41
+ try {
42
+ const stat = await file.stat()
43
+ if (!stat.isFile()) throw new Error('not a regular image file')
44
+ if (stat.size > IMAGE_LIMITS.inputBytes) throw new Error('source exceeds the 64MB safety limit')
45
+ // Bounded even if the file grows after stat.
46
+ const data = Buffer.alloc(Math.min(stat.size + 1, IMAGE_LIMITS.inputBytes + 1))
47
+ let size = 0
48
+ while (size < data.length) {
49
+ const { bytesRead } = await file.read(data, size, data.length - size, null)
50
+ if (!bytesRead) break
51
+ size += bytesRead
52
+ }
53
+ if (size > stat.size) throw new Error('image changed while reading; retry')
54
+ return data.subarray(0, size)
55
+ } finally { await file.close() }
56
+ }
57
+ let encoded = source?.kind === 'base64' ? source.data : null
58
+ if (source?.kind === 'url') {
59
+ encoded = /^data:image\/[\w.+-]+;base64,([\s\S]*)$/i.exec(source.url)?.[1]
60
+ }
61
+ if (typeof encoded !== 'string') throw new Error('unvalidated remote or unsupported image source; download it to a local file first')
62
+ if (encoded.length > Math.ceil(IMAGE_LIMITS.inputBytes / 3) * 4) throw new Error('source exceeds the 64MB safety limit')
63
+ encoded = encoded.replace(/\s/g, '')
64
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('invalid base64 image')
65
+ const bytes = Buffer.from(encoded, 'base64')
66
+ if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('invalid base64 image')
67
+ return bytes
68
+ }
69
+
70
+ export async function prepareImage(part) {
71
+ try {
72
+ const bytes = await imageBytes(part.source)
73
+ if (!bytes.length) throw new Error('image is empty')
74
+ const options = { limitInputPixels: IMAGE_LIMITS.inputPixels, failOn: 'warning' }
75
+ const metadata = await sharp(bytes, options).metadata()
76
+ if (!['png', 'jpeg', 'webp', 'gif'].includes(metadata.format)) throw new Error('unsupported image format; use PNG, JPEG, WebP or GIF')
77
+ if (!metadata.width || !metadata.height) throw new Error('invalid image dimensions')
78
+ const rotated = [5, 6, 7, 8].includes(metadata.orientation)
79
+ const dimensions = imageDimensions(rotated ? metadata.height : metadata.width, rotated ? metadata.width : metadata.height)
80
+ // Decode fully: metadata alone accepts truncated/corrupt pixel data. Always
81
+ // emit a static, correctly labelled image; animation uses its first frame.
82
+ const image = sharp(bytes, options).rotate().resize({ ...dimensions, fit: 'fill' })
83
+ let result = await image.png().toBuffer({ resolveWithObject: true })
84
+ let mediaType = 'image/png'
85
+ if (result.data.length > IMAGE_LIMITS.bytes) {
86
+ result = await sharp(result.data).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer({ resolveWithObject: true })
87
+ mediaType = 'image/jpeg'
88
+ }
89
+ if (result.data.length > IMAGE_LIMITS.bytes) throw new Error('encoded image exceeds the 4MB safety limit')
90
+ return { part: { type: 'image', source: { kind: 'base64', mediaType, data: result.data.toString('base64') } }, patches: patches(result.info.width, result.info.height) }
91
+ } catch (error) {
92
+ // Do not expose decoder internals or base64 payloads to the model.
93
+ const reason = error.code === 'ENOENT' ? 'file is missing' :
94
+ /^(image |source |invalid |unsupported |unvalidated |not a regular|encoded image)/.test(error.message) ? error.message : 'cannot read or decode image safely'
95
+ return { part: imageUnavailable(part, reason), patches: 0 }
96
+ }
97
+ }
98
+
99
+ // Sequential and newest-first: bound decoder memory, and retain the images
100
+ // relevant to the current task rather than letting old history exhaust budgets.
101
+ export async function prepareImages(history) {
102
+ const out = history.map(message => Array.isArray(message.content) ? { ...message, content: [...message.content] } : message)
103
+ let count = 0, totalPatches = 0, totalBytes = 0
104
+ for (let m = out.length - 1; m >= 0; m--) {
105
+ const content = out[m].content
106
+ if (!Array.isArray(content)) continue
107
+ for (let p = content.length - 1; p >= 0; p--) {
108
+ const original = content[p]
109
+ if (original.type !== 'image') continue
110
+ if (count >= IMAGE_LIMITS.count) {
111
+ content[p] = imageUnavailable(original, 'request image count safety budget reached; newest images retained')
112
+ continue
113
+ }
114
+ const prepared = await prepareImage(original)
115
+ const size = prepared.part.source?.data.length || 0
116
+ if (totalPatches + prepared.patches > IMAGE_LIMITS.requestPatches || totalBytes + size > IMAGE_LIMITS.requestBase64Bytes) {
117
+ content[p] = imageUnavailable(original, 'request image safety budget reached; newer images take priority')
118
+ continue
119
+ }
120
+ content[p] = prepared.part
121
+ if (prepared.part.type === 'image') count++
122
+ totalPatches += prepared.patches
123
+ totalBytes += size
124
+ }
125
+ }
126
+ return out
127
+ }