picocode-core 0.9.133 → 0.9.135

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.133",
3
+ "version": "0.9.135",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.js CHANGED
@@ -3,6 +3,8 @@ import { join } from 'node:path'
3
3
  import { picoHome } from './paths.js'
4
4
  import { parseFrontmatter } from './skills.js'
5
5
 
6
+ const ARGUMENT = /\{\{([A-Za-z][A-Za-z0-9_-]*):(path|text|choice\(([^{}()]*)\))(?:\:([^{}]*))?\}\}/g
7
+
6
8
  export function globalCommandsDir() {
7
9
  return join(picoHome(), 'commands')
8
10
  }
@@ -11,6 +13,31 @@ export function projectCommandsDir(root) {
11
13
  return join(root, '.pico', 'commands')
12
14
  }
13
15
 
16
+ function inferredLabel(name) {
17
+ const words = name.replace(/[-_]+/g, ' ').trim()
18
+ return words ? words[0].toUpperCase() + words.slice(1) : name
19
+ }
20
+
21
+ export function parseCommandArguments(body) {
22
+ const arguments_ = []
23
+ const seen = new Set()
24
+ for (const match of body.matchAll(ARGUMENT)) {
25
+ const [, name, typeSpec, choicesText, explicitLabel] = match
26
+ if (seen.has(name)) continue
27
+ const type = typeSpec.startsWith('choice(') ? 'choice' : typeSpec
28
+ const choices = type === 'choice' ? choicesText.split(',').map((v) => v.trim()).filter(Boolean) : undefined
29
+ if (type === 'choice' && choices.length === 0) continue
30
+ seen.add(name)
31
+ arguments_.push({
32
+ name,
33
+ type,
34
+ label: explicitLabel?.trim() || inferredLabel(name),
35
+ ...(choices ? { choices } : {}),
36
+ })
37
+ }
38
+ return arguments_
39
+ }
40
+
14
41
  async function scanDir(dir, source) {
15
42
  let names = []
16
43
  try {
@@ -22,24 +49,42 @@ async function scanDir(dir, source) {
22
49
  for (const file of names) {
23
50
  if (!file.endsWith('.md')) continue
24
51
  try {
25
- const { meta } = parseFrontmatter(await readFile(join(dir, file), 'utf-8'))
52
+ const text = await readFile(join(dir, file), 'utf-8')
53
+ const { meta, body } = parseFrontmatter(text)
26
54
  commands.push({
27
55
  name: file.slice(0, -3),
28
56
  description: meta.description || '',
29
57
  source,
30
58
  file: join(dir, file),
59
+ arguments: parseCommandArguments(body),
31
60
  })
32
61
  } catch {}
33
62
  }
34
63
  return commands
35
64
  }
36
65
 
37
- export function expandCommand(body, args) {
38
- return body.includes('$ARGUMENTS')
39
- ? body.replaceAll('$ARGUMENTS', args)
66
+ export function expandCommand(body, args = '', namedValues = {}) {
67
+ if (args && typeof args === 'object' && !Array.isArray(args)) {
68
+ namedValues = args
69
+ args = ''
70
+ }
71
+ args = String(args ?? '')
72
+ namedValues = namedValues && typeof namedValues === 'object' ? namedValues : {}
73
+ const metadata = new Map(parseCommandArguments(body).map((argument) => [argument.name, argument]))
74
+ const rendered = body.replace(ARGUMENT, (markup, name) => {
75
+ const argument = metadata.get(name)
76
+ if (!argument) return markup
77
+ const value = Object.hasOwn(namedValues, name) ? String(namedValues[name] ?? '') : ''
78
+ if (argument.type === 'choice' && value && !argument.choices.includes(value)) {
79
+ throw new TypeError(`Invalid value for command argument "${name}"`)
80
+ }
81
+ return value
82
+ })
83
+ return rendered.includes('$ARGUMENTS')
84
+ ? rendered.replaceAll('$ARGUMENTS', () => args)
40
85
  : args
41
- ? `${body.trim()}\n\n${args}`
42
- : body
86
+ ? `${rendered.trim()}\n\n${args}`
87
+ : rendered
43
88
  }
44
89
 
45
90
  export async function createCommandIndex(root) {
@@ -51,11 +96,11 @@ export async function createCommandIndex(root) {
51
96
 
52
97
  return {
53
98
  list: () => commands,
54
- async load(name, args = '') {
99
+ async load(name, args = '', namedValues = {}) {
55
100
  const command = byName.get(name)
56
101
  if (!command) return null
57
102
  const { body } = parseFrontmatter(await readFile(command.file, 'utf-8'))
58
- return expandCommand(body.trim(), args.trim())
103
+ return expandCommand(body.trim(), args, namedValues)
59
104
  },
60
105
  }
61
106
  }
package/src/controller.js CHANGED
@@ -28,7 +28,7 @@ import { connectOpenAI, openaiCredentials, disconnectOpenAI } from './openai-aut
28
28
  import { agentScratchDir, ensureDir } from './paths.js'
29
29
  import { loadCodexModels } from './codex-models.js'
30
30
  import { fuzzyScore } from './fuzzy.js'
31
- import { finalizeUserContent, inputTextFromContent, mediaTypeFor } from './attachments.js'
31
+ import { buildUserContent, finalizeUserContent, inputTextFromContent, mediaTypeFor } from './attachments.js'
32
32
 
33
33
  export const EFFORT_LEVELS = [
34
34
  { key: null, desc: 'let the provider decide how much to think' },
@@ -364,8 +364,11 @@ export function createController({ boot }) {
364
364
  }
365
365
 
366
366
  async function executeTurn(text) {
367
- const { content, used } = finalizeUserContent(text, state.attachments)
368
- const viewed = used.length > 0 && used.every((placeholder) => deliveredImages.has(placeholder))
367
+ // an image the model asked to view arrives with its path in the label;
368
+ // that text must not be scanned for image paths or it attaches twice
369
+ const built = buildUserContent(text, state.attachments)
370
+ const viewed = built.used.length > 0 && built.used.every((placeholder) => deliveredImages.has(placeholder))
371
+ const { content, used } = viewed ? built : finalizeUserContent(text, state.attachments)
369
372
  for (const placeholder of used) deliveredImages.delete(placeholder)
370
373
  persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
371
374
  ensureSession()
@@ -999,8 +1002,8 @@ export function createController({ boot }) {
999
1002
  send(`Follow these skill instructions now.\n\n${body}`)
1000
1003
  }
1001
1004
 
1002
- async function sendCommand(name, args) {
1003
- const text = await boot.commands.load(name, args)
1005
+ async function sendCommand(name, args, namedValues) {
1006
+ const text = await boot.commands.load(name, args, namedValues)
1004
1007
  if (!text) return flash(`could not load command ${name}`)
1005
1008
  send(text)
1006
1009
  }
package/src/git.js CHANGED
@@ -48,6 +48,7 @@ export function createGitService({ onChange = () => {} } = {}) {
48
48
  let gitDir = null
49
49
  let epoch = 0
50
50
  let watcher = null
51
+ let treeWatcher = null
51
52
  let poll = null
52
53
  let debounce = null
53
54
  let child = null
@@ -58,6 +59,8 @@ export function createGitService({ onChange = () => {} } = {}) {
58
59
  epoch += 1
59
60
  watcher?.close()
60
61
  watcher = null
62
+ treeWatcher?.close()
63
+ treeWatcher = null
61
64
  if (poll) clearInterval(poll)
62
65
  poll = null
63
66
  if (debounce) clearTimeout(debounce)
@@ -85,6 +88,20 @@ export function createGitService({ onChange = () => {} } = {}) {
85
88
  } catch {
86
89
  watcher = null
87
90
  }
91
+ // the working tree too, so an edit shows up right away rather than at
92
+ // the next poll. recursive watching rides on FSEvents on macOS; the
93
+ // refresh is debounced so a burst of writes costs one git call
94
+ try {
95
+ treeWatcher = watch(root, { recursive: true }, (_event, file) => {
96
+ if (epoch !== started) return
97
+ const name = String(file ?? '')
98
+ if (name === '.git' || name.startsWith('.git/') || name.includes('node_modules')) return
99
+ refresh()
100
+ })
101
+ treeWatcher.on('error', () => {})
102
+ } catch {
103
+ treeWatcher = null
104
+ }
88
105
  poll = setInterval(run, POLL_MS)
89
106
  poll.unref?.()
90
107
  run()
package/src/skills.js CHANGED
@@ -121,8 +121,20 @@ Review $ARGUMENTS for security problems. Focus on input validation and secrets.
121
121
  \`\`\`
122
122
 
123
123
  \`$ARGUMENTS\` is replaced with whatever follows the command; without the placeholder,
124
- arguments are appended after the body. Commands are rescanned every turn, so a new
125
- command appears in the slash menu from the next message.`
124
+ arguments are appended after the body. For compact typed form fields, put named
125
+ placeholders directly in the Markdown body:
126
+
127
+ - \`{{file:path:File to review}}\` — path/file picker, named \`file\`
128
+ - \`{{focus:text:Review focus}}\` — text input, named \`focus\`
129
+ - \`{{mode:choice(review,fix):Mode}}\` — choice picker whose selected literal is inserted
130
+
131
+ The final label is optional and inferred from the name (for example, \`{{focus:text}}\`
132
+ is labelled "Focus"). Names start with a letter and may contain letters, digits, \`_\`,
133
+ and \`-\`. Keep choice values short and comma-separated. A placeholder may be repeated.
134
+ Named fields and \`$ARGUMENTS\` may coexist for backwards compatibility.
135
+
136
+ Commands are rescanned every turn, so a new command appears in the slash menu from the
137
+ next message.`
126
138
 
127
139
  export async function createSkillIndex(root) {
128
140
  const builtin = [