pi-code 1.0.7 → 1.0.8
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/extensions/commands.ts +24 -5
- package/extensions/hooks.ts +7 -1
- package/extensions/init.ts +3 -1
- package/extensions/internal/model-complete.ts +6 -4
- package/extensions/mcp.ts +3 -1
- package/extensions/memory.ts +42 -29
- package/extensions/notify.ts +5 -0
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +56 -23
- package/extensions/todo.ts +11 -1
- package/extensions/web.ts +12 -7
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { Type } from 'typebox'
|
|
|
46
46
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
47
47
|
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
48
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
49
|
+
import { capForContext } from './internal/output-guard.js'
|
|
49
50
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
50
51
|
import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
|
|
51
52
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
@@ -315,12 +316,17 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
315
316
|
pendingBashRules = undefined
|
|
316
317
|
pendingPathRules = undefined
|
|
317
318
|
if (pendingModelRestore) {
|
|
318
|
-
|
|
319
|
+
const restore = pendingModelRestore as Parameters<typeof pi.setModel>[0]
|
|
319
320
|
pendingModelRestore = undefined
|
|
321
|
+
// setModel can reject (e.g. auth resolution fails), and a floated rejection would
|
|
322
|
+
// escape as unhandled; surface it instead of leaving the session silently on the
|
|
323
|
+
// command's override model.
|
|
324
|
+
void pi.setModel(restore).catch(() => {})
|
|
325
|
+
}
|
|
326
|
+
if (pendingRestore) {
|
|
327
|
+
pi.setActiveTools(pendingRestore)
|
|
328
|
+
pendingRestore = undefined
|
|
320
329
|
}
|
|
321
|
-
if (!pendingRestore) return
|
|
322
|
-
pi.setActiveTools(pendingRestore)
|
|
323
|
-
pendingRestore = undefined
|
|
324
330
|
})
|
|
325
331
|
|
|
326
332
|
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
@@ -416,6 +422,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
416
422
|
// restored when that run ends. Restoring inline does not work: sendUserMessage is
|
|
417
423
|
// fire-and-forget, so the restore would land before the agent ever read the tool
|
|
418
424
|
// list, leaving the command running with everything enabled.
|
|
425
|
+
// A command invoked while the agent is streaming must not narrow the in-flight run's
|
|
426
|
+
// tools or switch its model (that would corrupt a run it does not own), and a bare
|
|
427
|
+
// sendUserMessage throws mid-stream and would be silently dropped. Queue it as a
|
|
428
|
+
// follow-up through pi's own queue, which is abort-aware and shown to the user; its
|
|
429
|
+
// frontmatter scoping is not applied in that case, since it cannot land on a run that
|
|
430
|
+
// has not started yet.
|
|
431
|
+
if (!ctx.isIdle()) {
|
|
432
|
+
pi.sendUserMessage(expanded, { deliverAs: 'followUp' })
|
|
433
|
+
return
|
|
434
|
+
}
|
|
419
435
|
applyAllowedTools(parsed, vars)
|
|
420
436
|
applyDisallowedTools(parsed)
|
|
421
437
|
await applyModelOverride(parsed, varCtx)
|
|
@@ -500,7 +516,10 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
500
516
|
// the tool result is the channel, and frontmatter scoping stays user-path
|
|
501
517
|
// territory (see the header).
|
|
502
518
|
const expanded = await expandCommand(pi, current, args, execCtx, command.filePath, command.plugin, { allowShell: false })
|
|
503
|
-
|
|
519
|
+
// Cap the tool result: a command body can inline an arbitrarily large @file, and
|
|
520
|
+
// an uncapped tool result overflows the model's context (every other pi-code tool
|
|
521
|
+
// routes its output through capForContext). The user-invoked path stays uncapped.
|
|
522
|
+
return { content: [{ type: 'text' as const, text: capForContext(`Contents of /${name} (expanded):\n\n${expanded}`) }], details: {} }
|
|
504
523
|
},
|
|
505
524
|
})
|
|
506
525
|
})
|
package/extensions/hooks.ts
CHANGED
|
@@ -565,7 +565,7 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
565
565
|
const prompt = substituteArguments(hook.prompt, payload)
|
|
566
566
|
const signal = AbortSignal.timeout(timeoutMs)
|
|
567
567
|
try {
|
|
568
|
-
const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
|
568
|
+
const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
|
569
569
|
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
570
570
|
} catch (error) {
|
|
571
571
|
return abortAwareFailure(signal, error)
|
|
@@ -971,6 +971,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
971
971
|
// turn, and stop_hook_active in the payload tells the next firing it is already
|
|
972
972
|
// continuing from a stop hook, which is the hook script's documented loop guard.
|
|
973
973
|
// Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
|
|
974
|
+
//
|
|
975
|
+
// On agent_end rather than agent_settled: agent_settled is only emitted after every
|
|
976
|
+
// agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
|
|
977
|
+
// handler on a UI dialog, which would starve the Stop hook and idle notification
|
|
978
|
+
// until the user answers it. agent_end can fire slightly early before a rare
|
|
979
|
+
// automatic retry or compaction; that is the better tradeoff.
|
|
974
980
|
pi.on('agent_end', async (event, ctx) => {
|
|
975
981
|
// Claude's Notification event, for the one type pi can honestly source: the
|
|
976
982
|
// agent finished and is waiting for input (idle_prompt). Observational only;
|
package/extensions/init.ts
CHANGED
|
@@ -75,7 +75,9 @@ export default function initExtension(pi: ExtensionAPI) {
|
|
|
75
75
|
const existing = findExistingContextFile(root)
|
|
76
76
|
const cursorRules = statOf(path.join(root, '.cursor', 'rules'))?.isDirectory() === true || statOf(path.join(root, '.cursorrules'))?.isFile() === true
|
|
77
77
|
const copilotRules = statOf(path.join(root, '.github', 'copilot-instructions.md'))?.isFile() === true
|
|
78
|
-
|
|
78
|
+
// A bare send throws (and is silently swallowed) while the agent is
|
|
79
|
+
// streaming, so mid-stream invocations queue as a follow-up turn.
|
|
80
|
+
pi.sendUserMessage(buildInitPrompt({ ...(existing !== undefined ? { existingContextFile: existing } : {}), cursorRules, copilotRules }), ctx.isIdle() ? {} : { deliverAs: 'followUp' })
|
|
79
81
|
},
|
|
80
82
|
})
|
|
81
83
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* throw and the caller falls back to its non-model behavior.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions } from '@earendil-works/pi-ai'
|
|
17
|
+
import type { Api, AssistantMessage, Context, Model, ModelsSimpleStreamOptions, Usage } from '@earendil-works/pi-ai'
|
|
18
18
|
import { ModelRuntime } from '@earendil-works/pi-coding-agent'
|
|
19
19
|
|
|
20
20
|
/** The completion backend: model + context -> assistant message. Overridable for tests. */
|
|
@@ -52,11 +52,13 @@ export interface CompleteOptions {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Run `prompt` through `model` as a single user turn and return the reply text
|
|
55
|
+
* Run `prompt` through `model` as a single user turn and return the reply text plus
|
|
56
|
+
* the call's usage. A tool that makes a nested LLM call must return that usage on
|
|
57
|
+
* its tool result, or the call's tokens and cost vanish from pi's session totals.
|
|
56
58
|
* Throws on any failure so the caller can fall back; never returns a partial or a
|
|
57
59
|
* tool call, only assistant text.
|
|
58
60
|
*/
|
|
59
|
-
export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<string> {
|
|
61
|
+
export async function completeText(model: Model<Api>, prompt: string, options: CompleteOptions = {}): Promise<{ text: string; usage: Usage }> {
|
|
60
62
|
backend ??= realBackend()
|
|
61
63
|
const complete = await backend
|
|
62
64
|
const context: Context = {
|
|
@@ -64,5 +66,5 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
|
|
|
64
66
|
messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
|
|
65
67
|
}
|
|
66
68
|
const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
|
|
67
|
-
return assistantText(message)
|
|
69
|
+
return { text: assistantText(message), usage: message.usage }
|
|
68
70
|
}
|
package/extensions/mcp.ts
CHANGED
|
@@ -933,7 +933,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
933
933
|
ctx.ui.notify(`${commandName}: prompt returned no content`, 'info')
|
|
934
934
|
return
|
|
935
935
|
}
|
|
936
|
-
|
|
936
|
+
// A bare send throws (and is silently swallowed) while the agent is
|
|
937
|
+
// streaming, so mid-stream invocations queue as a follow-up turn.
|
|
938
|
+
pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
|
|
937
939
|
} catch (error) {
|
|
938
940
|
ctx.ui.notify(`${commandName}: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
939
941
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
|
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
|
-
import type
|
|
15
|
+
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
17
|
import { capForContext } from './internal/output-guard.js'
|
|
18
18
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
@@ -138,29 +138,39 @@ export function indexWouldOverflow(index: string, name: string, description: str
|
|
|
138
138
|
return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
type MemoryToolResult = { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> }
|
|
142
|
+
|
|
143
|
+
/** Write a memory and its index line, or say why it cannot be written. The whole
|
|
144
|
+
* read-modify-write holds the index's mutation queue: tool calls run in parallel, so
|
|
145
|
+
* two unqueued saves both read the same index and the second silently drops the first's
|
|
146
|
+
* line. The queue keys ONLY on the index, the shared file every save touches, and never
|
|
147
|
+
* also on the memory file: a second nested queue self-deadlocks when a memory name
|
|
148
|
+
* canonicalizes to the same key as the index (e.g. `memory.md` and `MEMORY.md` under a
|
|
149
|
+
* case-insensitive filesystem, since the queue keys on realpath). */
|
|
150
|
+
export async function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): Promise<MemoryToolResult> {
|
|
143
151
|
if (!name || !description || !content) {
|
|
144
152
|
return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
|
|
145
153
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
return withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
|
|
155
|
+
const index = readIndex(dir)
|
|
156
|
+
// Claude reports an explicit error rather than writing a memory the next session
|
|
157
|
+
// would never load, and says what to do about it.
|
|
158
|
+
if (indexWouldOverflow(index, name, description)) {
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
|
|
161
|
+
details: {},
|
|
162
|
+
}
|
|
153
163
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
165
|
+
// A memory with frontmatter records its write time; one without is left as-is.
|
|
166
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
|
|
167
|
+
writeIndex(indexPath, upsertIndexLine(index, name, description))
|
|
168
|
+
return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
|
|
169
|
+
})
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/** The read action: a memory's body, capped for context, or a not-found message. */
|
|
163
|
-
function readMemory(dir: string, name: string):
|
|
173
|
+
function readMemory(dir: string, name: string): MemoryToolResult {
|
|
164
174
|
try {
|
|
165
175
|
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
166
176
|
return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
|
|
@@ -169,21 +179,23 @@ function readMemory(dir: string, name: string): { content: Array<{ type: 'text';
|
|
|
169
179
|
}
|
|
170
180
|
}
|
|
171
181
|
|
|
172
|
-
/** The delete action: remove a memory file and its index line
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
/** The delete action: remove a memory file and its index line, queued on the index
|
|
183
|
+
* like save (single key, no deadlock). The index is read before anything is removed,
|
|
184
|
+
* and any failure (a bad index read, or an unreadable store the queue key cannot
|
|
185
|
+
* realpath) leaves both the memory file and the index as they were. */
|
|
186
|
+
async function deleteMemory(dir: string, indexPath: string, name: string): Promise<MemoryToolResult> {
|
|
177
187
|
try {
|
|
178
|
-
|
|
188
|
+
return await withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
|
|
189
|
+
const index = readIndex(dir)
|
|
190
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
191
|
+
const remaining = removeIndexLine(index, name)
|
|
192
|
+
if (remaining) writeIndex(indexPath, remaining)
|
|
193
|
+
else fs.rmSync(indexPath, { force: true })
|
|
194
|
+
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
195
|
+
})
|
|
179
196
|
} catch (error) {
|
|
180
197
|
return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
181
198
|
}
|
|
182
|
-
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
183
|
-
const remaining = removeIndexLine(index, name)
|
|
184
|
-
if (remaining) writeIndex(indexPath, remaining)
|
|
185
|
-
else fs.rmSync(indexPath, { force: true })
|
|
186
|
-
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
/** The index as injected into the prompt, bounded like Claude's startup load. */
|
|
@@ -346,7 +358,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
346
358
|
|
|
347
359
|
if (params.action === 'save') {
|
|
348
360
|
try {
|
|
349
|
-
|
|
361
|
+
// Awaited here, not returned: the catch must see a queued write's rejection.
|
|
362
|
+
return await saveMemory(dir, indexPath, name, params.description, params.content)
|
|
350
363
|
} catch (error) {
|
|
351
364
|
return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
|
|
352
365
|
}
|
package/extensions/notify.ts
CHANGED
|
@@ -113,6 +113,11 @@ export default function notifyExtension(pi: ExtensionAPI) {
|
|
|
113
113
|
lastInputAt = Date.now()
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
// Fires on agent_end rather than agent_settled deliberately: agent_settled is only
|
|
117
|
+
// emitted after every agent_end handler returns, and a peer extension (plan mode)
|
|
118
|
+
// blocks its agent_end handler on a UI dialog, which would starve this notification
|
|
119
|
+
// exactly when the user has stepped away. agent_end can fire slightly early before a
|
|
120
|
+
// rare automatic retry or compaction, which is a better failure than never notifying.
|
|
116
121
|
pi.on('agent_end', async () => {
|
|
117
122
|
if (channel === 'off') return
|
|
118
123
|
// Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
|
|
@@ -209,7 +209,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
209
209
|
planFromTool = false
|
|
210
210
|
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
211
211
|
if (refinement?.trim()) {
|
|
212
|
-
|
|
212
|
+
// A bare send throws (and is silently swallowed) while the agent is
|
|
213
|
+
// streaming, so mid-stream invocations queue as a follow-up turn.
|
|
214
|
+
pi.sendUserMessage(refinement.trim(), ctx.isIdle() ? {} : { deliverAs: 'followUp' })
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
217
|
}
|
package/extensions/question.ts
CHANGED
|
@@ -238,7 +238,39 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
238
238
|
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
239
239
|
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
// ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
|
|
242
|
+
// undefined immediately, which would read as a cancel without ever asking. Ask
|
|
243
|
+
// through the dialog primitives there instead.
|
|
244
|
+
const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect) : await askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
245
|
+
|
|
246
|
+
// Build simple options list for details; header/multiSelect appear only when set,
|
|
247
|
+
// so single-select details are unchanged.
|
|
248
|
+
const simpleOptions = params.options.map((o) => o.label)
|
|
249
|
+
const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: shortHeader(params.header) } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
|
|
250
|
+
|
|
251
|
+
if (!result) {
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: 'text', text: 'User cancelled the selection' }],
|
|
254
|
+
details: { ...base, answer: null } as QuestionDetails,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (result.wasCustom) {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
261
|
+
details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: 'text', text: selectionText }],
|
|
267
|
+
details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Terminal path: the full custom overlay (options list, checkboxes, inline editor). */
|
|
272
|
+
function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
|
|
273
|
+
return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
|
|
242
274
|
let optionIndex = 0
|
|
243
275
|
let editMode = false
|
|
244
276
|
const checked: boolean[] = allOptions.map(() => false)
|
|
@@ -339,28 +371,29 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
339
371
|
handleInput,
|
|
340
372
|
}
|
|
341
373
|
})
|
|
374
|
+
}
|
|
342
375
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
content: [{ type: 'text', text: selectionText }],
|
|
364
|
-
details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
|
|
376
|
+
/** Dialog-primitive fallback for UI without a terminal (RPC mode supports
|
|
377
|
+
* select/input/notify but not custom components). Mirrors the overlay's result
|
|
378
|
+
* shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */
|
|
379
|
+
async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
|
|
380
|
+
const header = shortHeader(params.header)
|
|
381
|
+
const title = header ? `[${header}] ${params.question}` : params.question
|
|
382
|
+
// Number the labels: ctx.ui.select returns the chosen label string, so duplicate
|
|
383
|
+
// labels (or a model-supplied option named like the free-text entry) would be
|
|
384
|
+
// ambiguous by text alone; the number is the unambiguous way back to the option.
|
|
385
|
+
const labels = allOptions.map((option, i) => `${i + 1}. ${option.label}`)
|
|
386
|
+
const choice = await ctx.ui.select(title, labels)
|
|
387
|
+
if (choice === undefined) return null
|
|
388
|
+
const index = labels.indexOf(choice)
|
|
389
|
+
const chosen = allOptions[index]
|
|
390
|
+
if (!multiSelect && chosen?.isOther === true) {
|
|
391
|
+
const typed = await ctx.ui.input(params.question, 'Your answer')
|
|
392
|
+
// A dismissed dialog cancels; a submitted empty answer is an (empty) answer, not a
|
|
393
|
+
// cancel, so one accidental blank Enter does not abort the rest of a question batch.
|
|
394
|
+
if (typed === undefined) return null
|
|
395
|
+
return { answer: typed.trim(), wasCustom: true }
|
|
365
396
|
}
|
|
397
|
+
const answer = chosen?.label ?? choice
|
|
398
|
+
return multiSelect ? { answer, wasCustom: false } : { answer, wasCustom: false, index: index + 1 }
|
|
366
399
|
}
|
package/extensions/todo.ts
CHANGED
|
@@ -84,6 +84,9 @@ const listMark = (status: TodoStatus): string => {
|
|
|
84
84
|
return '[ ]'
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Plain-text list, shared by the list action and the non-terminal /todos path. */
|
|
88
|
+
const plainTodoList = (todos: Todo[]): string => (todos.length ? todos.map((t) => `${listMark(t.status)} #${t.id}: ${t.text}`).join('\n') : 'No todos')
|
|
89
|
+
|
|
87
90
|
const overlayLabel = (todo: Todo, theme: Theme): string => {
|
|
88
91
|
if (todo.status === 'completed') return theme.fg('dim', todo.text)
|
|
89
92
|
if (todo.status === 'in_progress') return theme.fg('text', todo.activeForm ?? todo.text)
|
|
@@ -433,7 +436,7 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
433
436
|
return ok('clear', `Cleared ${count} todos`)
|
|
434
437
|
}
|
|
435
438
|
|
|
436
|
-
const handleList = () => ok('list', todos
|
|
439
|
+
const handleList = () => ok('list', plainTodoList(todos))
|
|
437
440
|
|
|
438
441
|
// Register the todo tool for the LLM
|
|
439
442
|
pi.registerTool({
|
|
@@ -516,6 +519,13 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
516
519
|
return
|
|
517
520
|
}
|
|
518
521
|
|
|
522
|
+
// ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it
|
|
523
|
+
// resolves undefined without showing anything, so notify the plain list.
|
|
524
|
+
if (ctx.mode !== 'tui') {
|
|
525
|
+
ctx.ui.notify(plainTodoList(todos), 'info')
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
|
|
519
529
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
520
530
|
return new TodoListComponent(todos, theme, () => done())
|
|
521
531
|
})
|
package/extensions/web.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { LookupAddress } from 'node:dns'
|
|
10
10
|
import { lookup } from 'node:dns/promises'
|
|
11
11
|
import type { LookupFunction } from 'node:net'
|
|
12
|
+
import type { Usage } from '@earendil-works/pi-ai'
|
|
12
13
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
14
|
import { Type } from 'typebox'
|
|
14
15
|
|
|
@@ -252,16 +253,16 @@ function rememberFetch(cache: FetchCache, url: string, body: string, now: number
|
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
/** Claude's WebFetch runs the prompt over the page with a fast model and returns
|
|
255
|
-
* that answer, not the raw page
|
|
256
|
+
* that answer, not the raw page, along with the nested call's usage so the tool
|
|
257
|
+
* result can account for it. Best-effort: any failure (no model, provider error)
|
|
256
258
|
* yields null so the caller falls back to the markdown. */
|
|
257
|
-
async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<string | null> {
|
|
259
|
+
async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<{ text: string; usage: Usage } | null> {
|
|
258
260
|
try {
|
|
259
|
-
|
|
261
|
+
return await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
|
|
260
262
|
system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
|
|
261
263
|
maxTokens: 1024,
|
|
262
264
|
signal,
|
|
263
265
|
})
|
|
264
|
-
return answer || null
|
|
265
266
|
} catch {
|
|
266
267
|
return null
|
|
267
268
|
}
|
|
@@ -318,14 +319,18 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
318
319
|
}
|
|
319
320
|
|
|
320
321
|
// Best-effort prompt-over-page: a failure returns null, so web_fetch always
|
|
321
|
-
// falls back to the raw markdown and returns something.
|
|
322
|
+
// falls back to the raw markdown and returns something. The nested call's
|
|
323
|
+
// usage rides on the result either way, so pi counts it in session totals.
|
|
324
|
+
let usage: Usage | undefined
|
|
322
325
|
if (params.prompt && ctx?.model) {
|
|
323
326
|
const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
|
|
324
|
-
if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
|
|
327
|
+
if (answer?.text) return { content: [{ type: 'text' as const, text: answer.text }], details: {}, usage: answer.usage }
|
|
328
|
+
// An empty answer still cost the completion; the fallback carries its usage.
|
|
329
|
+
usage = answer?.usage
|
|
325
330
|
}
|
|
326
331
|
// The char cap alone admits thousands of short lines; pi's tool-output budget
|
|
327
332
|
// bounds lines too, which the shared guard enforces.
|
|
328
|
-
return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
|
|
333
|
+
return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {}, usage }
|
|
329
334
|
},
|
|
330
335
|
})
|
|
331
336
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|