pi-code 1.0.5 → 1.0.7

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.
@@ -159,6 +159,33 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
159
159
  return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
160
160
  }
161
161
 
162
+ /** The read action: a memory's body, capped for context, or a not-found message. */
163
+ function readMemory(dir: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
164
+ try {
165
+ const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
166
+ return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
167
+ } catch {
168
+ return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
169
+ }
170
+ }
171
+
172
+ /** The delete action: remove a memory file and its index line. The index is read
173
+ * before anything is removed: refusing on a failed read must leave both the memory
174
+ * file and the index as they were. */
175
+ function deleteMemory(dir: string, indexPath: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
176
+ let index: string
177
+ try {
178
+ index = readIndex(dir)
179
+ } catch (error) {
180
+ return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
181
+ }
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
+ }
188
+
162
189
  /** The index as injected into the prompt, bounded like Claude's startup load. */
163
190
  export function capIndexForPrompt(index: string): string {
164
191
  const loaded = stripNonLoaded(index)
@@ -327,29 +354,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
327
354
 
328
355
  if (params.action === 'read') {
329
356
  if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
330
- try {
331
- const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
332
- return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
333
- } catch {
334
- return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
335
- }
357
+ return readMemory(dir, name)
336
358
  }
337
359
 
338
360
  if (params.action === 'delete') {
339
361
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
340
- // The index is read before anything is removed: refusing on a failed read
341
- // must leave both the memory file and the index as they were.
342
- let index: string
343
- try {
344
- index = readIndex(dir)
345
- } catch (error) {
346
- return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
347
- }
348
- fs.rmSync(path.join(dir, `${name}.md`), { force: true })
349
- const remaining = removeIndexLine(index, name)
350
- if (remaining) writeIndex(indexPath, remaining)
351
- else fs.rmSync(indexPath, { force: true })
352
- return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
362
+ return deleteMemory(dir, indexPath, name)
353
363
  }
354
364
 
355
365
  const index = readIndexQuietly(dir)
@@ -104,10 +104,9 @@ export default function notifyExtension(pi: ExtensionAPI) {
104
104
  // Claude's "appear to be away" check. Undefined until the first prompt this session.
105
105
  let lastInputAt: number | undefined
106
106
 
107
- pi.on('session_start', async (_event, ctx) => {
107
+ pi.on('session_start', async (_event, _ctx) => {
108
108
  channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
109
109
  lastInputAt = undefined
110
- void ctx
111
110
  })
112
111
 
113
112
  pi.on('input', async () => {
@@ -9,6 +9,8 @@
9
9
  * analogue, off the shared bus), and on the optional `refreshInterval` timer
10
10
  * (minimum 1s). A project-defined command is arbitrary shell, so project settings
11
11
  * count only once the project is already approved, read without prompting.
12
+ * Claude's `disableAllHooks` setting turns the configured command off too, and
13
+ * the built-in segment stands in.
12
14
  *
13
15
  * Without a configured statusLine, the built-in segment shows turn state plus
14
16
  * running session cost, summed from per-message usage on the current branch so it
@@ -24,7 +26,7 @@ import * as os from 'node:os'
24
26
  import * as path from 'node:path'
25
27
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
26
28
 
27
- import { hookFiles, runHookCommand } from './hooks.js'
29
+ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
28
30
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
29
31
  import { isProjectApprovedSilently } from './internal/project-approval.js'
30
32
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -250,7 +252,7 @@ export default function statusLine(pi: ExtensionAPI) {
250
252
  })
251
253
  // The last message's token usage, for the breakdown getContextUsage() omits.
252
254
  pi.on('message_end', async (event) => {
253
- const usage = (event as { message?: { usage?: typeof lastUsage } }).message?.usage
255
+ const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
254
256
  if (usage) lastUsage = usage
255
257
  })
256
258
 
@@ -271,7 +273,10 @@ export default function statusLine(pi: ExtensionAPI) {
271
273
  // the keys meant for it. An undecided project simply skips project settings.
272
274
  const trusted = isProjectApprovedSilently(ctx)
273
275
  projectApproved = trusted
274
- config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
276
+ const files = hookFiles(ctx.cwd, os.homedir(), trusted)
277
+ // Claude's disableAllHooks also turns off the custom statusLine command; the
278
+ // built-in segment still renders as the fallback.
279
+ config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
275
280
  if (config?.refreshInterval) {
276
281
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
277
282
  }
@@ -26,6 +26,10 @@ export interface BackgroundRun {
26
26
  /** True until the child process actually closes: a cancelled child that ignores
27
27
  * SIGTERM is still alive and must keep holding its concurrency slot. */
28
28
  live?: boolean
29
+ /** Monotonic finish order, stamped when the run completes. Eviction drops the
30
+ * earliest-finished runs by this, not Map insertion (start) order: a long run
31
+ * started first but finished last must not vanish the instant it completes. */
32
+ finishedAt?: number
29
33
  /** pi session the child ran under, so a follow-up can continue its context. */
30
34
  sessionId: string
31
35
  /** How the child was spawned, so a follow-up can repeat it with a new task. */
@@ -63,8 +67,13 @@ export function activeBackgroundRuns(): number {
63
67
  return [...runs.values()].filter((run) => run.live || run.state === 'running').length
64
68
  }
65
69
 
70
+ /** Stamps BackgroundRun.finishedAt; a counter rather than a clock so two runs
71
+ * completing in the same millisecond still evict in their true finish order. */
72
+ let finishSequence = 0
73
+
66
74
  function evictFinishedRuns(): void {
67
75
  const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
76
+ finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
68
77
  for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
69
78
  }
70
79
 
@@ -160,6 +169,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
160
169
  run.output = undefined
161
170
  run.exitCode = undefined
162
171
  run.stderr = undefined
172
+ run.finishedAt = undefined
163
173
  driveRun(run, { ...run.spawn, args }, onComplete)
164
174
  return 'resumed'
165
175
  }
@@ -256,6 +266,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
256
266
  const complete = (): void => {
257
267
  if (completed) return
258
268
  completed = true
269
+ run.finishedAt = ++finishSequence
259
270
  evictFinishedRuns()
260
271
  // A run outlives the session that started it, and pi's loader wires assertActive()
261
272
  // into every runtime call, so notifying a disposed session throws. This fires from
@@ -30,8 +30,8 @@ import { repoRoot } from '../internal/project-root.js'
30
30
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
31
31
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
32
32
  import { skillDirs } from '../skills.js'
33
- import { type AgentConfig, type AgentMemoryScope, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
34
- import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
33
+ import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
34
+ import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
35
35
 
36
36
  const MAX_PARALLEL_TASKS = 8
37
37
  const MAX_CONCURRENCY = 4
@@ -557,6 +557,67 @@ export function cancelResultText(id: string): string {
557
557
  return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
558
558
  }
559
559
 
560
+ /** The registry fields the /tasks listing prints. */
561
+ type BackgroundRunView = Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'output' | 'stderr'>
562
+
563
+ const TASK_PREVIEW_CHARS = 60
564
+ const TAIL_PREVIEW_CHARS = 200
565
+
566
+ /** Truncate to at most `max` codepoints, iterating by codepoint so a multi-byte
567
+ * character on the boundary is never cut into a lone surrogate. Returns the whole
568
+ * string when it already fits, so a caller can tell it did not clip. */
569
+ function clipCodepoints(text: string, max: number): string {
570
+ const points = Array.from(text)
571
+ return points.length > max ? points.slice(0, max).join('') : text
572
+ }
573
+
574
+ /** A one-line tail of what a run last said: the stderr tail for a failure (the only
575
+ * diagnostics a boot failure leaves), the latest assistant text otherwise. */
576
+ function runOutputTail(run: BackgroundRunView): string | undefined {
577
+ const stderrTail = run.state === 'failed' ? run.stderr?.trim() : undefined
578
+ const raw = (stderrTail || run.output)?.trim()
579
+ if (!raw) return undefined
580
+ const last = raw.split('\n').at(-1)?.trim() ?? ''
581
+ const shortened = clipCodepoints(last, TAIL_PREVIEW_CHARS)
582
+ const clipped = shortened === last ? last : `${shortened}...`
583
+ return stderrTail ? `stderr: ${clipped}` : clipped
584
+ }
585
+
586
+ /** The /tasks listing: one line per background run, plus the short output tail the
587
+ * registry's own status lines omit. A pure formatter so it tests against a plain list. */
588
+ export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
589
+ if (runs.length === 0) return 'No background subagent runs in this session.'
590
+ return runs
591
+ .map((run) => {
592
+ const plural = run.turns === 1 ? '' : 's'
593
+ const label = run.state === 'running' ? 'running' : `${run.state} (${run.turns} turn${plural})`
594
+ const head = `${run.id} ${run.agent}: ${label} - ${clipCodepoints(run.task, TASK_PREVIEW_CHARS)}`
595
+ const tail = runOutputTail(run)
596
+ return tail ? `${head}\n ${tail}` : head
597
+ })
598
+ .join('\n')
599
+ }
600
+
601
+ /** Listing order for /agents: lowest to highest precedence, matching how discovery
602
+ * lets a later source win a name clash. */
603
+ const AGENT_SOURCE_ORDER: ReadonlyArray<AgentSource> = ['builtin', 'plugin', 'user', 'project']
604
+
605
+ const AGENTS_DIR_HINT = 'Add agents as markdown files under ~/.claude/agents (user) or .claude/agents (project).'
606
+
607
+ /** The /agents listing: the discovered roster grouped by source, with file paths.
608
+ * A pure formatter so it tests against a sample roster. */
609
+ export function agentsListText(agents: ReadonlyArray<Pick<AgentConfig, 'name' | 'source' | 'filePath'>>): string {
610
+ if (agents.length === 0) return `No agents discovered.\n${AGENTS_DIR_HINT}`
611
+ const sections: string[] = []
612
+ for (const source of AGENT_SOURCE_ORDER) {
613
+ const group = agents.filter((agent) => agent.source === source)
614
+ if (group.length === 0) continue
615
+ const lines = group.map((agent) => ` ${agent.name} - ${agent.filePath}`).join('\n')
616
+ sections.push(`${source}:\n${lines}`)
617
+ }
618
+ return `${sections.join('\n')}\n\n${AGENTS_DIR_HINT}`
619
+ }
620
+
560
621
  /** Everything a mode handler needs from the surrounding execute() call. */
561
622
  interface ModeContext {
562
623
  agents: AgentConfig[]
@@ -729,7 +790,20 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
729
790
  }
730
791
  }
731
792
 
732
- async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>, projectApproved: boolean): Promise<ToolResult> {
793
+ /** Everything runBackgroundMode needs from the surrounding execute() call, grouped so
794
+ * the parameter list stays in bounds. */
795
+ interface BackgroundContext {
796
+ agents: AgentConfig[]
797
+ defaultCwd: string
798
+ pi: ExtensionAPI
799
+ makeDetails: MakeDetails
800
+ skillRoots: string[]
801
+ availableModels: ReadonlyArray<{ id: string }>
802
+ projectApproved: boolean
803
+ }
804
+
805
+ async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext, onStarted?: (id: string) => void): Promise<ToolResult> {
806
+ const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
733
807
  const task = params.task
734
808
  const agentName = params.agent
735
809
  if (!task || !agentName) {
@@ -775,6 +849,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
775
849
  removeTmpPrompt(tmpPrompt)
776
850
  return backgroundCapResult(makeDetails)
777
851
  }
852
+ onStarted?.(id)
778
853
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
779
854
  return {
780
855
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
@@ -1249,6 +1324,21 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1249
1324
  }
1250
1325
 
1251
1326
  export default function subagentExtension(pi: ExtensionAPI) {
1327
+ // /tasks resolves these against the registry at print time. background.ts owns the
1328
+ // run records but does not enumerate them, so the ids started here are remembered;
1329
+ // a run the registry has since evicted simply drops out of the listing.
1330
+ const startedBackgroundRuns = new Set<string>()
1331
+
1332
+ // The registry self-caps and evicts old runs, so an id kept here after its record is
1333
+ // gone is dead weight. Drop those on every add, bounding the set to the registry's
1334
+ // live capacity rather than letting it grow for the whole session.
1335
+ const rememberBackgroundRun = (id: string): void => {
1336
+ for (const known of startedBackgroundRuns) {
1337
+ if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
1338
+ }
1339
+ startedBackgroundRuns.add(id)
1340
+ }
1341
+
1252
1342
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1253
1343
  // Runs through driveRun's guard, same as the background-mode callback above.
1254
1344
  // The stop event fires here too, so SubagentStop hooks see resumed runs end.
@@ -1344,7 +1434,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
1344
1434
  })
1345
1435
 
1346
1436
  if (params.resume) {
1347
- return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, (run) => pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })) }], details: makeDetails('single')([]) }
1437
+ const onResumed = (run: { id: string; agent: string }): void => {
1438
+ rememberBackgroundRun(run.id)
1439
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
1440
+ }
1441
+ return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
1348
1442
  }
