picocode-core 0.9.119
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/README.md +15 -0
- package/package.json +33 -0
- package/src/agent-transcript.js +80 -0
- package/src/agent.js +170 -0
- package/src/agents.js +213 -0
- package/src/attachments.js +141 -0
- package/src/boot.js +28 -0
- package/src/catalog-snapshot.json +1 -0
- package/src/catalog.js +86 -0
- package/src/codex-models.js +56 -0
- package/src/commands.js +61 -0
- package/src/compaction.js +82 -0
- package/src/completion.js +21 -0
- package/src/config.js +32 -0
- package/src/context.js +82 -0
- package/src/controller.js +1263 -0
- package/src/conversation-search.js +101 -0
- package/src/deliberation-history.js +65 -0
- package/src/deliberation.js +61 -0
- package/src/derive.js +307 -0
- package/src/events.js +54 -0
- package/src/export.js +16 -0
- package/src/files.js +39 -0
- package/src/format.js +6 -0
- package/src/fuzzy.js +41 -0
- package/src/git.js +156 -0
- package/src/history.js +51 -0
- package/src/init.js +25 -0
- package/src/keys.js +26 -0
- package/src/mcp.js +280 -0
- package/src/memory.js +120 -0
- package/src/models.js +14 -0
- package/src/openai-auth.js +204 -0
- package/src/paths.js +67 -0
- package/src/reversible-edit.js +79 -0
- package/src/rewind.js +84 -0
- package/src/session-index.js +264 -0
- package/src/session-lock.js +27 -0
- package/src/session.js +160 -0
- package/src/shells.js +166 -0
- package/src/skills.js +164 -0
- package/src/steer.js +129 -0
- package/src/system-prompt.js +49 -0
- package/src/terminal-theme.js +49 -0
- package/src/tools/bash.js +184 -0
- package/src/tools/diff.js +18 -0
- package/src/tools/edit.js +95 -0
- package/src/tools/glob.js +43 -0
- package/src/tools/grep.js +59 -0
- package/src/tools/index.js +296 -0
- package/src/tools/read.js +49 -0
- package/src/tools/recorder.js +74 -0
- package/src/tools/web.js +84 -0
- package/src/tools/write.js +48 -0
- package/src/update.js +82 -0
- package/src/user-tools.js +59 -0
- package/src/wakeups.js +40 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { makeReversibleEdit } from '../reversible-edit.js'
|
|
4
|
+
import { makeDiff } from './diff.js'
|
|
5
|
+
|
|
6
|
+
const LOOKALIKES = { '‘': "'", '’': "'", '“': '"', '”': '"', '–': '-', '—': '-' }
|
|
7
|
+
|
|
8
|
+
// matching tolerates cosmetic differences the model routinely gets wrong:
|
|
9
|
+
// trailing whitespace, smart quotes, dashes. the normalized text is only ever
|
|
10
|
+
// used to find the span; offsets map back so the file is spliced at its own
|
|
11
|
+
// bytes and nothing outside the replaced span is rewritten
|
|
12
|
+
function normalize(text) {
|
|
13
|
+
const chars = []
|
|
14
|
+
const offsets = []
|
|
15
|
+
let last = 0
|
|
16
|
+
const keep = (from, to) => {
|
|
17
|
+
for (let i = from; i < to; i++) {
|
|
18
|
+
chars.push(LOOKALIKES[text[i]] || text[i])
|
|
19
|
+
offsets.push(i)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const match of text.matchAll(/[^\S\n]+$/gm)) {
|
|
23
|
+
keep(last, match.index)
|
|
24
|
+
last = match.index + match[0].length
|
|
25
|
+
}
|
|
26
|
+
keep(last, text.length)
|
|
27
|
+
return { text: chars.join(''), offsets }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function locate(content, old, path) {
|
|
31
|
+
const multiple = () => new Error(`string appears multiple times in ${path}, provide more surrounding context to make it unique`)
|
|
32
|
+
|
|
33
|
+
const exact = content.indexOf(old)
|
|
34
|
+
if (exact !== -1) {
|
|
35
|
+
if (content.indexOf(old, exact + 1) !== -1) throw multiple()
|
|
36
|
+
return { start: exact, end: exact + old.length }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const normalizedContent = normalize(content)
|
|
40
|
+
const normalizedOld = normalize(old).text
|
|
41
|
+
if (!normalizedOld) throw new Error(`string not found in ${path}`)
|
|
42
|
+
const at = normalizedContent.text.indexOf(normalizedOld)
|
|
43
|
+
if (at === -1) throw new Error(`string not found in ${path}`)
|
|
44
|
+
if (normalizedContent.text.indexOf(normalizedOld, at + 1) !== -1) throw multiple()
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
start: normalizedContent.offsets[at],
|
|
48
|
+
end: normalizedContent.offsets[at + normalizedOld.length - 1] + 1,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createEdit({ cwd, recorder, tracker }) {
|
|
53
|
+
return {
|
|
54
|
+
name: 'edit',
|
|
55
|
+
description: 'Replace oldText with newText in a file. oldText must appear exactly once unless replaceAll is set.',
|
|
56
|
+
schema: {
|
|
57
|
+
description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
|
|
58
|
+
path: { type: 'string', description: 'file path, relative to the working directory or absolute' },
|
|
59
|
+
oldText: { type: 'string', description: 'exact text to replace, must be unique in the file' },
|
|
60
|
+
newText: { type: 'string', description: 'replacement text' },
|
|
61
|
+
replaceAll: { type: 'boolean', description: 'replace every occurrence', optional: true },
|
|
62
|
+
},
|
|
63
|
+
execute: async ({ path, oldText, newText, replaceAll }) => {
|
|
64
|
+
const full = resolve(cwd, path)
|
|
65
|
+
recorder.extra({ title: path })
|
|
66
|
+
const before = await readFile(full, 'utf-8')
|
|
67
|
+
|
|
68
|
+
let after
|
|
69
|
+
let splices
|
|
70
|
+
if (replaceAll) {
|
|
71
|
+
if (!oldText) throw new Error('oldText must not be empty when replaceAll is set')
|
|
72
|
+
if (!before.includes(oldText)) throw new Error(`string not found in ${path}`)
|
|
73
|
+
splices = []
|
|
74
|
+
for (let start = before.indexOf(oldText); start !== -1; start = before.indexOf(oldText, start + oldText.length)) {
|
|
75
|
+
splices.push({ start, oldText, newText })
|
|
76
|
+
}
|
|
77
|
+
after = before.split(oldText).join(newText)
|
|
78
|
+
} else {
|
|
79
|
+
const { start, end } = locate(before, oldText, path)
|
|
80
|
+
const actualOldText = before.slice(start, end)
|
|
81
|
+
splices = [{ start, oldText: actualOldText, newText }]
|
|
82
|
+
after = before.slice(0, start) + newText + before.slice(end)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await writeFile(full, after, 'utf-8')
|
|
86
|
+
const diff = makeDiff(path, before, after)
|
|
87
|
+
recorder.extra({ diff, revert: makeReversibleEdit(full, before, after, splices) })
|
|
88
|
+
|
|
89
|
+
const result = { ok: true, path, additions: diff.additions, deletions: diff.deletions }
|
|
90
|
+
const context = tracker.check(full)
|
|
91
|
+
if (context.length) result.context_from_agents_md = context
|
|
92
|
+
return result
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import fg from 'fast-glob'
|
|
3
|
+
|
|
4
|
+
const MAX_RESULTS = 200
|
|
5
|
+
|
|
6
|
+
function ripgrepGlob(pattern, cwd) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const args = ['--files', '--hidden', '--glob', pattern, '--glob', '!**/node_modules/**', '--glob', '!**/.git/**']
|
|
9
|
+
execFile('rg', args, { cwd, maxBuffer: 50 * 1024 * 1024 }, (err, stdout) => {
|
|
10
|
+
if (err && err.code !== 1) resolve(null)
|
|
11
|
+
else resolve(stdout ? stdout.split('\n').filter(Boolean) : [])
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createGlob({ cwd, recorder }) {
|
|
17
|
+
return {
|
|
18
|
+
name: 'glob',
|
|
19
|
+
description: 'Find files matching a glob pattern, relative to the working directory. Respects .gitignore; use bash to look inside ignored paths.',
|
|
20
|
+
schema: {
|
|
21
|
+
description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
|
|
22
|
+
pattern: { type: 'string', description: 'glob pattern, e.g. src/**/*.js' },
|
|
23
|
+
maxResults: { type: 'number', description: 'cap on returned paths, default 200', optional: true },
|
|
24
|
+
},
|
|
25
|
+
execute: async ({ pattern, maxResults = MAX_RESULTS }) => {
|
|
26
|
+
recorder.extra({ title: pattern })
|
|
27
|
+
let files = await ripgrepGlob(pattern, cwd)
|
|
28
|
+
if (files === null) {
|
|
29
|
+
files = await fg(pattern, {
|
|
30
|
+
cwd,
|
|
31
|
+
dot: true,
|
|
32
|
+
ignore: ['**/node_modules/**', '**/.git/**'],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
recorder.extra({ fullOutput: files.join('\n') })
|
|
36
|
+
return {
|
|
37
|
+
files: files.slice(0, maxResults),
|
|
38
|
+
totalFound: files.length,
|
|
39
|
+
truncated: files.length > maxResults,
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MAX_RESULTS = 200
|
|
5
|
+
|
|
6
|
+
function runRipgrep(args, cwd) {
|
|
7
|
+
return new Promise((done) => {
|
|
8
|
+
execFile('rg', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
9
|
+
done(err && !stdout ? '' : stdout || '')
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createGrep({ cwd, recorder }) {
|
|
15
|
+
return {
|
|
16
|
+
name: 'grep',
|
|
17
|
+
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
|
+
schema: {
|
|
19
|
+
description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
|
|
20
|
+
pattern: { type: 'string', description: 'regex pattern' },
|
|
21
|
+
path: { type: 'string', description: 'file or directory to search, defaults to the working directory', optional: true },
|
|
22
|
+
mode: { type: 'string', enum: ['content', 'files', 'count'], optional: true },
|
|
23
|
+
glob: { type: 'string', description: 'filter files by glob, e.g. *.js', optional: true },
|
|
24
|
+
ignoreCase: { type: 'boolean', optional: true },
|
|
25
|
+
context: { type: 'number', description: 'lines of context around matches', optional: true },
|
|
26
|
+
multiline: { type: 'boolean', description: 'allow patterns to span lines', optional: true },
|
|
27
|
+
limit: { type: 'number', description: 'max results, default 200', optional: true },
|
|
28
|
+
},
|
|
29
|
+
execute: async ({ pattern, path, mode = 'content', glob, ignoreCase, context, multiline, limit = MAX_RESULTS }) => {
|
|
30
|
+
if (typeof pattern !== 'string') throw new Error('grep pattern is required')
|
|
31
|
+
recorder.extra({ title: pattern })
|
|
32
|
+
const args = ['--no-heading', '--color=never', '--max-columns', '500', '--hidden', '--glob', '!**/.git/**']
|
|
33
|
+
|
|
34
|
+
if (mode === 'files') args.push('--files-with-matches')
|
|
35
|
+
else if (mode === 'count') args.push('--count')
|
|
36
|
+
else args.push('--line-number')
|
|
37
|
+
|
|
38
|
+
if (ignoreCase) args.push('--ignore-case')
|
|
39
|
+
if (multiline) args.push('--multiline', '--multiline-dotall')
|
|
40
|
+
if (glob) args.push('--glob', glob)
|
|
41
|
+
if (mode === 'content' && context) args.push('-C', String(context))
|
|
42
|
+
|
|
43
|
+
args.push(pattern.startsWith('-') ? '-e' : '--', pattern)
|
|
44
|
+
args.push(path ? resolve(cwd, path) : cwd)
|
|
45
|
+
|
|
46
|
+
const stdout = await runRipgrep(args, cwd)
|
|
47
|
+
const lines = stdout.split('\n').filter(Boolean)
|
|
48
|
+
recorder.extra({ fullOutput: lines.join('\n') })
|
|
49
|
+
|
|
50
|
+
const relativize = (line) => {
|
|
51
|
+
if (mode === 'files') return relative(cwd, line)
|
|
52
|
+
const m = line.match(/^(\/[^:]+):(.*)$/)
|
|
53
|
+
return m ? `${relative(cwd, m[1])}:${m[2]}` : line
|
|
54
|
+
}
|
|
55
|
+
const results = lines.map(relativize).slice(0, limit)
|
|
56
|
+
return { results, mode, totalMatches: lines.length, truncated: lines.length > limit }
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createRecorder, recorded } from './recorder.js'
|
|
2
|
+
import { createRead } from './read.js'
|
|
3
|
+
import { createWrite } from './write.js'
|
|
4
|
+
import { createEdit } from './edit.js'
|
|
5
|
+
import { createBash } from './bash.js'
|
|
6
|
+
import { createGlob } from './glob.js'
|
|
7
|
+
import { createGrep } from './grep.js'
|
|
8
|
+
import { createWebTools } from './web.js'
|
|
9
|
+
|
|
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
|
+
const recorder = createRecorder(onToolUpdate)
|
|
12
|
+
let agentStarts = 0
|
|
13
|
+
let plannedAgentStarts = requireAgentPlan ? null : maxAgentStarts
|
|
14
|
+
const deps = { cwd, env, recorder, tracker, signal, shells, sessionId, sessionFile }
|
|
15
|
+
|
|
16
|
+
const local = [
|
|
17
|
+
createRead(deps),
|
|
18
|
+
createWrite(deps),
|
|
19
|
+
createEdit(deps),
|
|
20
|
+
createBash(deps),
|
|
21
|
+
createGlob(deps),
|
|
22
|
+
createGrep(deps),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
if (dredge) {
|
|
26
|
+
local.push(...createWebTools({ dredge, recorder, signal }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (shells) {
|
|
30
|
+
local.push(
|
|
31
|
+
{
|
|
32
|
+
name: 'shell_output',
|
|
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
|
+
schema: {
|
|
35
|
+
id: { type: 'string', description: 'the shell id' },
|
|
36
|
+
tail: { type: 'number', description: 'how many trailing lines to return, default 100', optional: true },
|
|
37
|
+
},
|
|
38
|
+
execute: ({ id, tail = 100 }) => {
|
|
39
|
+
const result = shells.output(id, { tail: Math.min(tail, 500) })
|
|
40
|
+
if (result.status === 'exited') {
|
|
41
|
+
result.note = 'This shell has exited and its output is complete. Do not call shell_output again for this shell.'
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'shell_kill',
|
|
48
|
+
description: 'Stop a background shell by id.',
|
|
49
|
+
schema: {
|
|
50
|
+
id: { type: 'string', description: 'the shell id' },
|
|
51
|
+
},
|
|
52
|
+
execute: ({ id }) => shells.kill(id, 'model'),
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (wakeups) {
|
|
58
|
+
local.push(
|
|
59
|
+
{
|
|
60
|
+
name: 'schedule_wakeup',
|
|
61
|
+
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
|
+
schema: {
|
|
63
|
+
description: { type: 'string', description: 'briefly explain why this wake-up is needed, shown to the human watching' },
|
|
64
|
+
delaySeconds: { type: 'number', description: 'seconds from now, minimum 5' },
|
|
65
|
+
note: { type: 'string', description: 'what to do when you wake up; written to your future self' },
|
|
66
|
+
},
|
|
67
|
+
execute: ({ delaySeconds, note }) => {
|
|
68
|
+
const { id, at, seconds } = wakeups.schedule(delaySeconds, note)
|
|
69
|
+
return { wakeupId: id, firesAt: new Date(at).toString(), inSeconds: seconds }
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'cancel_wakeup',
|
|
74
|
+
description: 'Cancel a pending wake-up by id.',
|
|
75
|
+
schema: {
|
|
76
|
+
id: { type: 'string', description: 'the wake-up id' },
|
|
77
|
+
},
|
|
78
|
+
execute: ({ id }) => wakeups.cancel(id),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'list_wakeups',
|
|
82
|
+
description: 'List your pending scheduled wake-ups: id, when each fires, and its note.',
|
|
83
|
+
schema: {},
|
|
84
|
+
execute: () => ({
|
|
85
|
+
wakeups: wakeups.list().map((w) => ({
|
|
86
|
+
id: w.id,
|
|
87
|
+
firesAt: new Date(w.at).toString(),
|
|
88
|
+
inSeconds: Math.max(0, Math.round((w.at - Date.now()) / 1000)),
|
|
89
|
+
note: w.note,
|
|
90
|
+
})),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (askUser) {
|
|
97
|
+
local.push({
|
|
98
|
+
name: 'ask_user',
|
|
99
|
+
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
|
+
schema: {
|
|
101
|
+
questions: {
|
|
102
|
+
type: 'array',
|
|
103
|
+
description: 'questions to present, in order',
|
|
104
|
+
items: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
id: { type: 'string', description: 'short unique identifier' },
|
|
108
|
+
question: { type: 'string', description: 'clear question shown to the user' },
|
|
109
|
+
description: { type: 'string', description: 'brief context explaining why this matters', optional: true },
|
|
110
|
+
type: { type: 'string', enum: ['single', 'multi', 'text'], description: 'answer control' },
|
|
111
|
+
options: {
|
|
112
|
+
type: 'array', optional: true, description: 'choices for single or multi questions',
|
|
113
|
+
items: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
label: { type: 'string' },
|
|
117
|
+
description: { type: 'string', optional: true },
|
|
118
|
+
},
|
|
119
|
+
required: ['label'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
allowOther: { type: 'boolean', optional: true, description: 'allow a custom answer; defaults to true for choice questions' },
|
|
123
|
+
},
|
|
124
|
+
required: ['id', 'question', 'type'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
execute: ({ questions }) => {
|
|
129
|
+
if (!Array.isArray(questions) || questions.length === 0 || questions.length > 10) throw new Error('ask_user requires 1-10 questions')
|
|
130
|
+
const ids = new Set()
|
|
131
|
+
for (const question of questions) {
|
|
132
|
+
if (!question.id?.trim() || ids.has(question.id)) throw new Error('each question needs a unique non-empty id')
|
|
133
|
+
if (!question.question?.trim()) throw new Error(`question ${question.id} has no prompt`)
|
|
134
|
+
if (!['single', 'multi', 'text'].includes(question.type)) throw new Error(`question ${question.id} has an invalid type`)
|
|
135
|
+
if (question.type !== 'text' && (!Array.isArray(question.options) || question.options.length === 0)) throw new Error(`question ${question.id} needs at least one option`)
|
|
136
|
+
ids.add(question.id)
|
|
137
|
+
}
|
|
138
|
+
return askUser(questions)
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (agents) {
|
|
144
|
+
local.push(
|
|
145
|
+
{
|
|
146
|
+
name: 'agent_plan',
|
|
147
|
+
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
|
+
schema: {
|
|
149
|
+
count: { type: 'integer', description: `background workers permitted this turn, excluding the main agent (1-${maxAgentStarts || 100})` },
|
|
150
|
+
reason: { type: 'string', description: 'brief explanation of how the count follows the user request or research scope' },
|
|
151
|
+
},
|
|
152
|
+
execute: ({ count, reason }) => {
|
|
153
|
+
const ceiling = maxAgentStarts || 100
|
|
154
|
+
if (!Number.isInteger(count) || count < 1 || count > ceiling) throw new Error(`agent budget must be between 1 and ${ceiling}`)
|
|
155
|
+
if (agentStarts > 0) throw new Error('agent budget must be declared before starting agents')
|
|
156
|
+
plannedAgentStarts = count
|
|
157
|
+
return { agentLimit: count, reason }
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'agent_start',
|
|
162
|
+
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
|
+
schema: {
|
|
164
|
+
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' },
|
|
166
|
+
tools: { type: 'array', items: { type: 'string' }, description: 'tool names to allow; omit for the configured worker tools', optional: true },
|
|
167
|
+
},
|
|
168
|
+
execute: ({ prompt, description, tools }) => {
|
|
169
|
+
if (plannedAgentStarts == null) throw new Error('call agent_plan before starting research agents')
|
|
170
|
+
if (agentStarts >= plannedAgentStarts) throw new Error(`agent limit reached for this turn (${plannedAgentStarts}); completed or collected workers do not restore budget within the turn`)
|
|
171
|
+
const agent = agents.start({ prompt, description, tools, sessionId, sessionFile })
|
|
172
|
+
agentStarts++
|
|
173
|
+
return { agentId: agent.id, status: agent.status, model: agent.model }
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'agent_list',
|
|
178
|
+
description: 'List background agents and their current status.',
|
|
179
|
+
schema: {},
|
|
180
|
+
execute: () => ({ agents: agents.list().map(({ id, description, model, status }) => ({ id, description, model, status })) }),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'agent_collect',
|
|
184
|
+
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' } },
|
|
186
|
+
execute: async ({ ids }) => {
|
|
187
|
+
const requested = (ids || []).map(String)
|
|
188
|
+
const collected = await agents.collect(requested)
|
|
189
|
+
onAgentsCollected?.(collected.map((agent) => agent.id))
|
|
190
|
+
const found = new Set(collected.map((agent) => String(agent.id)))
|
|
191
|
+
return {
|
|
192
|
+
agents: [
|
|
193
|
+
...collected.map(({ id, status, result, error }) => ({ id, status, result, error })),
|
|
194
|
+
...requested.filter((id) => !found.has(id)).map((id) => ({
|
|
195
|
+
id,
|
|
196
|
+
status: 'unknown',
|
|
197
|
+
error: `no agent with id ${id}; it was never started, or was dismissed. Check agent_list rather than retrying.`,
|
|
198
|
+
})),
|
|
199
|
+
],
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'agent_cancel',
|
|
205
|
+
description: 'Cancel a queued or running background agent.',
|
|
206
|
+
schema: { id: { type: 'string', description: 'agent id' } },
|
|
207
|
+
execute: ({ id }) => ({ cancelled: agents.cancel(id) }),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (deliberations) {
|
|
213
|
+
local.push({
|
|
214
|
+
name: 'deliberate',
|
|
215
|
+
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
|
+
schema: {
|
|
217
|
+
brief: { type: 'string', description: 'self-contained decision, relevant context, constraints, and desired outcome' },
|
|
218
|
+
rounds: { type: 'integer', description: 'number of proposer-reviewer exchanges (1-5, default 3)', optional: true },
|
|
219
|
+
},
|
|
220
|
+
execute: ({ brief, rounds }) => deliberations.run({ brief, rounds, sessionId, sessionFile, signal }),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (memory) {
|
|
225
|
+
local.push(
|
|
226
|
+
{
|
|
227
|
+
name: 'remember',
|
|
228
|
+
description: 'Save a durable memory for future sessions. Use for corrections, preferences, and non-obvious facts worth keeping: a flaky test, a build quirk, something the user asked you to remember. Do not save what the code or git history already records, or details that only matter this session. Never duplicate what a skill or AGENTS.md already covers: those are already in your context every session, and a memory copy goes stale the moment they are edited. If asked to learn something a skill covers, say the skill already covers it instead of saving. Choose scope by applicability: if the fact would hold in a different project, use global; if it is tied to this codebase or how the user works here, use project. Always state the chosen scope in your reply so the user can correct it, and if applicability is genuinely unclear, ask before saving.',
|
|
229
|
+
schema: {
|
|
230
|
+
name: { type: 'string', description: 'short kebab-case identifier' },
|
|
231
|
+
description: { type: 'string', description: 'one line used to decide when to recall this; write it as a hook, not a summary' },
|
|
232
|
+
content: { type: 'string', description: 'the memory itself' },
|
|
233
|
+
scope: { type: 'string', enum: ['project', 'global'], optional: true },
|
|
234
|
+
},
|
|
235
|
+
execute: async ({ name, description, content, scope }) => {
|
|
236
|
+
const saved = await memory.remember({ name, description, content, scope })
|
|
237
|
+
recorder.extra({ title: `${saved.name} (${saved.scope})` })
|
|
238
|
+
return saved
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'recall',
|
|
243
|
+
description: 'Load the full content of a saved memory by name. Your memory index is in the system prompt.',
|
|
244
|
+
schema: {
|
|
245
|
+
name: { type: 'string', description: 'the memory name from the index' },
|
|
246
|
+
},
|
|
247
|
+
execute: async ({ name }) => {
|
|
248
|
+
const found = await memory.recall(name)
|
|
249
|
+
recorder.extra({ title: found.name })
|
|
250
|
+
return found
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (skills?.list().length) {
|
|
257
|
+
local.push({
|
|
258
|
+
name: 'skill',
|
|
259
|
+
description: `Load a skill by name and follow its instructions. Available skills:\n${skills
|
|
260
|
+
.list()
|
|
261
|
+
.map((s) => `- ${s.name}: ${s.description}`)
|
|
262
|
+
.join('\n')}`,
|
|
263
|
+
schema: {
|
|
264
|
+
name: { type: 'string', description: 'skill name' },
|
|
265
|
+
},
|
|
266
|
+
execute: async ({ name }) => {
|
|
267
|
+
recorder.extra({ title: name })
|
|
268
|
+
const body = await skills.load(name)
|
|
269
|
+
if (!body) throw new Error(`no skill named ${name}`)
|
|
270
|
+
return { instructions: body }
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const describedToolNames = new Set(['read', 'write', 'edit', 'bash', 'glob', 'grep', 'web_search', 'schedule_wakeup'])
|
|
276
|
+
const describedTools = new Set(local.filter((tool) => describedToolNames.has(tool.name)).map((tool) => tool.name))
|
|
277
|
+
const byName = new Map()
|
|
278
|
+
for (const tool of [...local, ...userTools, ...mcpTools]) {
|
|
279
|
+
if (allowNames && !allowNames.includes(tool.name)) continue
|
|
280
|
+
if (!byName.has(tool.name)) byName.set(tool.name, tool)
|
|
281
|
+
}
|
|
282
|
+
const tools = [...byName.values()].map((tool) => ({
|
|
283
|
+
...tool,
|
|
284
|
+
execute: recorded(recorder, tool.name, async (args) => {
|
|
285
|
+
if (describedTools.has(tool.name) && !args.description?.trim()) {
|
|
286
|
+
throw new Error('description is required; retry with a brief explanation of why this call is needed')
|
|
287
|
+
}
|
|
288
|
+
if (maxToolCalls && recorder.entries.length >= maxToolCalls) {
|
|
289
|
+
throw new Error('tool call limit reached for this run; do not call more tools, summarize what you have and finish')
|
|
290
|
+
}
|
|
291
|
+
return tool.execute(args)
|
|
292
|
+
}),
|
|
293
|
+
}))
|
|
294
|
+
|
|
295
|
+
return { tools, recorder }
|
|
296
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MAX_LINES = 2000
|
|
5
|
+
const MAX_LINE_LENGTH = 2000
|
|
6
|
+
|
|
7
|
+
function isBinary(buffer) {
|
|
8
|
+
const len = Math.min(buffer.length, 8000)
|
|
9
|
+
for (let i = 0; i < len; i++) {
|
|
10
|
+
if (buffer[i] === 0) return true
|
|
11
|
+
}
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createRead({ cwd, recorder, tracker }) {
|
|
16
|
+
return {
|
|
17
|
+
name: 'read',
|
|
18
|
+
description: 'Read a file. Returns line-numbered content. Use offset/limit for large files.',
|
|
19
|
+
schema: {
|
|
20
|
+
description: { type: 'string', description: 'briefly explain why this tool call is needed, shown to the human watching' },
|
|
21
|
+
path: { type: 'string', description: 'file path, relative to the working directory or absolute' },
|
|
22
|
+
offset: { type: 'number', description: '1-indexed line to start from', optional: true },
|
|
23
|
+
limit: { type: 'number', description: 'max lines to return', optional: true },
|
|
24
|
+
},
|
|
25
|
+
execute: async ({ path, offset = 1, limit = MAX_LINES }) => {
|
|
26
|
+
const full = resolve(cwd, path)
|
|
27
|
+
recorder.extra({ title: path })
|
|
28
|
+
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')
|
|
31
|
+
|
|
32
|
+
const start = Math.max(0, offset - 1)
|
|
33
|
+
const count = Math.min(limit, MAX_LINES)
|
|
34
|
+
const sliced = lines.slice(start, start + count)
|
|
35
|
+
const numbered = sliced
|
|
36
|
+
.map((line, i) => `${start + i + 1}\t${line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '…' : line}`)
|
|
37
|
+
.join('\n')
|
|
38
|
+
|
|
39
|
+
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}`
|
|
43
|
+
}
|
|
44
|
+
const context = tracker.check(full)
|
|
45
|
+
if (context.length) result.context_from_agents_md = context
|
|
46
|
+
return result
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
|
|
3
|
+
function collapseHomePath(value) {
|
|
4
|
+
const home = homedir()
|
|
5
|
+
if (value === home) return '~'
|
|
6
|
+
if (value.startsWith(`${home}/`) || value.startsWith(`${home}\\`)) return `~${value.slice(home.length)}`
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defaultTitle(name, args = {}) {
|
|
11
|
+
const candidate = args.path || args.command || args.pattern || args.url || args.name
|
|
12
|
+
if (typeof candidate === 'string' && candidate) return collapseHomePath(candidate)
|
|
13
|
+
const raw = JSON.stringify(args)
|
|
14
|
+
if (!raw || raw === '{}') return name
|
|
15
|
+
return raw.length > 60 ? raw.slice(0, 60) + '…' : raw
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRecorder(onChange) {
|
|
19
|
+
return {
|
|
20
|
+
currentCall: null,
|
|
21
|
+
entries: [],
|
|
22
|
+
pending: null,
|
|
23
|
+
begin(name, args) {
|
|
24
|
+
this.pending = {
|
|
25
|
+
callId: this.currentCall?.id ?? null,
|
|
26
|
+
name,
|
|
27
|
+
title: defaultTitle(name, args),
|
|
28
|
+
description: args.description,
|
|
29
|
+
status: 'done',
|
|
30
|
+
startedAt: Date.now(),
|
|
31
|
+
}
|
|
32
|
+
onChange?.(this.pending)
|
|
33
|
+
},
|
|
34
|
+
extra(fields) {
|
|
35
|
+
if (!this.pending) return
|
|
36
|
+
const normalized = typeof fields.title === 'string'
|
|
37
|
+
? { ...fields, title: collapseHomePath(fields.title) }
|
|
38
|
+
: fields
|
|
39
|
+
Object.assign(this.pending, normalized)
|
|
40
|
+
onChange?.(this.pending)
|
|
41
|
+
},
|
|
42
|
+
done(fields = {}) {
|
|
43
|
+
if (!this.pending) return
|
|
44
|
+
Object.assign(this.pending, fields, {
|
|
45
|
+
durationMs: Date.now() - this.pending.startedAt,
|
|
46
|
+
})
|
|
47
|
+
delete this.pending.startedAt
|
|
48
|
+
if (this.pending.fullOutput?.length > 200000) {
|
|
49
|
+
this.pending.fullOutput = this.pending.fullOutput.slice(0, 200000) + '\n[truncated]'
|
|
50
|
+
}
|
|
51
|
+
this.entries.push(this.pending)
|
|
52
|
+
this.pending = null
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function recorded(recorder, name, fn) {
|
|
58
|
+
return async (args) => {
|
|
59
|
+
recorder.begin(name, args)
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn(args)
|
|
62
|
+
if (!recorder.pending?.fullOutput && result !== undefined) {
|
|
63
|
+
recorder.extra({
|
|
64
|
+
fullOutput: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
recorder.done()
|
|
68
|
+
return result
|
|
69
|
+
} catch (err) {
|
|
70
|
+
recorder.done({ status: 'error', error: String(err.message || err) })
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|