picocode-core 0.9.129 → 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 +1 -1
- package/src/catalog.js +2 -0
- package/src/controller.js +16 -2
- package/src/derive.js +1 -1
- package/src/tools/index.js +4 -1
- package/src/tools/read.js +26 -0
- package/src/tools/view.js +70 -0
package/package.json
CHANGED
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
|
-
|
|
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,6 +810,17 @@ 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
|
+
|
|
810
824
|
function attachFile(path) {
|
|
811
825
|
if (!existsSync(path)) return null
|
|
812
826
|
const placeholder = `[File #${++state.imageCount}]`
|
package/src/derive.js
CHANGED
|
@@ -103,7 +103,7 @@ function foldMessage(state, event) {
|
|
|
103
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') {
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
+
}
|