1349
1443
 
1350
1444
  if (params.cancel) {
@@ -1385,7 +1479,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1385
1479
  // unavailable tier still falls back to the session model.
1386
1480
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1387
1481
 
1388
- if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved)
1482
+ if (params.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1389
1483
 
1390
1484
  const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, projectApproved, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1391
1485
 
@@ -1426,4 +1520,24 @@ export default function subagentExtension(pi: ExtensionAPI) {
1426
1520
  return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
1427
1521
  },
1428
1522
  })
1523
+
1524
+ // Claude's /tasks: background-run status at a glance, returning immediately without
1525
+ // interrupting the agent; the only other way to see these is to ask the model.
1526
+ pi.registerCommand('tasks', {
1527
+ description: 'Show background subagent runs',
1528
+ handler: async (_args, ctx) => {
1529
+ const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
1530
+ ctx.ui.notify(tasksStatusText(runs), 'info')
1531
+ },
1532
+ })
1533
+
1534
+ // Claude's /agents: the discovered roster with sources and paths. Approval is read
1535
+ // silently, like the roster above: project agents list only once the project is trusted.
1536
+ pi.registerCommand('agents', {
1537
+ description: 'List discovered subagents and where they come from',
1538
+ handler: async (_args, ctx) => {
1539
+ const { agents } = discoverAgents(ctx.cwd, isProjectApprovedSilently(ctx) ? 'both' : 'user')
1540
+ ctx.ui.notify(agentsListText(agents), 'info')
1541
+ },
1542
+ })
1429
1543
  }
