picocode-core 0.9.170 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocode-core",
3
- "version": "0.9.170",
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
@@ -143,7 +128,7 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
143
128
  // a tool may legitimately run for minutes (test suites, slow fetches)
144
129
  // and emits nothing while it does; the watchdog guards the provider
145
130
  // stream, so pause it until the tool finishes. tools carry their own
146
- // timeouts (bash 120s default, web tools via dredge)
131
+ // timeouts (bash 120s default)
147
132
  clearTimeout(watchdog)
148
133
  recorder.currentCall = event.call
149
134
  onStream?.(event)
@@ -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/controller.js CHANGED
@@ -53,7 +53,7 @@ export const SESSION_COLORS = {
53
53
  gray: '#9ca3af',
54
54
  }
55
55
 
56
- const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill', 'web_search', 'web_fetch']
56
+ const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill']
57
57
  const AGENT_TOOLS = ['agent_plan', 'agent_start', 'agent_list', 'agent_collect', 'agent_cancel']
58
58
  const CONTEXT_COLORS = ['#67b7ff', '#c792ea', '#f7c66a', '#f78c6c', '#6be795']
59
59
 
@@ -202,7 +202,9 @@ export function createController({ boot }) {
202
202
  const sessionId = state.session?.id
203
203
  if (!sessionId) throw new Error('worker requires an active session')
204
204
  const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, agent.id))
