picocode-core 0.9.128 → 0.9.130

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.128",
3
+ "version": "0.9.130",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agent.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { compose, scope, model, noToolsCalled, Inherit, getText } from '@prsm/ai'
3
- import { mediaTypeFor } from './attachments.js'
3
+ import { fileLabel, mediaTypeFor } from './attachments.js'
4
4
 
5
+ // file parts reach the model as a labelled path; it reads them itself
5
6
  async function hydratePart(part) {
7
+ if (part.type === 'file') return { type: 'text', text: fileLabel(part.path) }
6
8
  if (part.type !== 'image' || part.source?.kind !== 'path') return part
7
9
  const mediaType = part.source.mediaType || mediaTypeFor(part.source.path)
8
10
  if (!mediaType) return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
@@ -68,19 +68,22 @@ export function splitTextByImagePaths(text, exists = existsSync) {
68
68
  return parts
69
69
  }
70
70
 
71
+ export const fileLabel = (path) => `[file: ${path}]`
72
+
73
+ // [Image #n] and [File #n] placeholders in the composed text stand for
74
+ // attachments; they become image and file parts of the user message
71
75
  export function buildUserContent(text, attachments) {
72
76
  const parts = []
73
77
  let last = 0
74
78
  const used = []
75
- for (const match of text.matchAll(/\[Image #\d+\]/g)) {
79
+ for (const match of text.matchAll(/\[(?:Image|File) #\d+\]/g)) {
76
80
  const attachment = attachments.get(match[0])
77
81
  if (!attachment) continue
78
82
  const before = text.slice(last, match.index)
79
83
  if (before) parts.push({ type: 'text', text: before })
80
- parts.push({
81
- type: 'image',
82
- source: { kind: 'path', path: attachment.path, mediaType: attachment.mediaType },
83
- })
84
+ parts.push(attachment.kind === 'file'
85
+ ? { type: 'file', path: attachment.path }
86
+ : { type: 'image', source: { kind: 'path', path: attachment.path, mediaType: attachment.mediaType } })
84
87
  used.push(match[0])
85
88
  last = match.index + match[0].length
86
89
  }
@@ -116,6 +119,10 @@ export function inputTextFromContent(content, { attachments, nextId }) {
116
119
  const placeholder = `[Image #${nextId()}]`
117
120
  attachments.set(placeholder, { path: part.source.path, mediaType: part.source.mediaType || mediaTypeFor(part.source.path) })
118
121
  out += placeholder
122
+ } else if (part.type === 'file' && part.path) {
123
+ const placeholder = `[File #${nextId()}]`
124
+ attachments.set(placeholder, { path: part.path, kind: 'file' })
125
+ out += placeholder
119
126
  } else {
120
127
  out += '[image]'
121
128
  }
package/src/catalog.js CHANGED
@@ -63,6 +63,7 @@ export function extractModels(providers, providerIds) {
63
63
  desc: m.description || m.name || id,
64
64
  price: m.cost && m.cost.input != null ? { in: m.cost.input, out: m.cost.output } : null,
65
65
  effort: !!m.reasoning,
66
+ vision: !m.modalities || m.modalities.input?.includes('image') === true,
66
67
  // the measured fill number is input tokens, so prefer the input limit
67
68
  // as the denominator where the provider distinguishes it
68
69
  context: m.limit?.input || m.limit?.context || null,
@@ -81,6 +82,7 @@ export function adhocModel(name, providerIds) {
81
82
  desc: 'not in catalog',
82
83
  price: null,
83
84
  effort: false,
85
+ vision: true,
84
86
  context: null,
85
87
  }
86
88
  }
package/src/controller.js CHANGED
@@ -363,8 +363,10 @@ export function createController({ boot }) {
363
363
  }
364
364
 
365
365
  async function executeTurn(text) {
366
- const { content } = finalizeUserContent(text, state.attachments)
367
- persist(makeEvent('message', { message: { role: 'user', content } }))
366
+ const { content, used } = finalizeUserContent(text, state.attachments)
367
+ const viewed = used.length > 0 && used.every((placeholder) => deliveredImages.has(placeholder))
368
+ for (const placeholder of used) deliveredImages.delete(placeholder)
369
+ persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
368
370
  ensureSession()
369
371
  reDerive()
370
372
  await runAgentTurn()
@@ -577,6 +579,7 @@ export function createController({ boot }) {
577
579
  requireAgentPlan: !!researchAgentLimit,
578
580
  allowNames: researchAgentLimit ? AGENT_TOOLS : undefined,
579
581
  onToolUpdate,
582
+ viewer: state.model.vision === false ? null : { deliver: deliverImage },
580
583
  })
581
584
 
582
585
  sendAfterToolTriggered = false
@@ -807,10 +810,30 @@ export function createController({ boot }) {
807
810
  return placeholder
808
811
  }
809
812
 
813
+ // an image the model asked to see rides in as an expedited user message,
814
+ // which the tool-completion path sends right after the current call
815
+ const deliveredImages = new Set()
816
+ function deliverImage(path, label) {
817
+ const placeholder = attachImage(path)
818
+ if (!placeholder) return false
819
+ deliveredImages.add(placeholder)
820
+ set({ expedited: [...state.expedited, `${label}\n${placeholder}`] })
821
+ return true
822
+ }
823
+
824
+ function attachFile(path) {
825
+ if (!existsSync(path)) return null
826
+ const placeholder = `[File #${++state.imageCount}]`
827
+ state.attachments.set(placeholder, { path, kind: 'file' })
828
+ return placeholder
829
+ }
830
+
831
+ // a project-relative pick: images attach as images, anything else as a
832
+ // file reference
810
833
  function attachProjectFile(file) {
811
834
  const full = join(boot.cwd, file)
812
- if (!mediaTypeFor(file) || !existsSync(full)) return null
813
- return attachImage(full)
835
+ if (!existsSync(full)) return null
836
+ return mediaTypeFor(file) ? attachImage(full) : attachFile(full)
814
837
  }
815
838
 
816
839
  function detachImage(placeholder) {
@@ -1246,6 +1269,7 @@ export function createController({ boot }) {
1246
1269
  undoRewind,
1247
1270
  recallText,
1248
1271
  attachImage,
1272
+ attachFile,
1249
1273
  attachProjectFile,
1250
1274
  detachImage,
1251
1275
  costSummary,
package/src/derive.js CHANGED
@@ -100,10 +100,10 @@ function foldMessage(state, event) {
100
100
  if (message.role === 'user') {
101
101
  const text = Array.isArray(message.content)
102
102
  ? message.content
103
- .map((p) => (p.type === 'text' ? p.text : `[image: ${String(p.source?.path || '').split('/').pop() || 'attached'}]`))
103
+ .map((p) => (p.type === 'text' ? p.text : p.type === 'file' ? `[file: ${p.path}]` : `[image: ${String(p.source?.path || '').split('/').pop() || 'attached'}]`))
104
104
  .join('')
105
105
  : String(message.content)
106
- state.transcript.push({ ...base, kind: 'user', text, content: message.content })
106
+ state.transcript.push({ ...base, kind: 'user', text, content: message.content, ...(event.data.origin ? { origin: event.data.origin } : {}) })
107
107
  return
108
108
  }
109
109
  if (message.role === 'assistant') {
@@ -6,8 +6,9 @@ import { createBash } from './bash.js'
6
6
  import { createGlob } from './glob.js'
7
7
  import { createGrep } from './grep.js'
8
8
  import { createWebTools } from './web.js'
9
+ import { createView } from './view.js'
9
10
 
10
- export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser, dredge, mcpTools = [], userTools = [], signal, maxToolCalls, maxAgentStarts, requireAgentPlan = false, allowNames, onToolUpdate }) {
11
+ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser, dredge, mcpTools = [], userTools = [], signal, maxToolCalls, maxAgentStarts, requireAgentPlan = false, allowNames, onToolUpdate, viewer }) {
11
12
  const recorder = createRecorder(onToolUpdate)
12
13
  let agentStarts = 0
13
14
  let plannedAgentStarts = requireAgentPlan ? null : maxAgentStarts
@@ -22,6 +23,8 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
22
23
  createGrep(deps),
23
24
  ]
24
25
 
26
+ if (viewer) local.push(createView({ ...deps, viewer }))
27
+
25
28
  if (dredge) {
26
29
  local.push(...createWebTools({ dredge, recorder, signal }))
27
30
  }
package/src/tools/read.js CHANGED
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { execFile } from 'node:child_process'
5
5
  import { extractText } from 'unpdf'
6
+ import { mediaTypeFor } from '../attachments.js'
6
7
 
7
8
  const MAX_LINES = 2000
8
9
  const MAX_LINE_LENGTH = 2000
@@ -28,6 +29,26 @@ async function unpdfPages(buffer) {
28
29
  return (Array.isArray(text) ? text : [text]).map((page) => String(page).split('\n'))
29
30
  }
30
31
 
32
+ // the embedded images per page, from poppler when installed, so the model
33
+ // knows what view can show it
34
+ function pdfImages(full) {
35
+ return new Promise((resolvePromise) => {
36
+ execFile('pdfimages', ['-list', full], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
37
+ if (error) return resolvePromise(null)
38
+ const counts = new Map()
39
+ for (const line of stdout.split('\n')) {
40
+ const m = line.match(/^\s*(\d+)\s+\d+\s+image\s+(\d+)\s+(\d+)/)
41
+ if (!m) continue
42
+ const page = Number(m[1])
43
+ const list = counts.get(page) ?? []
44
+ list.push({ index: list.length, width: Number(m[2]), height: Number(m[3]) })
45
+ counts.set(page, list)
46
+ }
47
+ resolvePromise([...counts].map(([page, images]) => ({ page, images })))
48
+ })
49
+ })
50
+ }
51
+
31
52
  async function pdfLines(full, buffer) {
32
53
  const pages = (await pdftotext(full)) ?? (await unpdfPages(buffer))
33
54
  const lines = []
@@ -62,6 +83,7 @@ export function createRead({ cwd, recorder, tracker }) {
62
83
  recorder.extra({ title: path })
63
84
  const buf = await readFile(full)
64
85
  const lines = isPdf(buf) ? await pdfLines(full, buf) : null
86
+ if (!lines && mediaTypeFor(full)) return { note: `${path} is an image; use view to look at it` }
65
87
  if (!lines && isBinary(buf)) throw new Error(`${path} is a binary file`)
66
88
  const source = lines ?? buf.toString('utf-8').split('\n')
67
89
 
@@ -77,6 +99,10 @@ export function createRead({ cwd, recorder, tracker }) {
77
99
  if (start + count < source.length) {
78
100
  result.note = `showing lines ${start + 1}-${start + sliced.length} of ${source.length}`
79
101
  }
102
+ if (lines) {
103
+ const images = await pdfImages(full)
104
+ if (images?.length) result.images = { note: 'use view with the path and page (and image index) to see one', pages: images }
105
+ }
80
106
  const context = tracker.check(full)
81
107
  if (context.length) result.context_from_agents_md = context
82
108
  return result
@@ -0,0 +1,70 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { mkdir, readdir } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { describeParam } from './recorder.js'
7
+ import { mediaTypeFor } from '../attachments.js'
8
+
9
+ const run = (cmd, args) =>
10
+ new Promise((resolvePromise, reject) => {
11
+ execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolvePromise(stdout)))
12
+ })
13
+
14
+ const isPdf = (path) => /\.pdf$/i.test(path)
15
+
16
+ async function scratch(sessionId) {
17
+ const dir = join(tmpdir(), 'pico-view', String(sessionId || 'session'))
18
+ await mkdir(dir, { recursive: true })
19
+ return dir
20
+ }
21
+
22
+ // a page rendered by poppler, or one of the images embedded on it
23
+ async function renderPage(full, page, dir) {
24
+ const prefix = join(dir, `page-${Date.now()}`)
25
+ await run('pdftoppm', ['-png', '-r', '110', '-f', String(page), '-l', String(page), '-singlefile', full, prefix])
26
+ return `${prefix}.png`
27
+ }
28
+
29
+ async function extractImage(full, page, index, dir) {
30
+ const prefix = join(dir, `img-${Date.now()}`)
31
+ await run('pdfimages', ['-png', '-f', String(page), '-l', String(page), full, prefix])
32
+ const files = (await readdir(dir)).filter((f) => f.startsWith(`${prefix.slice(dir.length + 1)}-`)).sort()
33
+ const file = files[index]
34
+ if (!file) throw new Error(`page ${page} has ${files.length} embedded image${files.length === 1 ? '' : 's'}; image index ${index} does not exist`)
35
+ return join(dir, file)
36
+ }
37
+
38
+ // the model api cannot carry an image inside a tool result, so the tool
39
+ // hands the image to the viewer, which delivers it as the next user message
40
+ // right after this call
41
+ export function createView({ cwd, sessionId, recorder, viewer }) {
42
+ return {
43
+ name: 'view',
44
+ description: 'Look at an image. Pass an image file path, or a pdf path with a page number to see that page rendered (add image to pick one embedded image on that page, 0-based, as listed by read). The image arrives in the next user message.',
45
+ schema: {
46
+ description: describeParam,
47
+ path: { type: 'string', description: 'image or pdf path, relative to the working directory or absolute' },
48
+ page: { type: 'number', description: 'pdf page to render, 1-based', optional: true },
49
+ image: { type: 'number', description: 'embedded image index on that page, 0-based; omit to render the whole page', optional: true },
50
+ },
51
+ execute: async ({ path, page, image }) => {
52
+ const full = resolve(cwd, path)
53
+ recorder.extra({ title: page != null ? `${path} page ${page}${image != null ? ` image ${image}` : ''}` : path })
54
+ if (!existsSync(full)) throw new Error(`${path} does not exist`)
55
+ let file = full
56
+ let label = `[view: ${path}]`
57
+ if (isPdf(full)) {
58
+ if (page == null) throw new Error('a pdf needs a page number')
59
+ const dir = await scratch(sessionId)
60
+ file = image != null ? await extractImage(full, page, image, dir) : await renderPage(full, page, dir)
61
+ label = `[view: ${path}, page ${page}${image != null ? `, image ${image}` : ''}]`
62
+ } else if (!mediaTypeFor(full)) {
63
+ throw new Error(`${path} is not an image or pdf`)
64
+ }
65
+ const delivered = viewer.deliver(file, label)
66
+ if (!delivered) throw new Error('could not attach the image')
67
+ return { ok: true, note: 'the image follows in the next user message' }
68
+ },
69
+ }
70
+ }