package/extensions/web.ts CHANGED
@@ -233,6 +233,40 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
233
233
  const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
234
234
  const FETCH_CACHE_MAX_ENTRIES = 50
235
235
 
236
+ type FetchCache = Map<string, { expires: number; body: string }>
237
+
238
+ /** Store a freshly fetched body. Drop expired entries first, then evict the oldest
239
+ * live one if still full; deleting before set keeps Map insertion order a true
240
+ * recency order, so a refreshed URL moves to the newest slot instead of keeping its
241
+ * stale one. Only a delivered body reaches here, so a thrown fetch retries next call. */
242
+ function rememberFetch(cache: FetchCache, url: string, body: string, now: number): void {
243
+ cache.delete(url)
244
+ for (const [key, entry] of cache) {
245
+ if (entry.expires <= now) cache.delete(key)
246
+ }
247
+ if (cache.size >= FETCH_CACHE_MAX_ENTRIES) {
248
+ const oldest = cache.keys().next().value
249
+ if (oldest !== undefined) cache.delete(oldest)
250
+ }
251
+ cache.set(url, { expires: now + FETCH_CACHE_TTL_MS, body })
252
+ }
253
+
254
+ /** Claude's WebFetch runs the prompt over the page with a fast model and returns
255
+ * that answer, not the raw page. Best-effort: any failure (no model, provider error)
256
+ * 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> {
258
+ try {
259
+ const answer = await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
260
+ 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
+ maxTokens: 1024,
262
+ signal,
263
+ })
264
+ return answer || null
265
+ } catch {
266
+ return null
267
+ }
268
+ }
269
+
236
270
  export default function webExtension(pi: ExtensionAPI) {
237
271
  const fetchCache = new Map<string, { expires: number; body: string }>()
238
272
  pi.registerTool({
@@ -280,35 +314,14 @@ export default function webExtension(pi: ExtensionAPI) {
280
314
  } else {
281
315
  const { text, contentType } = await fetchText(params.url)
282
316
  body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
283
- // Only a delivered body is cached; a thrown fetch must retry next call.
284
- // Drop expired entries first, then evict the oldest live one if still full;
285
- // deleting before set keeps Map insertion order a true recency order, so a
286
- // refreshed URL moves to the newest slot instead of keeping its stale one.
287
- fetchCache.delete(params.url)
288
- for (const [url, entry] of fetchCache) {
289
- if (entry.expires <= now) fetchCache.delete(url)
290
- }
291
- if (fetchCache.size >= FETCH_CACHE_MAX_ENTRIES) {
292
- const oldest = fetchCache.keys().next().value
293
- if (oldest !== undefined) fetchCache.delete(oldest)
294
- }
295
- fetchCache.set(params.url, { expires: now + FETCH_CACHE_TTL_MS, body })
317
+ rememberFetch(fetchCache, params.url, body, now)
296
318
  }
297
319
 
298
- // Claude's WebFetch runs the prompt over the page with a fast model and returns
299
- // that answer, not the raw page. Best-effort: any failure (no model, provider
300
- // error) falls back to the markdown, so web_fetch always returns something.
320
+ // Best-effort prompt-over-page: a failure returns null, so web_fetch always
321
+ // falls back to the raw markdown and returns something.
301
322
  if (params.prompt && ctx?.model) {
302
- try {
303
- const answer = await completeText(ctx.model, `${params.prompt}\n\nAnswer using only the page content below, fetched from ${params.url}:\n\n${body}`, {
304
- 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.',
305
- maxTokens: 1024,
306
- signal,
307
- })
308
- if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
309
- } catch {
310
- // fall through to the raw markdown
311
- }
323
+ const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
324
+ if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
312
325
  }
313
326
  // The char cap alone admits thousands of short lines; pi's tool-output budget
314
327
  // bounds lines too, which the shared guard enforces.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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",