pi-code 1.0.46 → 1.0.48
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 +5 -3
- package/extensions/commands.ts +1 -3
- package/extensions/goal.ts +522 -0
- package/extensions/hooks/index.ts +2 -7
- package/extensions/internal/command-file.ts +1 -1
- 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/memory.ts +26 -1
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/web.ts +2 -6
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
12
12
|
[](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
|
|
13
13
|
|
|
14
|
-
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and
|
|
14
|
+
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, subagents, and goals.
|
|
15
15
|
|
|
16
16
|
What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes.
|
|
17
17
|
|
|
@@ -53,9 +53,10 @@ Each topic links to its own doc with the full contract and any divergences from
|
|
|
53
53
|
- **[Statusline](docs/statusline.md)** — your Claude `statusLine` command with the documented stdin JSON.
|
|
54
54
|
- **[WebSearch / WebFetch](docs/web.md)** — key-free search and SSRF-guarded fetch.
|
|
55
55
|
- **[Claude plugins](docs/plugins.md)** — installed marketplace plugins: commands, agents, hooks, MCP servers, styles, skills.
|
|
56
|
+
- **[Goal](docs/goal.md)**: `/goal <condition>` keeps the session working until a separate model check confirms the condition holds, with status, clear, block cap, background-work deferral, and resume.
|
|
56
57
|
- **[Session extras](docs/session-extras.md)** — project trust, plan mode, todos, checkpoints/rewind, AskUserQuestion, notifications, think keywords, session titles, `/context`, `/init`.
|
|
57
58
|
|
|
58
|
-
Slash commands: `/init`, `/context`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
|
|
59
|
+
Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
|
|
59
60
|
|
|
60
61
|
pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
|
|
61
62
|
|
|
@@ -69,7 +70,8 @@ Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example
|
|
|
69
70
|
|
|
70
71
|
```bash
|
|
71
72
|
npm install
|
|
72
|
-
npm run check # biome + strict tsc + vitest, the whole gate
|
|
73
|
+
npm run check # biome + strict tsc + knip + vitest with coverage floors, the whole gate
|
|
74
|
+
scripts/e2e-smoke.sh # headless deterministic smoke: wire payload against a dead-port model, no real model (also runs in CI)
|
|
73
75
|
scripts/e2e.sh # quick smoke of the real pi TUI via tmux (needs a working model)
|
|
74
76
|
scripts/e2e-full.sh # every README feature end to end, model turns included (5-15 min)
|
|
75
77
|
scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
|
package/extensions/commands.ts
CHANGED
|
@@ -45,7 +45,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
|
|
|
45
45
|
import { Type } from 'typebox'
|
|
46
46
|
|
|
47
47
|
import { matchesBashRules } from './internal/bash-rules.js'
|
|
48
|
-
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
48
|
+
import { type CommandExec, type DiscoveredCommand, discoverCommandFiles, expandDynamicContent, type ParsedCommand, type PathRuleTool, parseCommandFile, resolvePowershellBinary, spanExec, substituteArgsDetailed, substituteVars } from './internal/command-file.js'
|
|
49
49
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
50
50
|
import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
|
|
51
51
|
import { capForContext } from './internal/output-guard.js'
|
|
@@ -56,8 +56,6 @@ import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
|
56
56
|
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
57
57
|
import { createTurnOverride } from './internal/turn-override.js'
|
|
58
58
|
|
|
59
|
-
type PathRuleTool = 'read' | 'edit' | 'write'
|
|
60
|
-
|
|
61
59
|
/** Just enough of pi's Model to match and restore; getAvailable returns these. */
|
|
62
60
|
interface ModelLike {
|
|
63
61
|
id: string
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal Extension
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's /goal: a completion condition the session keeps working toward. After
|
|
5
|
+
* each turn a separate model judges the condition against the conversation and returns
|
|
6
|
+
* met, not yet met (its reason becomes the next turn's guidance), or impossible. Claude
|
|
7
|
+
* implements it as a session-scoped prompt-based Stop hook; pi-code runs the same loop
|
|
8
|
+
* on agent_end next to the hooks extension's Stop path:
|
|
9
|
+
* - `/goal <condition>` sets (or replaces) the goal and starts a turn with Claude's
|
|
10
|
+
* kickoff directive; `/goal` shows status; `/goal clear` (stop/off/reset/none/cancel)
|
|
11
|
+
* removes it. The condition is capped at 4,000 characters.
|
|
12
|
+
* - The evaluator is the session model, or ANTHROPIC_DEFAULT_HAIKU_MODEL resolved against
|
|
13
|
+
* the models this user can run. It reads the branch transcript trimmed to half its
|
|
14
|
+
* context window and cannot run tools, so the condition must be provable from output.
|
|
15
|
+
* - The Stop hooks' consecutive-block cap (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, default 8)
|
|
16
|
+
* bounds a stalled loop: that many not-met verdicts in a row on turns that used no tool
|
|
17
|
+
* pause the loop with a warning, goal still set, until the next user prompt.
|
|
18
|
+
* - Evaluation is skipped while a subagent is still running (tracked from the subagent
|
|
19
|
+
* extension's bus events; pi has no other background work). A check-in turn is
|
|
20
|
+
* injected once the wait reaches CLAUDE_CODE_GOAL_CHECKIN_MINUTES (30, doubling up to
|
|
21
|
+
* four times the first interval, at most three idle check-ins between user prompts;
|
|
22
|
+
* 0 turns check-ins off).
|
|
23
|
+
* - A turn ended by the user (Esc) or by an error is not evaluated, and an Esc during
|
|
24
|
+
* the evaluation cancels it with the goal left set. An unrecoverable
|
|
25
|
+
* error (authentication, credits, context overflow, model unavailable) clears the goal
|
|
26
|
+
* with a warning once the run settles; a transient one leaves it set.
|
|
27
|
+
* - The active goal persists as a session entry and is restored on resume or reload with
|
|
28
|
+
* the turn count, timer, and token baseline reset; an achieved, failed, or cleared goal
|
|
29
|
+
* is not restored.
|
|
30
|
+
* - Gated as Claude documents: unavailable when hooks are restricted (disableAllHooks or
|
|
31
|
+
* allowManagedHooksOnly) or the project is not trusted, with the reason shown.
|
|
32
|
+
* - Headless (`pi -p "/goal ..."`), the command holds the process open until the goal
|
|
33
|
+
* resolves or pauses, and warnings also go to stderr since there is no notify surface.
|
|
34
|
+
*
|
|
35
|
+
* Docs: https://code.claude.com/docs/en/goal.md
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as os from 'node:os'
|
|
39
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
40
|
+
|
|
41
|
+
import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
|
|
42
|
+
import {
|
|
43
|
+
checkinIntervalMs,
|
|
44
|
+
checkinText,
|
|
45
|
+
classifyUnrecoverable,
|
|
46
|
+
EVALUATOR_SYSTEM,
|
|
47
|
+
evaluatorPrompt,
|
|
48
|
+
formatAchievedGoal,
|
|
49
|
+
formatActiveGoal,
|
|
50
|
+
formatDuration,
|
|
51
|
+
formatTokens,
|
|
52
|
+
GOAL_CONDITION_MAX_CHARS,
|
|
53
|
+
type GoalSummary,
|
|
54
|
+
type GoalVerdict,
|
|
55
|
+
isClearAlias,
|
|
56
|
+
kickoffPrompt,
|
|
57
|
+
MAX_IDLE_CHECKINS,
|
|
58
|
+
NO_GOAL_TEXT,
|
|
59
|
+
parseVerdict,
|
|
60
|
+
type RunningWork,
|
|
61
|
+
renderTranscript,
|
|
62
|
+
summaryText,
|
|
63
|
+
} from './internal/goal-evaluator.js'
|
|
64
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
65
|
+
import { completeText } from './internal/model-complete.js'
|
|
66
|
+
import { resolveModelOverride } from './internal/model-lookup.js'
|
|
67
|
+
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
68
|
+
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
69
|
+
|
|
70
|
+
/** Session entry type the goal state persists under, and the custom message type its
|
|
71
|
+
* transcript lines (kickoff, verdicts, check-ins) carry. */
|
|
72
|
+
const GOAL_ENTRY = 'goal'
|
|
73
|
+
const GOAL_MESSAGE = 'goal'
|
|
74
|
+
/** Claude's prompt-hook default; a slow evaluator is an error, not a stall. */
|
|
75
|
+
const EVALUATOR_TIMEOUT_MS = 30_000
|
|
76
|
+
/** A verdict is one JSON object; the cap stops a runaway reply. */
|
|
77
|
+
const EVALUATOR_MAX_TOKENS = 512
|
|
78
|
+
/** Claude trims the transcript to half the evaluator's context window. */
|
|
79
|
+
const TRANSCRIPT_WINDOW_SHARE = 0.5
|
|
80
|
+
const CHARS_PER_TOKEN = 4
|
|
81
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000
|
|
82
|
+
/** The `◎ goal <elapsed>` indicator re-renders on this cadence while a goal is active. */
|
|
83
|
+
const INDICATOR_REFRESH_MS = 60_000
|
|
84
|
+
|
|
85
|
+
const HOOKS_GATE = "/goal can't run while hooks are restricted (disableAllHooks or allowManagedHooksOnly is set in settings or by policy)."
|
|
86
|
+
const TRUST_GATE = '/goal is only available in trusted workspaces. Restart, accept the trust dialog, and try again.'
|
|
87
|
+
|
|
88
|
+
type GoalState = 'active' | 'cleared' | 'achieved' | 'failed'
|
|
89
|
+
|
|
90
|
+
interface GoalEntry {
|
|
91
|
+
state: GoalState
|
|
92
|
+
condition: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ActiveGoal {
|
|
96
|
+
condition: string
|
|
97
|
+
setAt: number
|
|
98
|
+
iterations: number
|
|
99
|
+
lastReason?: string
|
|
100
|
+
/** Session token total when the goal was set; spend is measured from here. */
|
|
101
|
+
tokensAtStart: number
|
|
102
|
+
/** Evaluator calls are not on the session total; they count toward the goal's spend. */
|
|
103
|
+
evaluatorTokens: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface TurnMessage {
|
|
107
|
+
role?: string
|
|
108
|
+
stopReason?: string
|
|
109
|
+
errorMessage?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface TokenUsage {
|
|
113
|
+
totalTokens?: number
|
|
114
|
+
input?: number
|
|
115
|
+
output?: number
|
|
116
|
+
cacheRead?: number
|
|
117
|
+
cacheWrite?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function usageTokens(usage: TokenUsage | undefined): number {
|
|
121
|
+
if (!usage) return 0
|
|
122
|
+
return usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lastAssistant(messages: readonly unknown[]): TurnMessage | undefined {
|
|
126
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
127
|
+
const message = messages[i] as TurnMessage
|
|
128
|
+
if (message?.role === 'assistant') return message
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type SessionReader = Pick<ExtensionContext, 'sessionManager'>
|
|
134
|
+
|
|
135
|
+
/** The branch as the evaluator reads it: messages plus the goal's own transcript lines,
|
|
136
|
+
* which live in custom_message entries. */
|
|
137
|
+
function branchMessages(ctx: SessionReader): unknown[] {
|
|
138
|
+
const messages: unknown[] = []
|
|
139
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
140
|
+
if (entry.type === 'message') messages.push(entry.message)
|
|
141
|
+
else if (entry.type === 'custom_message') messages.push({ role: 'custom', customType: entry.customType, content: entry.content })
|
|
142
|
+
}
|
|
143
|
+
return messages
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cumulative assistant usage on the branch: the session's token spend so far. */
|
|
147
|
+
function sessionTokens(ctx: SessionReader): number {
|
|
148
|
+
let total = 0
|
|
149
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
150
|
+
if (entry.type === 'message' && entry.message.role === 'assistant') total += usageTokens(entry.message.usage as TokenUsage | undefined)
|
|
151
|
+
}
|
|
152
|
+
return total
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The newest goal entry on the branch, which is the goal's persisted state. */
|
|
156
|
+
function lastGoalEntry(ctx: SessionReader): GoalEntry | undefined {
|
|
157
|
+
const branch = ctx.sessionManager.getBranch()
|
|
158
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
159
|
+
const entry = branch[i]
|
|
160
|
+
if (entry.type !== 'custom' || entry.customType !== GOAL_ENTRY) continue
|
|
161
|
+
const data = entry.data as Partial<GoalEntry> | undefined
|
|
162
|
+
return typeof data?.condition === 'string' && typeof data.state === 'string' ? { state: data.state, condition: data.condition } : undefined
|
|
163
|
+
}
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Claude's availability rule: /goal rides the hooks system, so a hooks restriction or an
|
|
168
|
+
* untrusted workspace refuses it with the reason. Checked in Claude's order. */
|
|
169
|
+
function goalUnavailable(ctx: Pick<ExtensionContext, 'cwd' | 'isProjectTrusted' | 'ui'>): string | undefined {
|
|
170
|
+
const managed = readManagedSettings()
|
|
171
|
+
const trusted = isProjectApprovedSilently(ctx)
|
|
172
|
+
const restricted = managed.disableAllHooks === true || managed.allowManagedHooksOnly === true || readSettingsDisableAllHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
173
|
+
if (restricted) return HOOKS_GATE
|
|
174
|
+
if (!trusted) return TRUST_GATE
|
|
175
|
+
return undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Claude evaluates on its small fast model, ANTHROPIC_DEFAULT_HAIKU_MODEL overriding it;
|
|
179
|
+
* pi has no such tier, so the session model stands in unless the override names one of
|
|
180
|
+
* the models this user can run. */
|
|
181
|
+
function evaluatorModel(ctx: ExtensionContext): ExtensionContext['model'] {
|
|
182
|
+
return resolveModelOverride(ctx, process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || undefined)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function transcriptBudget(model: { contextWindow?: number }): number {
|
|
186
|
+
return Math.floor((model.contextWindow || DEFAULT_CONTEXT_WINDOW) * TRANSCRIPT_WINDOW_SHARE * CHARS_PER_TOKEN)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default function goalExtension(pi: ExtensionAPI) {
|
|
190
|
+
let goal: ActiveGoal | undefined
|
|
191
|
+
/** The last goal achieved this session, for the status view after it clears. */
|
|
192
|
+
let achieved: GoalSummary | undefined
|
|
193
|
+
/** The live context, for timer callbacks that fire outside any pi event. */
|
|
194
|
+
let sessionCtx: ExtensionContext | undefined
|
|
195
|
+
/** Bumped whenever the goal is set, cleared, or the session changes, so an evaluation
|
|
196
|
+
* that resolves after any of those is dropped instead of steering the wrong goal. */
|
|
197
|
+
let generation = 0
|
|
198
|
+
/** Whether the run now ending executed a tool: Claude's "progress" for the block cap. */
|
|
199
|
+
let turnUsedTools = false
|
|
200
|
+
/** Not-met verdicts in a row on turns that used no tool. */
|
|
201
|
+
let noProgressStreak = 0
|
|
202
|
+
/** The error the last run ended on, judged once the run settles (past pi's retries). */
|
|
203
|
+
let lastTurnError: string | undefined
|
|
204
|
+
/** Subagents still running, by id, from the subagent extension's bus events. */
|
|
205
|
+
const running = new Map<string, string>()
|
|
206
|
+
let deferredSince: number | undefined
|
|
207
|
+
let checkinsDelivered = 0
|
|
208
|
+
let idleCheckins = 0
|
|
209
|
+
/** A check-in came due while a turn was running: deliver it at the next turn end. */
|
|
210
|
+
let checkinDue = false
|
|
211
|
+
let checkinTimer: ReturnType<typeof setTimeout> | undefined
|
|
212
|
+
let indicatorTimer: ReturnType<typeof setInterval> | undefined
|
|
213
|
+
|
|
214
|
+
/** A goal line in the transcript, which the model also reads. A timer can outlive the
|
|
215
|
+
* session that armed it, and sendMessage throws on a disposed one. */
|
|
216
|
+
function send(content: string, options: { triggerTurn: boolean; deliverAs?: 'followUp' }): void {
|
|
217
|
+
try {
|
|
218
|
+
pi.sendMessage({ customType: GOAL_MESSAGE, content, display: true }, options)
|
|
219
|
+
} catch {
|
|
220
|
+
// Disposed session: nothing left to tell.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A line for the user. Headless runs have no notify surface, and a refusal or a
|
|
225
|
+
* loop that stops there without a word would look like a hang, so it also goes to
|
|
226
|
+
* stderr (where Claude's -p mode prints its goal messages). */
|
|
227
|
+
function tell(ctx: ExtensionContext, text: string, level: 'info' | 'warning' | 'error'): void {
|
|
228
|
+
ctx.ui.notify(text, level)
|
|
229
|
+
if (!ctx.hasUI) process.stderr.write(`${text}\n`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function currentSummary(ctx: SessionReader): GoalSummary | undefined {
|
|
233
|
+
if (!goal) return undefined
|
|
234
|
+
return { condition: goal.condition, durationMs: Date.now() - goal.setAt, iterations: goal.iterations, tokens: sessionTokens(ctx) - goal.tokensAtStart + goal.evaluatorTokens, lastReason: goal.lastReason }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function showIndicator(ctx: ExtensionContext): void {
|
|
238
|
+
if (!goal) return
|
|
239
|
+
ctx.ui.setStatus('goal', ctx.ui.theme.fg('accent', `◎ goal ${formatDuration(Date.now() - goal.setAt)}`))
|
|
240
|
+
if (!indicatorTimer) {
|
|
241
|
+
indicatorTimer = setInterval(() => {
|
|
242
|
+
if (sessionCtx) showIndicator(sessionCtx)
|
|
243
|
+
}, INDICATOR_REFRESH_MS)
|
|
244
|
+
indicatorTimer.unref?.()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function stopIndicator(ctx: ExtensionContext): void {
|
|
249
|
+
clearInterval(indicatorTimer)
|
|
250
|
+
indicatorTimer = undefined
|
|
251
|
+
ctx.ui.setStatus('goal', undefined)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function stopCheckin(): void {
|
|
255
|
+
clearTimeout(checkinTimer)
|
|
256
|
+
checkinTimer = undefined
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function endDeferral(): void {
|
|
260
|
+
deferredSince = undefined
|
|
261
|
+
checkinDue = false
|
|
262
|
+
stopCheckin()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Make `condition` the active goal with fresh counters; the entry is the caller's. */
|
|
266
|
+
function activate(ctx: ExtensionContext, condition: string): void {
|
|
267
|
+
goal = { condition, setAt: Date.now(), iterations: 0, tokensAtStart: sessionTokens(ctx), evaluatorTokens: 0 }
|
|
268
|
+
generation += 1
|
|
269
|
+
noProgressStreak = 0
|
|
270
|
+
checkinsDelivered = 0
|
|
271
|
+
idleCheckins = 0
|
|
272
|
+
endDeferral()
|
|
273
|
+
showIndicator(ctx)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Drop the active goal, recording how it ended; returns its condition. */
|
|
277
|
+
function endGoal(ctx: ExtensionContext, state: Exclude<GoalState, 'active'>): string | undefined {
|
|
278
|
+
const ended = goal
|
|
279
|
+
if (!ended) return undefined
|
|
280
|
+
goal = undefined
|
|
281
|
+
generation += 1
|
|
282
|
+
endDeferral()
|
|
283
|
+
stopIndicator(ctx)
|
|
284
|
+
pi.appendEntry(GOAL_ENTRY, { state, condition: ended.condition } satisfies GoalEntry)
|
|
285
|
+
return ended.condition
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function finish(ctx: ExtensionContext, state: 'achieved' | 'failed', reason: string): void {
|
|
289
|
+
const summary = currentSummary(ctx)
|
|
290
|
+
if (!summary) return
|
|
291
|
+
if (state === 'achieved') achieved = summary
|
|
292
|
+
endGoal(ctx, state)
|
|
293
|
+
const head = state === 'achieved' ? `Goal achieved (${summaryText(summary)}): ${summary.condition}` : `Goal could not be achieved (${summaryText(summary)}): ${summary.condition}`
|
|
294
|
+
send(reason ? `${head}\nEvaluator: ${reason}` : head, { triggerTurn: false })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Claude feeds a not-met reason back as the next turn, under the Stop hooks' cap. */
|
|
298
|
+
function continueGoal(ctx: ExtensionContext, active: ActiveGoal, reason: string): void {
|
|
299
|
+
noProgressStreak = turnUsedTools ? 0 : noProgressStreak + 1
|
|
300
|
+
const cap = stopHookBlockCap()
|
|
301
|
+
showIndicator(ctx)
|
|
302
|
+
if (noProgressStreak >= cap) {
|
|
303
|
+
noProgressStreak = 0
|
|
304
|
+
tell(ctx, `Goal paused after ${cap} turns in a row without tool use; it stays set and evaluation resumes after your next prompt.`, 'warning')
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const summary = currentSummary(ctx)
|
|
308
|
+
const spent = summary ? ` · ${formatDuration(summary.durationMs)} · ${formatTokens(summary.tokens)} tokens` : ''
|
|
309
|
+
send(`Goal not yet met (turn ${active.iterations}${spent}): ${reason}\nGoal: ${active.condition}`, { triggerTurn: true })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function askEvaluator(ctx: ExtensionContext, active: ActiveGoal, model: NonNullable<ExtensionContext['model']>): Promise<GoalVerdict> {
|
|
313
|
+
const transcript = renderTranscript(branchMessages(ctx), transcriptBudget(model))
|
|
314
|
+
// Esc during the evaluation aborts the run's signal; the call must die with it, or
|
|
315
|
+
// a late verdict would queue the next goal turn into a run the user just stopped.
|
|
316
|
+
const deadline = AbortSignal.timeout(EVALUATOR_TIMEOUT_MS)
|
|
317
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline
|
|
318
|
+
// Claude runs its evaluator with thinking disabled: the verdict is mechanical.
|
|
319
|
+
// completeText requests no thinking level, which is the same for pi.
|
|
320
|
+
const { text, usage } = await completeText(model, evaluatorPrompt(transcript, active.condition), { system: EVALUATOR_SYSTEM, maxTokens: EVALUATOR_MAX_TOKENS, signal })
|
|
321
|
+
active.evaluatorTokens += usageTokens(usage as TokenUsage | undefined)
|
|
322
|
+
const verdict = parseVerdict(text)
|
|
323
|
+
if (!verdict) throw new Error(`unreadable verdict: ${text.slice(0, 200)}`)
|
|
324
|
+
return verdict
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function evaluate(ctx: ExtensionContext): Promise<void> {
|
|
328
|
+
const active = goal
|
|
329
|
+
if (!active) return
|
|
330
|
+
const startedGeneration = generation
|
|
331
|
+
const model = evaluatorModel(ctx)
|
|
332
|
+
if (!model) {
|
|
333
|
+
tell(ctx, 'Goal evaluator has no model to run on; the goal stays set.', 'warning')
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
let verdict: GoalVerdict
|
|
337
|
+
try {
|
|
338
|
+
verdict = await askEvaluator(ctx, active, model)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// A user interrupt is not an evaluator failure: the goal stays, nothing to say.
|
|
341
|
+
if (ctx.signal?.aborted) return
|
|
342
|
+
// No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
|
|
343
|
+
if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${error instanceof Error ? error.message : String(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
// Interrupted, cleared, or replaced during the await: this verdict must not act.
|
|
347
|
+
if (ctx.signal?.aborted || generation !== startedGeneration) return
|
|
348
|
+
active.iterations += 1
|
|
349
|
+
active.lastReason = verdict.reason
|
|
350
|
+
if (verdict.ok) finish(ctx, 'achieved', verdict.reason)
|
|
351
|
+
else if (verdict.impossible) finish(ctx, 'failed', verdict.reason)
|
|
352
|
+
else continueGoal(ctx, active, verdict.reason)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function deliverCheckin(paused: boolean): void {
|
|
356
|
+
if (!goal) return
|
|
357
|
+
checkinDue = false
|
|
358
|
+
checkinsDelivered += 1
|
|
359
|
+
const work: RunningWork[] = [...running].map(([id, agentType]) => ({ id, agentType }))
|
|
360
|
+
send(checkinText(goal.condition, Date.now() - (deferredSince ?? Date.now()), work, paused), { triggerTurn: true })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function onCheckinDue(): void {
|
|
364
|
+
checkinTimer = undefined
|
|
365
|
+
if (!goal || !sessionCtx) return
|
|
366
|
+
// Claude delivers a due check-in at the next turn end when a turn is running, and
|
|
367
|
+
// only starts idle turns for it up to the per-prompt cap.
|
|
368
|
+
if (!sessionCtx.isIdle() || idleCheckins >= MAX_IDLE_CHECKINS) {
|
|
369
|
+
checkinDue = true
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
idleCheckins += 1
|
|
373
|
+
deliverCheckin(idleCheckins >= MAX_IDLE_CHECKINS)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function armCheckin(): void {
|
|
377
|
+
if (checkinTimer) return
|
|
378
|
+
const ms = checkinIntervalMs(process.env, checkinsDelivered)
|
|
379
|
+
if (ms <= 0) return
|
|
380
|
+
checkinTimer = setTimeout(onCheckinDue, ms)
|
|
381
|
+
checkinTimer.unref?.()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Background work keeps the goal waiting: no verdict this turn, a check-in later. */
|
|
385
|
+
function deferEvaluation(): void {
|
|
386
|
+
deferredSince ??= Date.now()
|
|
387
|
+
if (checkinDue) {
|
|
388
|
+
deliverCheckin(false)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
armCheckin()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function statusText(ctx: SessionReader): string {
|
|
395
|
+
const summary = currentSummary(ctx)
|
|
396
|
+
if (summary) return formatActiveGoal(summary)
|
|
397
|
+
if (achieved) return formatAchievedGoal(achieved)
|
|
398
|
+
return NO_GOAL_TEXT
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
pi.registerCommand('goal', {
|
|
402
|
+
description: 'Set a goal the session keeps working toward until a separate check confirms it is met; /goal shows status, /goal clear stops',
|
|
403
|
+
handler: async (args, ctx) => {
|
|
404
|
+
sessionCtx = ctx
|
|
405
|
+
const condition = args.trim()
|
|
406
|
+
if (condition === '') {
|
|
407
|
+
tell(ctx, statusText(ctx), 'info')
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
if (isClearAlias(condition)) {
|
|
411
|
+
const cleared = endGoal(ctx, 'cleared')
|
|
412
|
+
if (cleared === undefined) tell(ctx, 'No goal set', 'info')
|
|
413
|
+
else send(`Goal cleared: ${cleared}`, { triggerTurn: false })
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (condition.length > GOAL_CONDITION_MAX_CHARS) {
|
|
417
|
+
tell(ctx, `Goal condition is limited to ${GOAL_CONDITION_MAX_CHARS} characters (got ${condition.length})`, 'error')
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const blocked = goalUnavailable(ctx)
|
|
421
|
+
if (blocked) {
|
|
422
|
+
tell(ctx, blocked, 'error')
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
// A new goal replaces the current one, as Claude documents; the entry written here
|
|
426
|
+
// is the state a resume restores.
|
|
427
|
+
activate(ctx, condition)
|
|
428
|
+
pi.appendEntry(GOAL_ENTRY, { state: 'active', condition } satisfies GoalEntry)
|
|
429
|
+
tell(ctx, `Goal set: ${condition}`, 'info')
|
|
430
|
+
// Setting a goal starts a turn immediately with the condition as the directive; a
|
|
431
|
+
// send during streaming queues it as a follow-up turn.
|
|
432
|
+
send(kickoffPrompt(condition), ctx.isIdle() ? { triggerTurn: true } : { triggerTurn: true, deliverAs: 'followUp' })
|
|
433
|
+
// A headless run (`pi -p "/goal ..."`) exits as soon as the command returns, before
|
|
434
|
+
// the kickoff turn runs. pi marks the run active synchronously on the send, and the
|
|
435
|
+
// continuations queued at agent_end extend that run, so waiting here holds the
|
|
436
|
+
// process open until the goal resolves, as Claude's -p mode does. The TUI's main
|
|
437
|
+
// loop returns to the editor on its own, so it must not block on the loop.
|
|
438
|
+
if (!ctx.hasUI) await ctx.waitForIdle()
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
pi.events.on(SUBAGENT_CHANNEL, (data) => {
|
|
443
|
+
if (!isSubagentPhaseEvent(data)) return
|
|
444
|
+
if (data.phase === 'start') running.set(data.agentId, data.agentType)
|
|
445
|
+
else running.delete(data.agentId)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
pi.on('session_start', (_event, ctx) => {
|
|
449
|
+
// One extension instance serves every session: drop the previous session's goal and
|
|
450
|
+
// timers before reading this session's persisted state.
|
|
451
|
+
sessionCtx = ctx
|
|
452
|
+
goal = undefined
|
|
453
|
+
achieved = undefined
|
|
454
|
+
generation += 1
|
|
455
|
+
turnUsedTools = false
|
|
456
|
+
noProgressStreak = 0
|
|
457
|
+
lastTurnError = undefined
|
|
458
|
+
checkinsDelivered = 0
|
|
459
|
+
idleCheckins = 0
|
|
460
|
+
endDeferral()
|
|
461
|
+
stopIndicator(ctx)
|
|
462
|
+
const entry = lastGoalEntry(ctx)
|
|
463
|
+
if (entry?.state !== 'active') return
|
|
464
|
+
// Claude restores a still-active goal on resume with its counters reset.
|
|
465
|
+
activate(ctx, entry.condition)
|
|
466
|
+
tell(ctx, `Goal restored: ${entry.condition}`, 'info')
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
pi.on('session_shutdown', () => {
|
|
470
|
+
stopCheckin()
|
|
471
|
+
clearInterval(indicatorTimer)
|
|
472
|
+
indicatorTimer = undefined
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
pi.on('agent_start', () => {
|
|
476
|
+
turnUsedTools = false
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
pi.on('tool_execution_start', () => {
|
|
480
|
+
turnUsedTools = true
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
pi.on('input', (event) => {
|
|
484
|
+
// Only genuine user input is progress: a goal continuation or a subagent prompt
|
|
485
|
+
// arrives with source 'extension'. Claude resets the block cap and the idle
|
|
486
|
+
// check-in allowance on the user's next prompt.
|
|
487
|
+
if (event.source === 'extension') return
|
|
488
|
+
noProgressStreak = 0
|
|
489
|
+
idleCheckins = 0
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// On agent_end rather than agent_settled, for the reason hooks.ts gives: a peer
|
|
493
|
+
// extension can hold its agent_end handler on a dialog, which would starve a settle-
|
|
494
|
+
// based evaluation; and a continuation sent here is picked up as the run's own
|
|
495
|
+
// continuation rather than a fresh prompt.
|
|
496
|
+
pi.on('agent_end', async (event, ctx) => {
|
|
497
|
+
sessionCtx = ctx
|
|
498
|
+
const last = lastAssistant(event.messages)
|
|
499
|
+
lastTurnError = last?.stopReason === 'error' ? (last.errorMessage ?? 'unknown error') : undefined
|
|
500
|
+
// Claude runs no Stop evaluation after a user interrupt; a failed turn is judged once
|
|
501
|
+
// the run settles, so neither is evaluated here and the goal stays set.
|
|
502
|
+
if (!goal || last?.stopReason === 'aborted' || lastTurnError !== undefined) return
|
|
503
|
+
if (running.size > 0) {
|
|
504
|
+
deferEvaluation()
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
endDeferral()
|
|
508
|
+
await evaluate(ctx)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
pi.on('agent_settled', (_event, ctx) => {
|
|
512
|
+
const message = lastTurnError
|
|
513
|
+
lastTurnError = undefined
|
|
514
|
+
if (!goal || message === undefined) return
|
|
515
|
+
const kind = classifyUnrecoverable(message)
|
|
516
|
+
if (!kind) return
|
|
517
|
+
endGoal(ctx, 'cleared')
|
|
518
|
+
const text = `Goal cleared after an unrecoverable error (${kind}): "${message}". Run /goal again to continue.`
|
|
519
|
+
send(text, { triggerTurn: false })
|
|
520
|
+
tell(ctx, text, 'warning')
|
|
521
|
+
})
|
|
522
|
+
}
|
|
@@ -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
|
|
@@ -307,7 +307,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
/** Split a raw argument string, keeping quoted runs together. */
|
|
310
|
-
|
|
310
|
+
function splitArgs(args: string): string[] {
|
|
311
311
|
const out: string[] = []
|
|
312
312
|
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
|
|
313
313
|
let match = pattern.exec(args)
|
|
@@ -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
|
+
}
|
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
|
|
@@ -120,7 +120,7 @@ export interface TodoItem {
|
|
|
120
120
|
completed: boolean
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
function cleanStepText(text: string): string {
|
|
124
124
|
let cleaned = text
|
|
125
125
|
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') // Remove bold/italic
|
|
126
126
|
.replace(/`([^`]+)`/g, '$1') // Remove code
|
package/extensions/web.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { Usage } from '@earendil-works/pi-ai'
|
|
|
13
13
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
14
14
|
import { Type } from 'typebox'
|
|
15
15
|
|
|
16
|
-
import { htmlToMarkdown } from './internal/html-markdown.js'
|
|
16
|
+
import { htmlToMarkdown, removeTags } from './internal/html-markdown.js'
|
|
17
17
|
import { completeText } from './internal/model-complete.js'
|
|
18
18
|
import { capForContext } from './internal/output-guard.js'
|
|
19
19
|
import { httpFetch } from './internal/web-transport.js'
|
|
@@ -40,11 +40,7 @@ export function decodeEntities(text: string): string {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function stripTags(html: string): string {
|
|
43
|
-
|
|
44
|
-
// instead of rescanning to end of input, which is what makes the strip linear.
|
|
45
|
-
return decodeEntities(html.replace(/<[^<>]*>/g, ''))
|
|
46
|
-
.replace(/\s+/g, ' ')
|
|
47
|
-
.trim()
|
|
43
|
+
return decodeEntities(removeTags(html)).replace(/\s+/g, ' ').trim()
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
/** Resolve DuckDuckGo's redirect links (/l/?uddg=<encoded>) to the target URL. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
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
|
|
3
|
+
"version": "1.0.48",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
"biome:fix": "npx --no-install @biomejs/biome check --write .",
|
|
42
42
|
"type:check": "tsc --noEmit",
|
|
43
43
|
"test": "vitest run --coverage",
|
|
44
|
-
"check": "npm run biome && npm run type:check && npm run test",
|
|
45
|
-
"prepublishOnly": "npm run check"
|
|
44
|
+
"check": "npm run biome && npm run type:check && npm run knip && npm run test",
|
|
45
|
+
"prepublishOnly": "npm run check",
|
|
46
|
+
"knip": "npx --no-install knip"
|
|
46
47
|
},
|
|
47
48
|
"engines": {
|
|
48
49
|
"node": ">=22.19"
|
|
@@ -68,6 +69,8 @@
|
|
|
68
69
|
"@earendil-works/pi-tui": "^0.84.2",
|
|
69
70
|
"@types/node": "^26.1.1",
|
|
70
71
|
"@vitest/coverage-v8": "^4.1.10",
|
|
72
|
+
"fast-check": "^4.9.0",
|
|
73
|
+
"knip": "^6.34.0",
|
|
71
74
|
"typescript": "^7.0.2",
|
|
72
75
|
"vitest": "^4.1.10"
|
|
73
76
|
}
|