pi-code 1.0.47 → 1.0.49
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/README.md +3 -2
- package/extensions/claude-rules.ts +2 -1
- package/extensions/commands.ts +2 -2
- package/extensions/context-imports.ts +13 -12
- package/extensions/git-checkpoint.ts +26 -0
- package/extensions/goal.ts +522 -0
- package/extensions/hooks/index.ts +2 -7
- package/extensions/internal/command-file.ts +22 -11
- package/extensions/internal/goal-evaluator.ts +262 -0
- package/extensions/internal/html-markdown.ts +37 -9
- package/extensions/internal/model-lookup.ts +20 -0
- package/extensions/internal/output-guard.ts +6 -5
- package/extensions/internal/strip-comments.ts +3 -3
- package/extensions/memory.ts +26 -1
- package/extensions/plan-mode/index.ts +3 -1
- package/extensions/question.ts +1 -1
- package/extensions/subagent/background.ts +33 -10
- package/extensions/todo.ts +6 -2
- package/extensions/web.ts +2 -6
- package/package.json +2 -2
|
@@ -97,6 +97,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
|
|
|
97
97
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
98
98
|
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
99
99
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
100
|
+
import { resolveModelOverride } from '../internal/model-lookup.js'
|
|
100
101
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
101
102
|
import { installedPlugins } from '../internal/plugins.js'
|
|
102
103
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
@@ -230,13 +231,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
230
231
|
}
|
|
231
232
|
/** Claude's prompt-hook `model` override, resolved against the models this user
|
|
232
233
|
* can run (exact id first, then a substring match); the session model otherwise. */
|
|
233
|
-
const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] =>
|
|
234
|
-
if (!override) return ctx.model
|
|
235
|
-
const available = (ctx as { modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? []
|
|
236
|
-
const needle = override.toLowerCase()
|
|
237
|
-
const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
|
|
238
|
-
return (match as ExtensionContext['model']) ?? ctx.model
|
|
239
|
-
}
|
|
234
|
+
const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => resolveModelOverride(ctx, override)
|
|
240
235
|
|
|
241
236
|
/** Kills for background hooks still running; Claude kills async hooks at teardown,
|
|
242
237
|
* so session_shutdown reaps anything left rather than let a hung hook pin the
|
|
@@ -13,8 +13,8 @@ import * as fs from 'node:fs'
|
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
15
|
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
16
|
-
|
|
17
16
|
import { splitSegments } from './shell-split.js'
|
|
17
|
+
import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
|
|
18
18
|
|
|
19
19
|
/** The pi file tools a Claude path rule can govern. */
|
|
20
20
|
export type PathRuleTool = 'read' | 'edit' | 'write'
|
|
@@ -421,7 +421,7 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
|
|
|
421
421
|
return found
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
|
|
424
|
+
export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
|
|
425
425
|
|
|
426
426
|
/** PowerShell single-quote escaping: inside a '...' literal the only special
|
|
427
427
|
* characters are the quote delimiters themselves, written doubled. PowerShell's
|
|
@@ -511,20 +511,27 @@ interface FenceBlock {
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
/** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
|
|
514
|
-
* fence, while a ```! fence is itself a placeholder that executes.
|
|
514
|
+
* fence, while a ```! fence is itself a placeholder that executes. Fences follow
|
|
515
|
+
* CommonMark: any indentation, closed only by the opener's character in a run at
|
|
516
|
+
* least as long, so a tilde line or a shorter fence inside stays content. */
|
|
515
517
|
function fenceBlocks(body: string): FenceBlock[] {
|
|
516
518
|
const blocks: FenceBlock[] = []
|
|
517
|
-
|
|
519
|
+
let fence: Fence | null = null
|
|
518
520
|
let open: { index: number; exec: boolean; contentStart: number } | undefined
|
|
519
|
-
let
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
521
|
+
let offset = 0
|
|
522
|
+
for (const line of body.split('\n')) {
|
|
523
|
+
const trimmed = line.trimStart()
|
|
524
|
+
const step = stepFence(fence, trimmed, fenceMarker(trimmed))
|
|
525
|
+
const lineEnd = offset + line.length
|
|
526
|
+
if (fence === null && step.fence !== null) {
|
|
527
|
+
// Only the exact, unindented ```! opener executes, as Claude documents it.
|
|
528
|
+
open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
|
|
529
|
+
} else if (fence !== null && step.fence === null && open !== undefined) {
|
|
530
|
+
blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
|
|
525
531
|
open = undefined
|
|
526
532
|
}
|
|
527
|
-
|
|
533
|
+
fence = step.fence
|
|
534
|
+
offset = lineEnd + 1
|
|
528
535
|
}
|
|
529
536
|
// An unterminated fence protects to the end of the body rather than executing.
|
|
530
537
|
if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
|
|
@@ -563,6 +570,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
|
|
|
563
570
|
* documents: the model never sees a half-expanded body. */
|
|
564
571
|
async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
|
|
565
572
|
const result = await exec(command)
|
|
573
|
+
// A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
|
|
574
|
+
// so the code alone would paste the partial output as a success. Claude kills a span
|
|
575
|
+
// at the Bash timeout and that failure aborts the invocation.
|
|
576
|
+
if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
|
|
566
577
|
if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
|
|
567
578
|
throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
|
|
568
579
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of /goal: the directive Claude Code sends when a goal is set, the
|
|
3
|
+
* evaluator prompt its small fast model judges the condition with, the transcript
|
|
4
|
+
* rendering that prompt sees, verdict parsing, the classifier for the errors that
|
|
5
|
+
* clear a goal, the check-in schedule, and the status text. No pi state lives here,
|
|
6
|
+
* so goal.ts stays the lifecycle wiring and each contract is pinned on its own.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Claude caps a goal condition at 4,000 characters. */
|
|
10
|
+
export const GOAL_CONDITION_MAX_CHARS = 4000
|
|
11
|
+
|
|
12
|
+
/** `/goal clear` and its documented aliases, matched case-insensitively. */
|
|
13
|
+
const CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])
|
|
14
|
+
|
|
15
|
+
export function isClearAlias(text: string): boolean {
|
|
16
|
+
return CLEAR_ALIASES.has(text.toLowerCase())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The directive a set goal starts its first turn with: the condition itself is the task. */
|
|
20
|
+
export function kickoffPrompt(condition: string): string {
|
|
21
|
+
return `A session-scoped Stop hook is now active with condition: "${condition}". Briefly acknowledge the goal, then immediately start (or continue) working toward it: treat the condition itself as your directive and do not pause to ask the user what to do. The hook will block stopping until the condition holds. It auto-clears once the condition is met, so do not tell the user to run \`/goal clear\` after success; that is only for clearing a goal early.`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Claude's stop-condition evaluator instructions: transcript evidence only, three
|
|
25
|
+
* verdict shapes, and impossible reserved for a condition that can never hold. */
|
|
26
|
+
export const EVALUATOR_SYSTEM = [
|
|
27
|
+
'You are evaluating a stop-condition hook for a coding agent session. Read the conversation transcript carefully, then judge whether the user-provided condition is satisfied.',
|
|
28
|
+
'Your response must be a JSON object with one of these shapes:',
|
|
29
|
+
'- {"ok": true, "reason": "<quote evidence from the transcript that satisfies the condition>"}',
|
|
30
|
+
'- {"ok": false, "reason": "<quote what is missing or what blocks the condition>"}',
|
|
31
|
+
'- {"ok": false, "impossible": true, "reason": "<explain why the condition can never be satisfied>"}',
|
|
32
|
+
'Always include a "reason" field, quoting specific text from the transcript whenever possible. If the transcript does not contain clear evidence that the condition is satisfied, return {"ok": false, "reason": "insufficient evidence in transcript"}.',
|
|
33
|
+
'Only use {"ok": false, "impossible": true} when the condition is genuinely unachievable in this session, for example: the condition is self-contradictory, it depends on a resource or capability that is unavailable, or the assistant has explicitly tried, exhausted reasonable approaches, and stated it cannot be done. Apply your own judgment when deciding this: the assistant claiming the goal is impossible is evidence, not proof; independently confirm the condition is genuinely unachievable rather than deferring to the assistant\'s self-assessment. Do not use it just because the goal has not been reached yet or because progress is slow. When in doubt, return {"ok": false} without "impossible".',
|
|
34
|
+
// Claude pins the reply with a JSON schema; pi's one-off completion cannot, so the
|
|
35
|
+
// instruction has to carry that weight.
|
|
36
|
+
'Output the JSON object only, with no text before or after it and no code fence.',
|
|
37
|
+
].join('\n')
|
|
38
|
+
|
|
39
|
+
/** The user turn of the evaluation: the rendered transcript, then Claude's question. */
|
|
40
|
+
export function evaluatorPrompt(transcript: string, condition: string): string {
|
|
41
|
+
return `<transcript>\n${transcript}\n</transcript>\n\nBased on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\nCondition: ${condition}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GoalVerdict {
|
|
45
|
+
ok: boolean
|
|
46
|
+
reason: string
|
|
47
|
+
/** Only meaningful when ok is false: the condition can never be satisfied. */
|
|
48
|
+
impossible: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A reply with no JSON at all but an opening yes/no: a small model answering the
|
|
52
|
+
* question in prose. The whole reply becomes the reason. Anything less clear-cut is
|
|
53
|
+
* not a verdict. */
|
|
54
|
+
function proseVerdict(text: string): GoalVerdict | undefined {
|
|
55
|
+
const lead = /^\s*(yes|no)\b/i.exec(text)?.[1]?.toLowerCase()
|
|
56
|
+
if (lead === undefined) return undefined
|
|
57
|
+
return { ok: lead === 'yes', reason: text.trim(), impossible: false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The evaluator's JSON verdict, tolerating prose or a code fence around the object; a
|
|
61
|
+
* reply with no object at all falls back to its yes/no lead. Undefined when the object
|
|
62
|
+
* does not parse, `ok` is not a boolean, or no lead is there, which the caller treats
|
|
63
|
+
* as an evaluator error rather than a verdict. */
|
|
64
|
+
export function parseVerdict(text: string): GoalVerdict | undefined {
|
|
65
|
+
const start = text.indexOf('{')
|
|
66
|
+
const end = text.lastIndexOf('}')
|
|
67
|
+
if (start === -1) return proseVerdict(text)
|
|
68
|
+
if (end <= start) return undefined
|
|
69
|
+
let parsed: unknown
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(text.slice(start, end + 1))
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
76
|
+
const { ok, reason, impossible } = parsed as Record<string, unknown>
|
|
77
|
+
if (typeof ok !== 'boolean') return undefined
|
|
78
|
+
return { ok, reason: typeof reason === 'string' ? reason.trim() : '', impossible: !ok && impossible === true }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One message's rendering is capped so a single tool dump cannot consume the budget. */
|
|
82
|
+
const BLOCK_MAX_CHARS = 6000
|
|
83
|
+
/** Tool arguments are context, not evidence; a short prefix identifies the call. */
|
|
84
|
+
const TOOL_ARGS_MAX_CHARS = 400
|
|
85
|
+
const OMITTED_MARKER = '[earlier transcript omitted]'
|
|
86
|
+
|
|
87
|
+
interface ContentPart {
|
|
88
|
+
type?: string
|
|
89
|
+
text?: string
|
|
90
|
+
name?: string
|
|
91
|
+
arguments?: unknown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface TranscriptMessage {
|
|
95
|
+
role?: string
|
|
96
|
+
content?: unknown
|
|
97
|
+
toolName?: string
|
|
98
|
+
customType?: string
|
|
99
|
+
stopReason?: string
|
|
100
|
+
errorMessage?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function truncate(text: string, max: number): string {
|
|
104
|
+
if (text.length <= max) return text
|
|
105
|
+
return `${text.slice(0, max)}... [truncated ${text.length - max} chars]`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Text and tool-call parts of a content value; thinking is dropped, images noted. */
|
|
109
|
+
function partsText(content: unknown): string {
|
|
110
|
+
if (typeof content === 'string') return content
|
|
111
|
+
if (!Array.isArray(content)) return ''
|
|
112
|
+
const rendered: string[] = []
|
|
113
|
+
for (const part of content as ContentPart[]) {
|
|
114
|
+
if (part?.type === 'text' && typeof part.text === 'string') rendered.push(part.text)
|
|
115
|
+
else if (part?.type === 'image') rendered.push('[image]')
|
|
116
|
+
else if (part?.type === 'toolCall') rendered.push(`[tool call ${part.name}(${truncate(JSON.stringify(part.arguments ?? {}), TOOL_ARGS_MAX_CHARS)})]`)
|
|
117
|
+
}
|
|
118
|
+
return rendered.join('\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderMessage(message: TranscriptMessage): string | undefined {
|
|
122
|
+
switch (message.role) {
|
|
123
|
+
case 'user':
|
|
124
|
+
return `User: ${partsText(message.content)}`
|
|
125
|
+
case 'assistant': {
|
|
126
|
+
const body = message.stopReason === 'error' ? `[error: ${message.errorMessage ?? 'unknown error'}]` : partsText(message.content)
|
|
127
|
+
return `Assistant: ${body}`
|
|
128
|
+
}
|
|
129
|
+
case 'toolResult':
|
|
130
|
+
return `Tool result (${message.toolName ?? 'tool'}): ${partsText(message.content)}`
|
|
131
|
+
case 'custom':
|
|
132
|
+
return `Note (${message.customType ?? 'note'}): ${partsText(message.content)}`
|
|
133
|
+
default:
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The conversation as the evaluator reads it: one block per message, newest last,
|
|
140
|
+
* trimmed from the head to `budgetChars` (Claude trims to half the evaluator's
|
|
141
|
+
* context window). A cut is marked so the model knows evidence may predate it; the
|
|
142
|
+
* newest message always survives, cut to the budget when it alone exceeds it.
|
|
143
|
+
*/
|
|
144
|
+
export function renderTranscript(messages: readonly unknown[], budgetChars: number): string {
|
|
145
|
+
const blocks: string[] = []
|
|
146
|
+
for (const message of messages as TranscriptMessage[]) {
|
|
147
|
+
const rendered = renderMessage(message)
|
|
148
|
+
if (rendered !== undefined) blocks.push(truncate(rendered, BLOCK_MAX_CHARS))
|
|
149
|
+
}
|
|
150
|
+
const newest = blocks.at(-1)
|
|
151
|
+
if (newest === undefined) return ''
|
|
152
|
+
const separator = '\n\n'
|
|
153
|
+
const kept: string[] = []
|
|
154
|
+
let used = 0
|
|
155
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
156
|
+
const cost = blocks[i].length + (kept.length > 0 ? separator.length : 0)
|
|
157
|
+
if (used + cost > budgetChars) {
|
|
158
|
+
if (kept.length > 0) kept.unshift(OMITTED_MARKER)
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
kept.unshift(blocks[i])
|
|
162
|
+
used += cost
|
|
163
|
+
}
|
|
164
|
+
if (kept.length === 0) return newest.slice(0, budgetChars)
|
|
165
|
+
return kept.join(separator)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type UnrecoverableKind = 'authentication' | 'credits' | 'context overflow' | 'model unavailable'
|
|
169
|
+
|
|
170
|
+
/** Claude clears a goal after an error the user has to fix. Transient failures (rate
|
|
171
|
+
* limits, overloads, network) deliberately match nothing so the goal stays set. */
|
|
172
|
+
const UNRECOVERABLE: ReadonlyArray<[UnrecoverableKind, RegExp]> = [
|
|
173
|
+
['authentication', /\b40[13]\b|unauthori[sz]ed|authentication|invalid (?:api[ -])?key|x-api-key/i],
|
|
174
|
+
['credits', /\bcredit|billing|insufficient[ _](?:funds|balance|quota)|payment required|\b402\b/i],
|
|
175
|
+
['context overflow', /context (?:window|length)|too (?:long|many tokens)|maximum (?:context|input) (?:length|tokens)/i],
|
|
176
|
+
['model unavailable', /model.*(?:not found|unavailable|does not exist|not available|unsupported)|no such model|not_found_error|\b404\b/i],
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
export function classifyUnrecoverable(errorMessage: string): UnrecoverableKind | undefined {
|
|
180
|
+
return UNRECOVERABLE.find(([, pattern]) => pattern.test(errorMessage))?.[0]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function formatDuration(ms: number): string {
|
|
184
|
+
const seconds = Math.max(0, Math.round(ms / 1000))
|
|
185
|
+
if (seconds < 60) return `${seconds}s`
|
|
186
|
+
const minutes = Math.floor(seconds / 60)
|
|
187
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
|
188
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function formatTokens(tokens: number): string {
|
|
192
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
|
193
|
+
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
|
|
194
|
+
return String(tokens)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function plural(count: number, noun: string): string {
|
|
198
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface GoalSummary {
|
|
202
|
+
condition: string
|
|
203
|
+
durationMs: number
|
|
204
|
+
/** Turns the evaluator has judged. */
|
|
205
|
+
iterations: number
|
|
206
|
+
/** Tokens spent since the goal was set, evaluator calls included. */
|
|
207
|
+
tokens: number
|
|
208
|
+
lastReason?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Claude's status view fields: duration, turn count, and token spend. */
|
|
212
|
+
export function summaryText(summary: GoalSummary): string {
|
|
213
|
+
return `${formatDuration(summary.durationMs)} · ${plural(summary.iterations, 'turn')} · ${formatTokens(summary.tokens)} tokens`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const NO_GOAL_TEXT = 'No goal set. Usage: /goal <condition>'
|
|
217
|
+
|
|
218
|
+
export function formatActiveGoal(summary: GoalSummary): string {
|
|
219
|
+
const turns = summary.iterations === 0 ? 'not yet evaluated' : plural(summary.iterations, 'turn')
|
|
220
|
+
const lines = [`Goal active: ${summary.condition} (${turns})`, `Running for ${formatDuration(summary.durationMs)} · ${formatTokens(summary.tokens)} tokens`]
|
|
221
|
+
if (summary.lastReason) lines.push(`Last check: ${summary.lastReason}`)
|
|
222
|
+
lines.push('/goal clear to stop early')
|
|
223
|
+
return lines.join('\n')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function formatAchievedGoal(summary: GoalSummary): string {
|
|
227
|
+
return `Goal achieved: ${summary.condition} (${summaryText(summary)})\n/goal <condition> to set another`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Claude's first check-in interval while background work keeps a goal waiting. */
|
|
231
|
+
export const DEFAULT_CHECKIN_MINUTES = 30
|
|
232
|
+
/** Later check-ins wait twice as long each, up to four times the first interval. */
|
|
233
|
+
const MAX_CHECKIN_DOUBLINGS = 2
|
|
234
|
+
/** Idle check-ins per goal between user prompts; the third says they are paused. */
|
|
235
|
+
export const MAX_IDLE_CHECKINS = 3
|
|
236
|
+
|
|
237
|
+
/** Milliseconds until the next check-in given how many have been delivered for this
|
|
238
|
+
* goal: CLAUDE_CODE_GOAL_CHECKIN_MINUTES (0 turns check-ins off, junk falls back to
|
|
239
|
+
* the default) scaled by Claude's doubling. */
|
|
240
|
+
export function checkinIntervalMs(env: Record<string, string | undefined>, delivered: number): number {
|
|
241
|
+
const raw = env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES
|
|
242
|
+
const parsed = raw === undefined || raw.trim() === '' ? Number.NaN : Number(raw)
|
|
243
|
+
const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CHECKIN_MINUTES
|
|
244
|
+
return minutes * 60_000 * 2 ** Math.min(delivered, MAX_CHECKIN_DOUBLINGS)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface RunningWork {
|
|
248
|
+
id: string
|
|
249
|
+
agentType: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Claude's check-in turn: the running work to look at, or a nudge to continue when
|
|
253
|
+
* the work stopped without reporting. `paused` marks the capped idle check-in. */
|
|
254
|
+
export function checkinText(condition: string, deferredMs: number, running: readonly RunningWork[], paused: boolean): string {
|
|
255
|
+
const minutes = Math.max(1, Math.round(deferredMs / 60_000))
|
|
256
|
+
const pausedNote = paused ? ' Idle check-ins are paused until your next message, so say clearly where things stand.' : ''
|
|
257
|
+
if (running.length === 0) {
|
|
258
|
+
return `Goal check-in: «${condition}» is still active. Its evaluation was deferred for ${minutes} min while background work ran, and that work is no longer running (it finished or was stopped without reporting back). Continue toward the goal.${pausedNote}`
|
|
259
|
+
}
|
|
260
|
+
const list = running.map((work) => `- ${work.id} · subagent ${work.agentType}`).join('\n')
|
|
261
|
+
return `Goal check-in: «${condition}» is still active, and evaluation has been deferred for ${minutes} min because background work is still running:\n${list}\nCheck on their progress (e.g. read their output). If they are progressing, say so briefly and keep waiting; if they are stuck or no longer needed, fix or stop them and continue toward the goal.${pausedNote}`
|
|
262
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* A regex pipeline, not a DOM: pi ships no HTML parser and the output is prose
|
|
6
6
|
* for a model, not a rendering. Every pattern bounds its tag matches with
|
|
7
7
|
* [^<>]* so a failed match stops at the next tag instead of rescanning to the
|
|
8
|
-
* end of input, keeping the pass linear on hostile pages.
|
|
8
|
+
* end of input, keeping the pass linear on hostile pages. Bare tag removal is a
|
|
9
|
+
* scanner rather than a regex: see removeTags.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const NAMED_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
|
|
@@ -18,7 +19,34 @@ function decodeAllEntities(text: string): string {
|
|
|
18
19
|
})
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/** The HTML tokenizer's tag-open rule: `<` starts a tag only before a letter, `/`,
|
|
23
|
+
* `!`, or `?`; any other `<` (as in `1 < 2`) is text. */
|
|
24
|
+
const TAG_OPEN = /^<[A-Za-z/!?]/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drop every tag in one linear pass. A regex strip can rebuild a tag out of nested
|
|
28
|
+
* brackets: `<scr<b>ipt>` loses `<b>` and becomes `<script>` (CodeQL's incomplete
|
|
29
|
+
* multi-character sanitization). Skipping from a tag's `<` to the next `>` removes the
|
|
30
|
+
* whole span, so nothing removed can reassemble; a tag that never closes stays as text.
|
|
31
|
+
*/
|
|
32
|
+
export function removeTags(html: string): string {
|
|
33
|
+
let out = ''
|
|
34
|
+
let cursor = 0
|
|
35
|
+
while (cursor < html.length) {
|
|
36
|
+
const open = html.indexOf('<', cursor)
|
|
37
|
+
if (open === -1) return out + html.slice(cursor)
|
|
38
|
+
if (!TAG_OPEN.test(html.slice(open, open + 2))) {
|
|
39
|
+
out += html.slice(cursor, open + 1)
|
|
40
|
+
cursor = open + 1
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
const close = html.indexOf('>', open + 1)
|
|
44
|
+
if (close === -1) return out + html.slice(cursor)
|
|
45
|
+
out += html.slice(cursor, open)
|
|
46
|
+
cursor = close + 1
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
22
50
|
|
|
23
51
|
// Strip leading and trailing newline runs in linear time. The equivalent
|
|
24
52
|
// /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
|
|
@@ -37,23 +65,23 @@ export function htmlToMarkdown(html: string): string {
|
|
|
37
65
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
38
66
|
.replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
|
|
39
67
|
.replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
|
|
40
|
-
preBodies.push(trimNewlines(decodeAllEntities(
|
|
68
|
+
preBodies.push(trimNewlines(decodeAllEntities(removeTags(inner))))
|
|
41
69
|
return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
|
|
42
70
|
})
|
|
43
71
|
|
|
44
72
|
work = work
|
|
45
|
-
.replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${
|
|
73
|
+
.replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${removeTags(inner)}\``)
|
|
46
74
|
// Only real web links become markdown links; fragment and javascript hrefs
|
|
47
75
|
// keep their label and lose the target.
|
|
48
76
|
.replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
|
|
49
77
|
const href = decodeAllEntities(dq ?? sq ?? '')
|
|
50
|
-
const label =
|
|
78
|
+
const label = removeTags(inner).trim()
|
|
51
79
|
if (!label) return ' '
|
|
52
80
|
return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
|
|
53
81
|
})
|
|
54
|
-
.replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${
|
|
55
|
-
.replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${
|
|
56
|
-
.replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${
|
|
82
|
+
.replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${removeTags(inner).trim()}**`)
|
|
83
|
+
.replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${removeTags(inner).trim()}*`)
|
|
84
|
+
.replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${removeTags(inner).trim()}\n\n`)
|
|
57
85
|
.replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
|
|
58
86
|
.replace(/<li\b[^<>]*>/gi, '\n- ')
|
|
59
87
|
.replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
|
|
@@ -61,7 +89,7 @@ export function htmlToMarkdown(html: string): string {
|
|
|
61
89
|
.replace(/<(?:br|hr)\b[^<>]*>/gi, '\n')
|
|
62
90
|
.replace(/<\/(?:p|div|section|article|ul|ol|li|table|tr|blockquote|tbody|thead|header|footer|main|nav)[^<>]*>/gi, '\n\n')
|
|
63
91
|
|
|
64
|
-
const text = decodeAllEntities(work
|
|
92
|
+
const text = decodeAllEntities(removeTags(work))
|
|
65
93
|
.replace(/[ \t]+/g, ' ')
|
|
66
94
|
.replace(/ ?\n ?/g, '\n')
|
|
67
95
|
.replace(/\n{3,}/g, '\n\n')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model named by an override (a prompt hook's `model`, /goal's evaluator model),
|
|
3
|
+
* resolved against the models this user can run: exact id first, then a substring
|
|
4
|
+
* of the id or display name. The session model stands in when nothing matches or no
|
|
5
|
+
* override was given, so a misspelled override degrades to the default rather than
|
|
6
|
+
* silently disabling the feature.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ModelLookupContext<M> {
|
|
10
|
+
model: M | undefined
|
|
11
|
+
modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveModelOverride<M>(ctx: ModelLookupContext<M>, override: string | undefined): M | undefined {
|
|
15
|
+
if (!override) return ctx.model
|
|
16
|
+
const available = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
17
|
+
const needle = override.toLowerCase()
|
|
18
|
+
const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
|
|
19
|
+
return (match as M | undefined) ?? ctx.model
|
|
20
|
+
}
|
|
@@ -15,12 +15,13 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
|
|
|
15
15
|
/**
|
|
16
16
|
* Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
|
|
17
17
|
* string by a byte budget keeps up to three times the bytes asked for; cutting the
|
|
18
|
-
* encoded buffer is exact. A character straddling the cut
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* encoded buffer is exact. A character straddling the cut is dropped (the streaming
|
|
19
|
+
* decoder holds back an incomplete sequence instead of emitting U+FFFD), so the
|
|
20
|
+
* result is whole characters within the budget. Shorter input comes back whole and a
|
|
21
|
+
* negative budget yields nothing, so callers need no length check of their own.
|
|
21
22
|
*/
|
|
22
|
-
function sliceBytes(text: string, maxBytes: number): string {
|
|
23
|
-
return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes))
|
|
23
|
+
export function sliceBytes(text: string, maxBytes: number): string {
|
|
24
|
+
return new TextDecoder().decode(Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)), { stream: true })
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* fenced code block (backtick or tilde).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/** The fence a line opens or closes, if any
|
|
14
|
+
/** The fence a line opens or closes, if any. */
|
|
15
15
|
export function fenceMarker(lineStart: string): string | null {
|
|
16
16
|
if (lineStart.startsWith('```')) return '`'
|
|
17
17
|
if (lineStart.startsWith('~~~')) return '~'
|
|
@@ -29,14 +29,14 @@ function fenceLength(lineStart: string, marker: string): number {
|
|
|
29
29
|
// is at least as long as the opener, so both are tracked: a shorter same-char
|
|
30
30
|
// fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
|
|
31
31
|
// content, not a closer.
|
|
32
|
-
interface Fence {
|
|
32
|
+
export interface Fence {
|
|
33
33
|
marker: string
|
|
34
34
|
length: number
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** The fence state after a line, plus whether the line is fenced code (opener,
|
|
38
38
|
* body, or closer) and so emitted verbatim rather than scanned for comments. */
|
|
39
|
-
function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
39
|
+
export function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
|
|
40
40
|
if (marker !== null && fence === null) {
|
|
41
41
|
return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
|
|
42
42
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -98,11 +98,36 @@ export function stampModified(content: string, iso: string): string {
|
|
|
98
98
|
return `---\n${body}modified: ${iso}\n---${rest}`
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Length of the line break `text` starts with: CRLF, LF, or none. */
|
|
102
|
+
function leadingLineBreak(text: string): number {
|
|
103
|
+
if (text.startsWith('\r\n')) return 2
|
|
104
|
+
return text.startsWith('\n') ? 1 : 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Drop every `<!-- ... -->` (and the line break after it) in one pass. A regex strip
|
|
108
|
+
* can rebuild a comment from one nested in another: `<!-<!-- x -->-- y -->` loses the
|
|
109
|
+
* inner comment and becomes `<!--- y -->`. After a removal the scan resumes three
|
|
110
|
+
* characters back, so a comment assembled across the cut is removed too; an opener
|
|
111
|
+
* that never closes stays as text. */
|
|
112
|
+
function removeComments(text: string): string {
|
|
113
|
+
let out = text
|
|
114
|
+
let cursor = 0
|
|
115
|
+
while (cursor < out.length) {
|
|
116
|
+
const open = out.indexOf('<!--', cursor)
|
|
117
|
+
const close = open === -1 ? -1 : out.indexOf('-->', open + 4)
|
|
118
|
+
if (close === -1) return out
|
|
119
|
+
const tail = out.slice(close + 3)
|
|
120
|
+
out = out.slice(0, open) + tail.slice(leadingLineBreak(tail))
|
|
121
|
+
cursor = Math.max(0, open - 3)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
101
126
|
/** The index content that actually loads: YAML frontmatter and block-level HTML
|
|
102
127
|
* comments are stripped, so they neither show in the prompt nor count toward the
|
|
103
128
|
* 200-line / 25KB read limits, matching Claude Code. */
|
|
104
129
|
export function stripNonLoaded(text: string): string {
|
|
105
|
-
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
|
|
130
|
+
return removeComments(text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ''))
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
/** Move a store written under an older slug to the current one, once. Two earlier
|
|
@@ -429,7 +429,9 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
429
429
|
planModeEnabled = true
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// The current branch only: getEntries() lists every branch in the file, so after a
|
|
433
|
+
// rewind past a plan it would resurrect the abandoned plan and its tool restriction.
|
|
434
|
+
const entries = ctx.sessionManager.getBranch()
|
|
433
435
|
|
|
434
436
|
// Restore persisted state
|
|
435
437
|
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
|
package/extensions/question.ts
CHANGED
|
@@ -179,7 +179,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
179
179
|
|
|
180
180
|
renderCall(args, theme, _context) {
|
|
181
181
|
const multi = args.multiSelect === true
|
|
182
|
-
const heading = args.header ? `[${args.header}] ` : ''
|
|
182
|
+
const heading = args.header ? `[${shortHeader(String(args.header))}] ` : ''
|
|
183
183
|
let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
|
|
184
184
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
185
185
|
if (opts.length) {
|