205
- const requestedTools = agent.tools?.length ? agent.tools.filter((name) => WORKER_TOOLS.includes(name)) : WORKER_TOOLS
205
+ const mcpTools = boot.mcp.tools()
206
+ const availableNames = [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)]
207
+ const requestedTools = agent.tools?.length ? agent.tools.filter((name) => availableNames.includes(name)) : availableNames
206
208
  const { tools, recorder } = createToolset({
207
209
  cwd: boot.cwd,
208
210
  env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
@@ -213,10 +215,10 @@ export function createController({ boot }) {
213
215
  shells: boot.shells,
214
216
  sessionId,
215
217
  sessionFile: state.session?.file,
216
- dredge: boot.dredge,
217
218
  signal,
218
219
  maxToolCalls: 30,
219
220
  allowNames: requestedTools,
221
+ mcpTools,
220
222
  })
221
223
  return runTurn({
222
224
  history: [{ role: 'user', content: agent.prompt }],
@@ -277,6 +279,7 @@ export function createController({ boot }) {
277
279
 
278
280
  const runWorker = async ({ history, role, tools: enabled = true, onStream }) => {
279
281
  const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, `deliberation-${id}-${role}`))
282
+ const mcpTools = enabled ? boot.mcp.tools() : []
280
283
  const toolset = createToolset({
281
284
  cwd: boot.cwd,
282
285
  env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
@@ -284,10 +287,10 @@ export function createController({ boot }) {
284
287
  shells: boot.shells,
285
288
  sessionId,
286
289
  sessionFile: state.session?.file,
287
- dredge: boot.dredge,
288
290
  signal,
289
291
  maxToolCalls: 30,
290
- allowNames: enabled ? WORKER_TOOLS : [],
292
+ allowNames: enabled ? [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)] : [],
293
+ mcpTools,
291
294
  })
292
295
  return runTurn({
293
296
  history,
@@ -622,7 +625,6 @@ export function createController({ boot }) {
622
625
  deliberations: boot.deliberationModel || (boot.proposerModel && boot.reviewerModel) ? deliberations : null,
623
626
  onAgentsCollected: discardCollectedAgentNotes,
624
627
  askUser,
625
- dredge: boot.dredge,
626
628
  mcpTools: boot.mcp.tools(),
627
629
  userTools: userToolScan.tools,
628
630
  signal: controller.signal,
@@ -1216,7 +1218,6 @@ export function createController({ boot }) {
1216
1218
  shells: boot.shells,
1217
1219
  wakeups: boot.wakeups,
1218
1220
  memory: boot.memory,
1219
- dredge: boot.dredge,
1220
1221
  ...extra,
1221
1222
  })
1222
1223
  }
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
+ }
@@ -5,10 +5,9 @@ import { createEdit } from './edit.js'
5
5
  import { createBash } from './bash.js'
6
6
  import { createGlob } from './glob.js'
7
7
  import { createGrep } from './grep.js'
8
- import { createWebTools } from './web.js'
9
8
  import { createView } from './view.js'
10
9
 
11
- export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser, dredge, mcpTools = [], userTools = [], hostTools = [], signal, maxToolCalls, maxAgentStarts, requireAgentPlan = false, allowNames, onToolUpdate, viewer }) {
10
+ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser, mcpTools = [], userTools = [], hostTools = [], signal, maxToolCalls, maxAgentStarts, requireAgentPlan = false, allowNames, onToolUpdate, viewer }) {
12
11
  const recorder = createRecorder(onToolUpdate)
13
12
  let agentStarts = 0
14
13
  let plannedAgentStarts = requireAgentPlan ? null : maxAgentStarts
@@ -25,10 +24,6 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
25
24
 
26
25
  if (viewer) local.push(createView({ ...deps, viewer }))
27
26
 
28
- if (dredge) {
29
- local.push(...createWebTools({ dredge, recorder, signal }))
30
- }
31
-
32
27
  if (shells) {
33
28
  local.push(
34
29
  {
@@ -283,7 +278,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
283
278
  })
284
279
  }
285
280
 
286
- const describedToolNames = new Set(['read', 'write', 'edit', 'bash', 'glob', 'grep', 'web_search', 'schedule_wakeup'])
281
+ const describedToolNames = new Set(['read', 'write', 'edit', 'bash', 'glob', 'grep', 'schedule_wakeup'])
287
282
  const describedTools = new Set(local.filter((tool) => describedToolNames.has(tool.name)).map((tool) => tool.name))
288
283
  const byName = new Map()
289
284
  for (const tool of [...local, ...hostTools, ...userTools, ...mcpTools]) {
package/src/tools/web.js DELETED
@@ -1,86 +0,0 @@
1
- import { describeParam } from './recorder.js'
2
- const DEFAULT_SLICE_CHARS = 24000
3
- const MAX_SLICE_CHARS = 100000
4
-
5
- export function resolveDredge(config = {}, env = process.env) {
6
- const url = env.DREDGE_URL || config.dredge?.url || null
7
- if (!url) return null
8
- return {
9
- url: url.replace(/\/+$/, ''),
10
- apiKey: env.DREDGE_API_KEY || config.dredge?.apiKey || null,
11
- }
12
- }
13
-
14
- const CALL_TIMEOUT_MS = 120000
15
-
16
- async function call(dredge, path, params, signal) {
17
- const query = new URLSearchParams(params)
18
- // bounded above dredge's own 90s queue ceiling so a wedged server becomes
19
- // a tool error instead of a hung turn
20
- const signals = [AbortSignal.timeout(CALL_TIMEOUT_MS), ...(signal ? [signal] : [])]
21
- const response = await fetch(`${dredge.url}${path}?${query}`, {
22
- signal: AbortSignal.any(signals),
23
- headers: dredge.apiKey ? { authorization: `Bearer ${dredge.apiKey}` } : {},
24
- })
25
- const body = await response.json().catch(() => null)
26
- if (!body) throw new Error(`dredge returned http ${response.status} with no usable body`)
27
- return body
28
- }
29
-
30
- export function createWebTools({ dredge, recorder, signal }) {
31
- return [
32
- {
33
- name: 'web_search',
34
- description:
35
- 'Search the web. Google-style operators work: site:, filetype:pdf, quoted phrases. Returns ranked results; pass a result url to web_fetch to read it.',
36
- schema: {
37
- q: { type: 'string', description: 'the search query' },
38
- description: describeParam,
39
- },
40
- execute: async ({ q }) => {
41
- recorder.extra({ title: q })
42
- const body = await call(dredge, '/search', { q }, signal)
43
- if (!body.ok) throw new Error(body.error?.message || 'search failed')
44
- const results = (body.results || []).map((r) => ({
45
- title: r.title,
46
- url: r.url,
47
- snippet: r.snippet,
48
- source: r.source,
49
- }))
50
- if (results.length === 0) {
51
- const backends = (body.backends || []).map((b) => `${b.name}: ${b.status}`).join(', ')
52
- return { results, note: backends ? `no results · backends: ${backends}` : 'no results' }
53
- }
54
- return { results }
55
- },
56
- },
57
- {
58
- name: 'web_fetch',
59
- description:
60
- 'Fetch a url and read it as clean markdown (html, pdf, docx, and textual formats like json). Long documents arrive in slices: the result says which slice you have (e.g. "slice 1 of 12") and next_cursor continues from there. Every slice you fetch permanently occupies conversation context, so only walk cursors for content you actually need, and raise maxChars only when the task genuinely needs a bigger window.',
61
- schema: {
62
- description: describeParam,
63
- url: { type: 'string', description: 'the url to fetch' },
64
- cursor: { type: 'string', description: 'pagination cursor from a previous web_fetch of the same url', optional: true },
65
- maxChars: { type: 'number', description: `slice size in characters, default ${DEFAULT_SLICE_CHARS}, max ${MAX_SLICE_CHARS}`, optional: true },
66
- },
67
- execute: async ({ url, cursor, maxChars }) => {
68
- recorder.extra({ title: url.replace(/^https?:\/\//, '').slice(0, 80) })
69
- const slice = Math.min(MAX_SLICE_CHARS, Math.max(1000, maxChars || DEFAULT_SLICE_CHARS))
70
- const body = await call(dredge, '/fetch', { url, maxChars: slice, ...(cursor && { cursor }) }, signal)
71
- if (!body.ok) {
72
- const { code, message, retryable } = body.error || {}
73
- throw new Error(`${code || 'fetch failed'}: ${message || url}${retryable ? ' (retryable)' : ''}`)
74
- }
75
- const { markdown, metadata, pagination } = body.doc
76
- return {
77
- markdown,
78
- title: metadata?.title || null,
79
- finalUrl: metadata?.final_url || url,
80
- ...(pagination?.total_chunks > 1 && { slice: `${pagination.chunk_index + 1} of ${pagination.total_chunks}` }),
81
- ...(pagination?.next_cursor && { next_cursor: pagination.next_cursor }),
82
- }
83
- },
84
- },
85
- ]
86
- }