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 +1 -1
- package/src/commands.js +53 -8
- package/src/controller.js +8 -5
- package/src/git.js +17 -0
- package/src/skills.js +14 -2
package/package.json
CHANGED
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
? `${
|
|
42
|
-
:
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
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.
|
|
125
|
-
|
|
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 = [
|