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.
Files changed (57) hide show
  1. package/README.md +15 -0
  2. package/package.json +33 -0
  3. package/src/agent-transcript.js +80 -0
  4. package/src/agent.js +170 -0
  5. package/src/agents.js +213 -0
  6. package/src/attachments.js +141 -0
  7. package/src/boot.js +28 -0
  8. package/src/catalog-snapshot.json +1 -0
  9. package/src/catalog.js +86 -0
  10. package/src/codex-models.js +56 -0
  11. package/src/commands.js +61 -0
  12. package/src/compaction.js +82 -0
  13. package/src/completion.js +21 -0
  14. package/src/config.js +32 -0
  15. package/src/context.js +82 -0
  16. package/src/controller.js +1263 -0
  17. package/src/conversation-search.js +101 -0
  18. package/src/deliberation-history.js +65 -0
  19. package/src/deliberation.js +61 -0
  20. package/src/derive.js +307 -0
  21. package/src/events.js +54 -0
  22. package/src/export.js +16 -0
  23. package/src/files.js +39 -0
  24. package/src/format.js +6 -0
  25. package/src/fuzzy.js +41 -0
  26. package/src/git.js +156 -0
  27. package/src/history.js +51 -0
  28. package/src/init.js +25 -0
  29. package/src/keys.js +26 -0
  30. package/src/mcp.js +280 -0
  31. package/src/memory.js +120 -0
  32. package/src/models.js +14 -0
  33. package/src/openai-auth.js +204 -0
  34. package/src/paths.js +67 -0
  35. package/src/reversible-edit.js +79 -0
  36. package/src/rewind.js +84 -0
  37. package/src/session-index.js +264 -0
  38. package/src/session-lock.js +27 -0
  39. package/src/session.js +160 -0
  40. package/src/shells.js +166 -0
  41. package/src/skills.js +164 -0
  42. package/src/steer.js +129 -0
  43. package/src/system-prompt.js +49 -0
  44. package/src/terminal-theme.js +49 -0
  45. package/src/tools/bash.js +184 -0
  46. package/src/tools/diff.js +18 -0
  47. package/src/tools/edit.js +95 -0
  48. package/src/tools/glob.js +43 -0
  49. package/src/tools/grep.js +59 -0
  50. package/src/tools/index.js +296 -0
  51. package/src/tools/read.js +49 -0
  52. package/src/tools/recorder.js +74 -0
  53. package/src/tools/web.js +84 -0
  54. package/src/tools/write.js +48 -0
  55. package/src/update.js +82 -0
  56. package/src/user-tools.js +59 -0
  57. 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
+ }