picocode-core 0.9.129 → 0.9.131
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 +20 -3
- 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
|
@@ -108,6 +108,7 @@ export function createController({ boot }) {
|
|
|
108
108
|
defaultEffort: boot.initialEffort,
|
|
109
109
|
busy: false,
|
|
110
110
|
compacting: false,
|
|
111
|
+
compactOutcome: null,
|
|
111
112
|
compactStatus: null,
|
|
112
113
|
turnPhase: 'idle',
|
|
113
114
|
startedAt: 0,
|
|
@@ -363,8 +364,10 @@ export function createController({ boot }) {
|
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
async function executeTurn(text) {
|
|
366
|
-
const { content } = finalizeUserContent(text, state.attachments)
|
|
367
|
-
|
|
367
|
+
const { content, used } = finalizeUserContent(text, state.attachments)
|
|
368
|
+
const viewed = used.length > 0 && used.every((placeholder) => deliveredImages.has(placeholder))
|
|
369
|
+
for (const placeholder of used) deliveredImages.delete(placeholder)
|
|
370
|
+
persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
|
|
368
371
|
ensureSession()
|
|
369
372
|
reDerive()
|
|
370
373
|
await runAgentTurn()
|
|
@@ -383,7 +386,7 @@ export function createController({ boot }) {
|
|
|
383
386
|
|
|
384
387
|
const controller = new AbortController()
|
|
385
388
|
abort = controller
|
|
386
|
-
set({ busy: true, compacting: true, compactStatus: null, startedAt: Date.now() })
|
|
389
|
+
set({ busy: true, compacting: true, compactStatus: null, compactOutcome: null, startedAt: Date.now() })
|
|
387
390
|
|
|
388
391
|
const { auth, ok } = await codexAuth()
|
|
389
392
|
if (!ok) {
|
|
@@ -412,8 +415,10 @@ export function createController({ boot }) {
|
|
|
412
415
|
if (summarySections(summary) < 5) throw new Error('malformed summary, conversation left untouched')
|
|
413
416
|
persist(makeEvent('compact', { summary, keepFrom, sessionFile: state.session?.file || null }))
|
|
414
417
|
reDerive()
|
|
418
|
+
set({ compactOutcome: 'done' })
|
|
415
419
|
flash('compacted · recent messages kept verbatim')
|
|
416
420
|
} catch (err) {
|
|
421
|
+
set({ compactOutcome: controller.signal.aborted ? 'cancelled' : 'failed' })
|
|
417
422
|
if (controller.signal.aborted) flash('compaction cancelled')
|
|
418
423
|
else flash(`compact failed: ${errorText(err, 100)}`)
|
|
419
424
|
} finally {
|
|
@@ -577,6 +582,7 @@ export function createController({ boot }) {
|
|
|
577
582
|
requireAgentPlan: !!researchAgentLimit,
|
|
578
583
|
allowNames: researchAgentLimit ? AGENT_TOOLS : undefined,
|
|
579
584
|
onToolUpdate,
|
|
585
|
+
viewer: state.model.vision === false ? null : { deliver: deliverImage },
|
|
580
586
|
})
|
|
581
587
|
|
|
582
588
|
sendAfterToolTriggered = false
|
|
@@ -807,6 +813,17 @@ export function createController({ boot }) {
|
|
|
807
813
|
return placeholder
|
|
808
814
|
}
|
|
809
815
|
|
|
816
|
+
// an image the model asked to see rides in as an expedited user message,
|
|
817
|
+
// which the tool-completion path sends right after the current call
|
|
818
|
+
const deliveredImages = new Set()
|
|
819
|
+
function deliverImage(path, label) {
|
|
820
|
+
const placeholder = attachImage(path)
|
|
821
|
+
if (!placeholder) return false
|
|
822
|
+
deliveredImages.add(placeholder)
|
|
823
|
+
set({ expedited: [...state.expedited, `${label}\n${placeholder}`] })
|
|
824
|
+
return true
|
|
825
|
+
}
|
|
826
|
+
|
|
810
827
|
function attachFile(path) {
|
|
811
828
|
if (!existsSync(path)) return null
|
|
812
829
|
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
|
+
}
|