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.
- package/package.json +2 -1
- package/src/agent.js +18 -29
- 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.
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
+
}
|