picocode-core 0.9.124 → 0.9.126

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.124",
3
+ "version": "0.9.126",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "@prsm/ai": "^1.6.1",
29
29
  "diff": "^7.0.0",
30
30
  "fast-glob": "^3.3.0",
31
- "proper-lockfile": "^4.1.2"
31
+ "proper-lockfile": "^4.1.2",
32
+ "unpdf": "^1.8.1"
32
33
  }
33
34
  }
package/src/tools/bash.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { spawn } from 'node:child_process'
2
3
  import { stripAnsi, createSgrTracker } from '../ansi.js'
3
4
 
@@ -67,7 +68,7 @@ export function createBash({ cwd, env, recorder, signal, shells, sessionId, sess
67
68
  command: { type: 'string', description: 'the command to run' },
68
69
  timeout: { type: 'number', description: 'optional foreground timeout in milliseconds; commands still running after 150 seconds are backgrounded instead', optional: true },
69
70
  background: { type: 'boolean', description: 'run in the background and return a shell id immediately', optional: true },
70
- description: { type: 'string', description: 'a few words explaining the purpose of this command, shown to the human watching' },
71
+ description: describeParam,
71
72
  },
72
73
  execute: ({ command, timeout, background, description }) => {
73
74
  if (background && shells) {
package/src/tools/edit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { readFile, writeFile } from 'node:fs/promises'
2
3
  import { resolve } from 'node:path'
3
4
  import { makeReversibleEdit } from '../reversible-edit.js'
@@ -54,7 +55,7 @@ export function createEdit({ cwd, recorder, tracker }) {
54
55
  name: 'edit',
55
56
  description: 'Replace oldText with newText in a file. oldText must appear exactly once unless replaceAll is set.',
56
57
  schema: {
57
- description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
58
+ description: describeParam,
58
59
  path: { type: 'string', description: 'file path, relative to the working directory or absolute' },
59
60
  oldText: { type: 'string', description: 'exact text to replace, must be unique in the file' },
60
61
  newText: { type: 'string', description: 'replacement text' },
package/src/tools/glob.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { execFile } from 'node:child_process'
2
3
  import fg from 'fast-glob'
3
4
 
@@ -18,7 +19,7 @@ export function createGlob({ cwd, recorder }) {
18
19
  name: 'glob',
19
20
  description: 'Find files matching a glob pattern, relative to the working directory. Respects .gitignore; use bash to look inside ignored paths.',
20
21
  schema: {
21
- description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
22
+ description: describeParam,
22
23
  pattern: { type: 'string', description: 'glob pattern, e.g. src/**/*.js' },
23
24
  maxResults: { type: 'number', description: 'cap on returned paths, default 200', optional: true },
24
25
  },
package/src/tools/grep.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { execFile } from 'node:child_process'
2
3
  import { relative, resolve } from 'node:path'
3
4
 
@@ -16,7 +17,7 @@ export function createGrep({ cwd, recorder }) {
16
17
  name: 'grep',
17
18
  description: 'Search file contents with a regex using ripgrep. mode "content" returns matching lines, "files" returns matching file paths, "count" returns per-file match counts.',
18
19
  schema: {
19
- description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
20
+ description: describeParam,
20
21
  pattern: { type: 'string', description: 'regex pattern' },
21
22
  path: { type: 'string', description: 'file or directory to search, defaults to the working directory', optional: true },
22
23
  mode: { type: 'string', enum: ['content', 'files', 'count'], optional: true },
@@ -1,4 +1,4 @@
1
- import { createRecorder, recorded } from './recorder.js'
1
+ import { createRecorder, describeParam, recorded } from './recorder.js'
2
2
  import { createRead } from './read.js'
3
3
  import { createWrite } from './write.js'
4
4
  import { createEdit } from './edit.js'
@@ -32,6 +32,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
32
32
  name: 'shell_output',
33
33
  description: 'Read recent output from a background shell started with bash background: true. Use only when intermediate output is needed to diagnose a suspected problem or make a decision before the shell exits. Do not use for routine progress polling; completion is delivered automatically, so if no independent work remains, end your turn and wait.',
34
34
  schema: {
35
+ description: describeParam,
35
36
  id: { type: 'string', description: 'the shell id' },
36
37
  tail: { type: 'number', description: 'how many trailing lines to return, default 100', optional: true },
37
38
  },
@@ -47,6 +48,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
47
48
  name: 'shell_kill',
48
49
  description: 'Stop a background shell by id.',
49
50
  schema: {
51
+ description: describeParam,
50
52
  id: { type: 'string', description: 'the shell id' },
51
53
  },
52
54
  execute: ({ id }) => shells.kill(id, 'model'),
@@ -60,7 +62,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
60
62
  name: 'schedule_wakeup',
61
63
  description: 'Schedule a one-time wake-up: after the delay you receive a system notification carrying your note and can act on it. For a recurring loop, schedule the next wake-up at the end of each one. Do not use this to poll a background shell; its exit already notifies you. Wake-ups are lost if pico exits.',
62
64
  schema: {
63
- description: { type: 'string', description: 'briefly explain why this wake-up is needed, shown to the human watching' },
65
+ description: describeParam,
64
66
  delaySeconds: { type: 'number', description: 'seconds from now, minimum 5' },
65
67
  note: { type: 'string', description: 'what to do when you wake up; written to your future self' },
66
68
  },
@@ -73,6 +75,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
73
75
  name: 'cancel_wakeup',
74
76
  description: 'Cancel a pending wake-up by id.',
75
77
  schema: {
78
+ description: describeParam,
76
79
  id: { type: 'string', description: 'the wake-up id' },
77
80
  },
78
81
  execute: ({ id }) => wakeups.cancel(id),
@@ -80,7 +83,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
80
83
  {
81
84
  name: 'list_wakeups',
82
85
  description: 'List your pending scheduled wake-ups: id, when each fires, and its note.',
83
- schema: {},
86
+ schema: { description: describeParam },
84
87
  execute: () => ({
85
88
  wakeups: wakeups.list().map((w) => ({
86
89
  id: w.id,
@@ -98,6 +101,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
98
101
  name: 'ask_user',
99
102
  description: 'Ask the user one or more focused questions when their answers are genuinely needed to continue. Supports free text, one choice, or multiple choices. Do not ask questions you can answer from available context.',
100
103
  schema: {
104
+ description: describeParam,
101
105
  questions: {
102
106
  type: 'array',
103
107
  description: 'questions to present, in order',
@@ -146,6 +150,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
146
150
  name: 'agent_plan',
147
151
  description: 'Declare the background worker budget for the current assistant turn before starting any workers. The main agent is excluded. Set count to the number of workers you intend to start this turn; collecting a worker does not restore budget. Interpret any user-requested count semantically; otherwise use the configured default.',
148
152
  schema: {
153
+ description: describeParam,
149
154
  count: { type: 'integer', description: `background workers permitted this turn, excluding the main agent (1-${maxAgentStarts || 100})` },
150
155
  reason: { type: 'string', description: 'brief explanation of how the count follows the user request or research scope' },
151
156
  },
@@ -162,7 +167,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
162
167
  description: 'Start one background worker, consuming one unit of the current turn\'s agent_plan budget. Workers do not receive the parent conversation, only the supplied prompt and their available project context and tools. Make the prompt self-contained by including all conversation-specific terms, goals, constraints, and decisions the worker cannot discover itself. Collected or completed workers do not restore budget within that turn. Continue only independent work until you collect its result with agent_collect.',
163
168
  schema: {
164
169
  prompt: { type: 'string', description: 'complete, self-contained task and desired output, including necessary context from the parent conversation' },
165
- description: { type: 'string', description: 'short label shown to the user' },
170
+ description: describeParam,
166
171
  tools: { type: 'array', items: { type: 'string' }, description: 'tool names to allow; omit for the configured worker tools', optional: true },
167
172
  },
168
173
  execute: ({ prompt, description, tools }) => {
@@ -176,13 +181,13 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
176
181
  {
177
182
  name: 'agent_list',
178
183
  description: 'List background agents and their current status.',
179
- schema: {},
184
+ schema: { description: describeParam },
180
185
  execute: () => ({ agents: agents.list().map(({ id, description, model, status }) => ({ id, description, model, status })) }),
181
186
  },
182
187
  {
183
188
  name: 'agent_collect',
184
189
  description: 'Collect background agent results. Waits for any selected agents that are still running.',
185
- schema: { ids: { type: 'array', items: { type: 'string' }, description: 'agent ids whose results to collect' } },
190
+ schema: { description: describeParam, ids: { type: 'array', items: { type: 'string' }, description: 'agent ids whose results to collect' } },
186
191
  execute: async ({ ids }) => {
187
192
  const requested = (ids || []).map(String)
188
193
  const collected = await agents.collect(requested)
@@ -203,7 +208,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
203
208
  {
204
209
  name: 'agent_cancel',
205
210
  description: 'Cancel a queued or running background agent.',
206
- schema: { id: { type: 'string', description: 'agent id' } },
211
+ schema: { description: describeParam, id: { type: 'string', description: 'agent id' } },
207
212
  execute: ({ id }) => ({ cancelled: agents.cancel(id) }),
208
213
  },
209
214
  )
@@ -214,6 +219,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
214
219
  name: 'deliberate',
215
220
  description: 'Evaluate competing approaches, challenge a proposal, or reach a consequential decision under genuine uncertainty through a bounded, evidence-seeking exchange between two full tool-using agents. When the user requests deliberation, call this tool immediately with a self-contained brief: do not research first or approximate deliberation with multiple ordinary agents. Its participants own all supporting research. Use ordinary agents when independent work can be divided and collected. Do not deliberate routine implementation or questions answerable through direct research.',
216
221
  schema: {
222
+ description: describeParam,
217
223
  brief: { type: 'string', description: 'self-contained decision, relevant context, constraints, and desired outcome' },
218
224
  rounds: { type: 'integer', description: 'number of proposer-reviewer exchanges (1-5, default 3)', optional: true },
219
225
  },
@@ -242,6 +248,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
242
248
  name: 'recall',
243
249
  description: 'Load the full content of a saved memory by name. Your memory index is in the system prompt.',
244
250
  schema: {
251
+ description: describeParam,
245
252
  name: { type: 'string', description: 'the memory name from the index' },
246
253
  },
247
254
  execute: async ({ name }) => {
@@ -261,6 +268,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
261
268
  .map((s) => `- ${s.name}: ${s.description}`)
262
269
  .join('\n')}`,
263
270
  schema: {
271
+ description: describeParam,
264
272
  name: { type: 'string', description: 'skill name' },
265
273
  },
266
274
  execute: async ({ name }) => {
package/src/tools/read.js CHANGED
@@ -1,9 +1,27 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { resolve } from 'node:path'
4
+ import { extractText } from 'unpdf'
3
5
 
4
6
  const MAX_LINES = 2000
5
7
  const MAX_LINE_LENGTH = 2000
6
8
 
9
+ const isPdf = (buffer) => buffer.subarray(0, 5).toString('latin1') === '%PDF-'
10
+
11
+ // a pdf reads as its extracted text, one page after another, so the same
12
+ // offset and limit window applies
13
+ async function pdfLines(buffer) {
14
+ const { text, totalPages } = await extractText(new Uint8Array(buffer), { mergePages: false })
15
+ const pages = Array.isArray(text) ? text : [text]
16
+ const lines = []
17
+ pages.forEach((page, i) => {
18
+ if (i) lines.push('')
19
+ lines.push(`[page ${i + 1} of ${totalPages}]`)
20
+ lines.push(...String(page).split('\n'))
21
+ })
22
+ return lines
23
+ }
24
+
7
25
  function isBinary(buffer) {
8
26
  const len = Math.min(buffer.length, 8000)
9
27
  for (let i = 0; i < len; i++) {
@@ -17,7 +35,7 @@ export function createRead({ cwd, recorder, tracker }) {
17
35
  name: 'read',
18
36
  description: 'Read a file. Returns line-numbered content. Use offset/limit for large files.',
19
37
  schema: {
20
- description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
38
+ description: describeParam,
21
39
  path: { type: 'string', description: 'file path, relative to the working directory or absolute' },
22
40
  offset: { type: 'number', description: '1-indexed line to start from', optional: true },
23
41
  limit: { type: 'number', description: 'max lines to return', optional: true },
@@ -26,20 +44,21 @@ export function createRead({ cwd, recorder, tracker }) {
26
44
  const full = resolve(cwd, path)
27
45
  recorder.extra({ title: path })
28
46
  const buf = await readFile(full)
29
- if (isBinary(buf)) throw new Error(`${path} is a binary file`)
30
- const lines = buf.toString('utf-8').split('\n')
47
+ const lines = isPdf(buf) ? await pdfLines(buf) : null
48
+ if (!lines && isBinary(buf)) throw new Error(`${path} is a binary file`)
49
+ const source = lines ?? buf.toString('utf-8').split('\n')
31
50
 
32
51
  const start = Math.max(0, offset - 1)
33
52
  const count = Math.min(limit, MAX_LINES)
34
- const sliced = lines.slice(start, start + count)
53
+ const sliced = source.slice(start, start + count)
35
54
  const numbered = sliced
36
55
  .map((line, i) => `${start + i + 1}\t${line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '…' : line}`)
37
56
  .join('\n')
38
57
 
39
58
  recorder.extra({ fullOutput: sliced.join('\n') })
40
- const result = { content: numbered, totalLines: lines.length }
41
- if (start + count < lines.length) {
42
- result.note = `showing lines ${start + 1}-${start + sliced.length} of ${lines.length}`
59
+ const result = { content: numbered, totalLines: source.length }
60
+ if (start + count < source.length) {
61
+ result.note = `showing lines ${start + 1}-${start + sliced.length} of ${source.length}`
43
62
  }
44
63
  const context = tracker.check(full)
45
64
  if (context.length) result.context_from_agents_md = context
@@ -7,6 +7,13 @@ function collapseHomePath(value) {
7
7
  return value
8
8
  }
9
9
 
10
+ // every built-in tool takes this parameter; it is the line the human sees
11
+ // in the transcript while the call runs
12
+ export const describeParam = {
13
+ type: 'string',
14
+ description: 'what this call is doing, in present tense, a few words shown to the human watching, e.g. "Creating the test file" or "Collecting the worker results"',
15
+ }
16
+
10
17
  export function defaultTitle(name, args = {}) {
11
18
  const candidate = args.path || args.command || args.pattern || args.url || args.name
12
19
  if (typeof candidate === 'string' && candidate) return collapseHomePath(candidate)
package/src/tools/web.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  const DEFAULT_SLICE_CHARS = 24000
2
3
  const MAX_SLICE_CHARS = 100000
3
4
 
@@ -34,7 +35,7 @@ export function createWebTools({ dredge, recorder, signal }) {
34
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.',
35
36
  schema: {
36
37
  q: { type: 'string', description: 'the search query' },
37
- description: { type: 'string', description: 'briefly explain what this search is intended to find, shown to the human watching' },
38
+ description: describeParam,
38
39
  },
39
40
  execute: async ({ q }) => {
40
41
  recorder.extra({ title: q })
@@ -58,6 +59,7 @@ export function createWebTools({ dredge, recorder, signal }) {
58
59
  description:
59
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.',
60
61
  schema: {
62
+ description: describeParam,
61
63
  url: { type: 'string', description: 'the url to fetch' },
62
64
  cursor: { type: 'string', description: 'pagination cursor from a previous web_fetch of the same url', optional: true },
63
65
  maxChars: { type: 'number', description: `slice size in characters, default ${DEFAULT_SLICE_CHARS}, max ${MAX_SLICE_CHARS}`, optional: true },
@@ -1,3 +1,4 @@
1
+ import { describeParam } from './recorder.js'
1
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
3
  import { dirname, resolve } from 'node:path'
3
4
  import { makeWriteEdit } from '../reversible-edit.js'
@@ -8,7 +9,7 @@ export function createWrite({ cwd, recorder, tracker }) {
8
9
  name: 'write',
9
10
  description: 'Write content to a file, creating it and any parent directories if needed. Overwrites existing content.',
10
11
  schema: {
11
- description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
12
+ description: describeParam,
12
13
  path: { type: 'string', description: 'file path, relative to the working directory or absolute' },
13
14
  content: { type: 'string', description: 'the full file content to write' },
14
15
  },