decorated-pi 0.7.3 → 0.8.1
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 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +11 -146
- package/hooks/image-vision.ts +127 -5
- package/hooks/mcp.ts +10 -2
- package/hooks/pi-tool-filter.ts +2 -6
- package/hooks/skeleton.ts +4 -1
- package/hooks/smart-at.ts +18 -23
- package/hooks/thinking-label-strip.ts +79 -0
- package/index.ts +207 -176
- package/package.json +3 -1
- package/skills/pi-docs/SKILL.md +3 -4
- package/tools/ask/index.ts +1 -1
- package/tools/patch/core.ts +154 -81
- package/tools/patch/encoding.ts +172 -0
- package/tools/patch/index.ts +11 -7
- package/ui/ask.ts +71 -31
package/README.md
CHANGED
|
@@ -14,7 +14,9 @@ pi install /path/to/decorated-pi
|
|
|
14
14
|
|
|
15
15
|
### 1. Token Efficiency
|
|
16
16
|
|
|
17
|
-
Multiple layers of token savings that compound across every session.
|
|
17
|
+
Multiple layers of token savings that compound across every session.
|
|
18
|
+
|
|
19
|
+
**Talk Normal Prompt** — injects a compact response-style prompt adapted from [talk-normal](https://github.com/hexiecs/talk-normal), trimming filler, summary stamps, conditional follow-up menus, and verbose framing. This reduces assistant output tokens and keeps visible reasoning / explanation blocks tighter.
|
|
18
20
|
|
|
19
21
|
**RTK** — integrates [RTK](https://github.com/rtk-ai/rtk) to compress bash output into structured summaries, so the LLM never sees raw noise. **Just install the CLI, zero config**.
|
|
20
22
|
|
|
@@ -30,13 +32,11 @@ Multiple layers of token savings that compound across every session.
|
|
|
30
32
|
**Cache‑friendly Design** — stable system prompt prefix:
|
|
31
33
|
|
|
32
34
|
- tool definitions, guidelines, and skills are sorted alphabetically so the system prompt is identical across sessions
|
|
33
|
-
- volatile elements like `Current date: …` are stripped before prompt assembly
|
|
34
35
|
- MCP tool schemas are persisted to a local cache, so the tool list stays stable regardless of network conditions or server availability
|
|
35
36
|
|
|
36
37
|
**Pi Native Prompt Slimming**
|
|
37
38
|
|
|
38
39
|
- move the default Pi documentation block out of the system prompt and into a builtin `pi-docs` skill, so the docs reference loads on demand instead of sitting in every turn's prompt
|
|
39
|
-
- unregister the native `write` tool, since `bash` can handle it
|
|
40
40
|
|
|
41
41
|
**Large Result Externalization**
|
|
42
42
|
|
package/commands/usage.ts
CHANGED
|
@@ -401,7 +401,11 @@ export function formatCost(c: number): string {
|
|
|
401
401
|
|
|
402
402
|
export function formatHitRate(hitRate: number, turns: number): string {
|
|
403
403
|
if (turns === 0) return "—";
|
|
404
|
-
|
|
404
|
+
// Truncate (not round) beyond 1 decimal place: 99.97 → "99.9%", not
|
|
405
|
+
// "100.0%". A true 100.0 (every input-side token served from cache) is
|
|
406
|
+
// the only way to reach "100.0%".
|
|
407
|
+
const truncated = Math.floor(hitRate * 10) / 10;
|
|
408
|
+
return `${truncated.toFixed(1)}%`;
|
|
405
409
|
}
|
|
406
410
|
|
|
407
411
|
export type ColumnId = "input" | "output" | "cacheRead" | "cacheWrite" | "hitRate" | "cost";
|
package/hooks/compaction.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* compaction — custom compaction model
|
|
2
|
+
* compaction — custom compaction model.
|
|
3
3
|
*
|
|
4
4
|
* On `session_before_compact`, runs pi-coding-agent's `compact()` against
|
|
5
5
|
* the model configured in settings (rather than the agent's current model)
|
|
@@ -7,82 +7,25 @@
|
|
|
7
7
|
* default. If the configured model is missing, auth fails, or the call
|
|
8
8
|
* throws, we fall through (return undefined) and pi runs its own compaction.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Auto-retry/resume is handled by pi natively; `reason` and `willRetry`
|
|
11
|
+
* on compaction events describe manual, threshold, and overflow flows.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
14
|
import { compact } from "@earendil-works/pi-coding-agent";
|
|
16
15
|
import type { Model } from "@earendil-works/pi-ai";
|
|
17
|
-
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
18
|
-
import * as fs from "node:fs";
|
|
19
|
-
import * as os from "node:os";
|
|
20
|
-
import { resolve } from "node:path";
|
|
21
16
|
import { getCompactModelKey } from "../settings.js";
|
|
22
17
|
import { parseModelKey } from "../settings.js";
|
|
23
18
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
enabled: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface AutoCompactionCandidate {
|
|
30
|
-
messages: any[];
|
|
31
|
-
usage: { tokens: number | null; contextWindow: number } | undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const DEFAULT_PI_COMPACTION_SETTINGS: PiCompactionSettings = {
|
|
35
|
-
enabled: true,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function readJsonObject(filePath: string): any | undefined {
|
|
39
|
-
try {
|
|
40
|
-
if (!fs.existsSync(filePath)) return undefined;
|
|
41
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
42
|
-
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
43
|
-
} catch {
|
|
44
|
-
return undefined;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
20
|
+
type CompactionReason = "manual" | "threshold" | "overflow";
|
|
47
21
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const merged = {
|
|
52
|
-
...DEFAULT_PI_COMPACTION_SETTINGS,
|
|
53
|
-
...(globalSettings?.compaction ?? {}),
|
|
54
|
-
...(projectSettings?.compaction ?? {}),
|
|
55
|
-
};
|
|
56
|
-
return {
|
|
57
|
-
enabled: merged.enabled !== false,
|
|
58
|
-
};
|
|
22
|
+
interface CompactionEventMetadata {
|
|
23
|
+
reason: CompactionReason;
|
|
24
|
+
willRetry: boolean;
|
|
59
25
|
}
|
|
60
26
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
if (messages[i]?.role === "assistant") return messages[i];
|
|
64
|
-
}
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Mirror pi's _runAutoCompaction(reason, willRetry) decision:
|
|
69
|
-
* - overflow → willRetry=true → auto-resume the agent
|
|
70
|
-
* - threshold → willRetry=false → no auto-resume (user continues manually)
|
|
71
|
-
* - manual /compact → no resume
|
|
72
|
-
* Detected from the post-agent-end candidate: overflow shows up as a
|
|
73
|
-
* context-overflow error on the last assistant message; threshold is a
|
|
74
|
-
* pre-emptive compaction and we intentionally skip auto-resume for it. */
|
|
75
|
-
function shouldAutoResumeCompaction(
|
|
76
|
-
candidate: AutoCompactionCandidate | null,
|
|
77
|
-
settings: PiCompactionSettings,
|
|
78
|
-
customInstructions?: string,
|
|
79
|
-
): boolean {
|
|
80
|
-
if (customInstructions !== undefined) return false;
|
|
81
|
-
if (!candidate || !settings.enabled) return false;
|
|
82
|
-
const lastAssistant = getLastAssistantMessage(candidate.messages);
|
|
83
|
-
if (!lastAssistant) return false;
|
|
84
|
-
const contextWindow = candidate.usage?.contextWindow ?? 0;
|
|
85
|
-
return contextWindow > 0 && isContextOverflow(lastAssistant, contextWindow);
|
|
27
|
+
function formatCompactionMode(event: CompactionEventMetadata): string {
|
|
28
|
+
return event.willRetry ? `${event.reason}, retrying` : event.reason;
|
|
86
29
|
}
|
|
87
30
|
|
|
88
31
|
function getConfiguredCompactModel(registry: any): Model<any> | null {
|
|
@@ -93,71 +36,11 @@ function getConfiguredCompactModel(registry: any): Model<any> | null {
|
|
|
93
36
|
return registry.find(parsed.provider, parsed.modelId) ?? null;
|
|
94
37
|
}
|
|
95
38
|
|
|
96
|
-
/** Per-session state. Indexed by sessionId (from ctx.sessionManager) so
|
|
97
|
-
* two concurrent pi sessions don't trample each other's flags. */
|
|
98
|
-
interface SessionState {
|
|
99
|
-
postAgentEndCandidate: AutoCompactionCandidate | null;
|
|
100
|
-
currentCompactionIsAuto: boolean;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const sessionStates = new Map<string, SessionState>();
|
|
104
|
-
|
|
105
|
-
function getSessionState(sessionId: string): SessionState {
|
|
106
|
-
let s = sessionStates.get(sessionId);
|
|
107
|
-
if (!s) {
|
|
108
|
-
s = { postAgentEndCandidate: null, currentCompactionIsAuto: false };
|
|
109
|
-
sessionStates.set(sessionId, s);
|
|
110
|
-
}
|
|
111
|
-
return s;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
39
|
export const compactionModule: Module = {
|
|
115
40
|
name: "compaction",
|
|
116
41
|
hooks: {
|
|
117
|
-
session_shutdown: [
|
|
118
|
-
(_event, ctx) => {
|
|
119
|
-
// Clean up state when the session ends so the Map doesn't grow.
|
|
120
|
-
sessionStates.delete(ctx.sessionManager.getSessionId());
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
input: [
|
|
124
|
-
(_event, ctx) => {
|
|
125
|
-
getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
before_agent_start: [
|
|
129
|
-
(_event, ctx) => {
|
|
130
|
-
getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
|
|
131
|
-
},
|
|
132
|
-
],
|
|
133
|
-
agent_start: [
|
|
134
|
-
(_event, ctx) => {
|
|
135
|
-
getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = null;
|
|
136
|
-
},
|
|
137
|
-
],
|
|
138
|
-
agent_end: [
|
|
139
|
-
(event, ctx) => {
|
|
140
|
-
getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = {
|
|
141
|
-
messages: event.messages,
|
|
142
|
-
usage: ctx.getContextUsage(),
|
|
143
|
-
};
|
|
144
|
-
},
|
|
145
|
-
],
|
|
146
42
|
session_before_compact: [
|
|
147
43
|
async (event, ctx, pi) => {
|
|
148
|
-
const sessionState = getSessionState(ctx.sessionManager.getSessionId());
|
|
149
|
-
const compactionSettings = loadPiCompactionSettings(ctx.cwd);
|
|
150
|
-
// Pi only auto-compacts after agent_end (see _checkCompaction in
|
|
151
|
-
// agent-session.js), so we detect "auto" via the post-agent-end
|
|
152
|
-
// overflow heuristic. Manual /compact carries customInstructions
|
|
153
|
-
// and skips auto-resume.
|
|
154
|
-
const isAutoResume = shouldAutoResumeCompaction(
|
|
155
|
-
sessionState.postAgentEndCandidate,
|
|
156
|
-
compactionSettings,
|
|
157
|
-
event.customInstructions,
|
|
158
|
-
);
|
|
159
|
-
sessionState.postAgentEndCandidate = null;
|
|
160
|
-
|
|
161
44
|
const model = getConfiguredCompactModel(ctx.modelRegistry);
|
|
162
45
|
if (!model) return; // No custom compact model configured → let pi run its default compaction.
|
|
163
46
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
@@ -168,7 +51,7 @@ export const compactionModule: Module = {
|
|
|
168
51
|
const { preparation, customInstructions, signal } = event;
|
|
169
52
|
if (ctx.hasUI) {
|
|
170
53
|
ctx.ui.notify(
|
|
171
|
-
|
|
54
|
+
`📦 Compacting with ${model.id} (${formatCompactionMode(event)}, ${preparation.tokensBefore.toLocaleString()} tokens)...`,
|
|
172
55
|
"info",
|
|
173
56
|
);
|
|
174
57
|
}
|
|
@@ -189,11 +72,6 @@ export const compactionModule: Module = {
|
|
|
189
72
|
signal,
|
|
190
73
|
thinkingLevel,
|
|
191
74
|
);
|
|
192
|
-
// Only mark as auto-resumed if OUR hook did the compaction. If we
|
|
193
|
-
// had fallen through to pi's default path above, pi's own
|
|
194
|
-
// _runAutoCompaction would call agent.continue() on overflow;
|
|
195
|
-
// marking here would cause a duplicate resume message on top.
|
|
196
|
-
sessionState.currentCompactionIsAuto = isAutoResume;
|
|
197
75
|
return { compaction: result };
|
|
198
76
|
} catch (err) {
|
|
199
77
|
if (signal.aborted) return;
|
|
@@ -209,19 +87,6 @@ export const compactionModule: Module = {
|
|
|
209
87
|
}
|
|
210
88
|
},
|
|
211
89
|
],
|
|
212
|
-
session_compact: [
|
|
213
|
-
(_event, ctx, pi) => {
|
|
214
|
-
const sessionState = getSessionState(ctx.sessionManager.getSessionId());
|
|
215
|
-
const shouldResume = sessionState.currentCompactionIsAuto;
|
|
216
|
-
sessionState.currentCompactionIsAuto = false;
|
|
217
|
-
if (!shouldResume) return;
|
|
218
|
-
pi.sendMessage({
|
|
219
|
-
customType: "auto_compact_resume",
|
|
220
|
-
content: "The context was just auto-compacted. Continue the current task based on the summary above. Do not repeat completed work. If unsure about progress, briefly summarize current state then continue.",
|
|
221
|
-
display: false,
|
|
222
|
-
}, { triggerTurn: true });
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
90
|
},
|
|
226
91
|
};
|
|
227
92
|
|
|
@@ -230,5 +95,5 @@ export function setupCompaction(sk: Skeleton): void {
|
|
|
230
95
|
}
|
|
231
96
|
|
|
232
97
|
export const __modelIntegrationTest = {
|
|
233
|
-
|
|
98
|
+
formatCompactionMode,
|
|
234
99
|
};
|
package/hooks/image-vision.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { fileTypeFromFile } from "file-type";
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
7
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
8
10
|
import { extname, resolve } from "node:path";
|
|
9
11
|
import OpenAI from "openai";
|
|
10
12
|
import type { Model } from "@earendil-works/pi-ai";
|
|
@@ -13,6 +15,12 @@ import type { Module, Skeleton } from "./skeleton.js";
|
|
|
13
15
|
|
|
14
16
|
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
15
17
|
|
|
18
|
+
function expandHome(filePath: string): string {
|
|
19
|
+
if (filePath.startsWith("~/")) return resolve(os.homedir(), filePath.slice(2));
|
|
20
|
+
if (filePath === "~") return os.homedir();
|
|
21
|
+
return filePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
async function detectImageMimeType(filePath: string): Promise<string | null> {
|
|
17
25
|
try {
|
|
18
26
|
const type = await fileTypeFromFile(filePath);
|
|
@@ -32,6 +40,9 @@ export async function analyzeImage(
|
|
|
32
40
|
if (model.api === "anthropic-messages") {
|
|
33
41
|
return analyzeAnthropic(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
34
42
|
}
|
|
43
|
+
if (model.api === "openai-codex-responses") {
|
|
44
|
+
return analyzeCodex(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
45
|
+
}
|
|
35
46
|
return analyzeOpenAI(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -51,6 +62,99 @@ async function analyzeOpenAI(
|
|
|
51
62
|
return resp.choices[0]?.message?.content ?? "No analysis returned.";
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
function codexResponsesUrl(baseUrl?: string): string {
|
|
66
|
+
const base = (baseUrl || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
|
|
67
|
+
return base.endsWith("/codex/responses") ? base
|
|
68
|
+
: base.endsWith("/codex") ? `${base}/responses`
|
|
69
|
+
: `${base}/codex/responses`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractCodexAccountId(apiKey: string): string {
|
|
73
|
+
try {
|
|
74
|
+
const parts = apiKey.split(".");
|
|
75
|
+
if (parts.length !== 3) return "";
|
|
76
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
77
|
+
return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id ?? "";
|
|
78
|
+
} catch {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appendCodexSseDelta(outputText: string, line: string): string {
|
|
84
|
+
if (!line.startsWith("data: ")) return outputText;
|
|
85
|
+
const data = line.slice(6).trim();
|
|
86
|
+
if (data === "[DONE]") return outputText;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(data);
|
|
89
|
+
if (parsed.type === "response.output_text.delta") {
|
|
90
|
+
return outputText + (parsed.delta ?? "");
|
|
91
|
+
}
|
|
92
|
+
} catch { /* skip malformed */ }
|
|
93
|
+
return outputText;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function analyzeCodex(
|
|
97
|
+
model: Model<any>, imageBase64: string, mediaType: string,
|
|
98
|
+
apiKey: string, extraHeaders: Record<string, string>,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
// Codex uses /codex/responses, not the standard /responses path.
|
|
101
|
+
const url = codexResponsesUrl(model.baseUrl);
|
|
102
|
+
|
|
103
|
+
// Codex auth: extract accountId from JWT, set special headers.
|
|
104
|
+
const accountId = extractCodexAccountId(apiKey);
|
|
105
|
+
|
|
106
|
+
const headers: Record<string, string> = {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
109
|
+
"Originator": "pi",
|
|
110
|
+
"OpenAI-Beta": "responses=experimental",
|
|
111
|
+
"accept": "text/event-stream",
|
|
112
|
+
...extraHeaders,
|
|
113
|
+
};
|
|
114
|
+
if (accountId) headers["chatgpt-account-id"] = accountId;
|
|
115
|
+
|
|
116
|
+
const resp = await fetch(url, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers,
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
model: model.id,
|
|
121
|
+
store: false,
|
|
122
|
+
stream: true,
|
|
123
|
+
input: [{
|
|
124
|
+
role: "user",
|
|
125
|
+
content: [
|
|
126
|
+
{ type: "input_text", text: DEFAULT_PROMPT },
|
|
127
|
+
{ type: "input_image", image_url: `data:${mediaType};base64,${imageBase64}`, detail: "auto" },
|
|
128
|
+
],
|
|
129
|
+
}],
|
|
130
|
+
text: { format: { type: "text" } },
|
|
131
|
+
}),
|
|
132
|
+
signal: AbortSignal.timeout(60_000),
|
|
133
|
+
});
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
const text = await resp.text().catch(() => "");
|
|
136
|
+
throw new Error(`Codex API error ${resp.status}: ${text.slice(0, 300)}`);
|
|
137
|
+
}
|
|
138
|
+
// Codex only supports SSE streaming; read the event stream.
|
|
139
|
+
const reader = resp.body?.getReader();
|
|
140
|
+
if (!reader) throw new Error("No response body");
|
|
141
|
+
const decoder = new TextDecoder();
|
|
142
|
+
let buffer = "";
|
|
143
|
+
let outputText = "";
|
|
144
|
+
while (true) {
|
|
145
|
+
const { done, value } = await reader.read();
|
|
146
|
+
if (done) break;
|
|
147
|
+
buffer += decoder.decode(value, { stream: true });
|
|
148
|
+
const lines = buffer.split(/\r?\n/);
|
|
149
|
+
buffer = lines.pop() ?? "";
|
|
150
|
+
for (const line of lines) outputText = appendCodexSseDelta(outputText, line);
|
|
151
|
+
}
|
|
152
|
+
buffer += decoder.decode();
|
|
153
|
+
for (const line of buffer.split(/\r?\n/)) outputText = appendCodexSseDelta(outputText, line);
|
|
154
|
+
return outputText || "No analysis returned.";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
54
158
|
async function analyzeAnthropic(
|
|
55
159
|
model: Model<any>, imageBase64: string, mediaType: string,
|
|
56
160
|
apiKey: string, extraHeaders: Record<string, string>,
|
|
@@ -83,10 +187,20 @@ export const imageVisionModule: Module = {
|
|
|
83
187
|
if (event.toolName !== "read") return;
|
|
84
188
|
const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
|
|
85
189
|
if (!filePath) return;
|
|
86
|
-
const mimeType = await detectImageMimeType(resolve(ctx.cwd, filePath));
|
|
190
|
+
const mimeType = await detectImageMimeType(resolve(ctx.cwd, expandHome(filePath)));
|
|
87
191
|
if (!mimeType) return;
|
|
88
|
-
|
|
192
|
+
const imageKey = getImageModelKey();
|
|
193
|
+
if (!imageKey) return;
|
|
89
194
|
pendingImageFallbacks.add(event.toolCallId);
|
|
195
|
+
if (ctx.hasUI) {
|
|
196
|
+
const p = parseModelKey(imageKey);
|
|
197
|
+
if (p) {
|
|
198
|
+
ctx.ui.notify(
|
|
199
|
+
`🖼️ Analyzing image with ${p.provider}/${p.modelId}...`,
|
|
200
|
+
"info",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
90
204
|
},
|
|
91
205
|
],
|
|
92
206
|
tool_result: [
|
|
@@ -101,7 +215,7 @@ export const imageVisionModule: Module = {
|
|
|
101
215
|
const imageModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
102
216
|
if (!imageModel) return;
|
|
103
217
|
try {
|
|
104
|
-
const absPath = resolve(ctx.cwd, filePath);
|
|
218
|
+
const absPath = resolve(ctx.cwd, expandHome(filePath));
|
|
105
219
|
const imageData = fs.readFileSync(absPath);
|
|
106
220
|
const imageBase64 = imageData.toString("base64");
|
|
107
221
|
const mimeType = (await detectImageMimeType(absPath)) ?? "image/png";
|
|
@@ -113,13 +227,19 @@ export const imageVisionModule: Module = {
|
|
|
113
227
|
);
|
|
114
228
|
return {
|
|
115
229
|
...event,
|
|
116
|
-
content: [{ type: "text", text:
|
|
230
|
+
content: [{ type: "text", text: analysis }],
|
|
117
231
|
details: { imageModel: imageKey, originalPath: filePath },
|
|
118
232
|
};
|
|
119
233
|
} catch (error) {
|
|
234
|
+
if (ctx.hasUI) {
|
|
235
|
+
ctx.ui.notify(
|
|
236
|
+
`Image analysis failed: ${error instanceof Error ? error.message : error}`,
|
|
237
|
+
"warning",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
120
240
|
return {
|
|
121
241
|
...event,
|
|
122
|
-
content: [{ type: "text", text: `Image analysis failed
|
|
242
|
+
content: [{ type: "text", text: `Image analysis failed` }],
|
|
123
243
|
};
|
|
124
244
|
}
|
|
125
245
|
},
|
|
@@ -130,3 +250,5 @@ export const imageVisionModule: Module = {
|
|
|
130
250
|
export function setupImageVision(sk: Skeleton): void {
|
|
131
251
|
sk.register(imageVisionModule);
|
|
132
252
|
}
|
|
253
|
+
|
|
254
|
+
export const __imageVisionTest = { expandHome, analyzeCodex, appendCodexSseDelta, codexResponsesUrl, extractCodexAccountId };
|
package/hooks/mcp.ts
CHANGED
|
@@ -360,9 +360,17 @@ export const mcpModule: Module = {
|
|
|
360
360
|
|
|
361
361
|
// connectAll runs in the background so the watchdog tests
|
|
362
362
|
// that mock hung connections don't block session_start.
|
|
363
|
+
//
|
|
364
|
+
// Capture hasUI / notify synchronously: connectAll may resolve
|
|
365
|
+
// after the session is replaced or reloaded, at which point `ctx`
|
|
366
|
+
// is stale and its `hasUI` getter throws via assertActive.
|
|
367
|
+
// Using the captured plain values avoids touching the stale ctx
|
|
368
|
+
// in the .then callback.
|
|
369
|
+
const hasUI = ctx.hasUI;
|
|
370
|
+
const notify = ctx.ui?.notify?.bind(ctx.ui);
|
|
363
371
|
void connectAll(toConnect, ctx.modelRegistry, ctx).then(({ schemaChanges }) => {
|
|
364
|
-
if (schemaChanges.length > 0 &&
|
|
365
|
-
|
|
372
|
+
if (schemaChanges.length > 0 && hasUI && notify) {
|
|
373
|
+
notify(`mcp schema changed! please '/reload'`, "warning");
|
|
366
374
|
}
|
|
367
375
|
});
|
|
368
376
|
},
|
package/hooks/pi-tool-filter.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-tool-filter — unregister pi native tools that are replaced by our extensions.
|
|
3
3
|
*
|
|
4
|
-
* edit
|
|
5
|
-
* write → replaced by patch
|
|
6
|
-
* grep → replaced by bash
|
|
7
|
-
* find → replaced by bash
|
|
8
|
-
* ls → replaced by bash
|
|
4
|
+
* edit → replaced by patch
|
|
9
5
|
*/
|
|
10
6
|
|
|
11
7
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
12
8
|
|
|
13
|
-
const TOOLS_TO_DROP = new Set(["edit"
|
|
9
|
+
const TOOLS_TO_DROP = new Set(["edit"]);
|
|
14
10
|
|
|
15
11
|
export const piToolFilterModule: Module = {
|
|
16
12
|
name: "pi-tool-filter",
|
package/hooks/skeleton.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type HookEvent =
|
|
|
25
25
|
| "agent_end"
|
|
26
26
|
| "input"
|
|
27
27
|
| "tool_call"
|
|
28
|
-
| "tool_result"
|
|
28
|
+
| "tool_result"
|
|
29
|
+
| "message_end";
|
|
29
30
|
|
|
30
31
|
// ─── Handler modes ─────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -67,6 +68,7 @@ export interface Module {
|
|
|
67
68
|
input?: ParallelHandler<"input">[];
|
|
68
69
|
tool_call?: ComposeHandler<"tool_call">[];
|
|
69
70
|
tool_result?: ComposeHandler<"tool_result">[];
|
|
71
|
+
message_end?: ComposeHandler<"message_end">[];
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -99,6 +101,7 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
|
|
|
99
101
|
"before_agent_start",
|
|
100
102
|
"tool_call",
|
|
101
103
|
"tool_result",
|
|
104
|
+
"message_end",
|
|
102
105
|
]);
|
|
103
106
|
|
|
104
107
|
/** Events whose handler return value is propagated to the extension runner
|
package/hooks/smart-at.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* frecency + git status, and returns ranked results. We create one
|
|
7
7
|
* FileFinder per session and query it directly for every @ prefix.
|
|
8
8
|
*
|
|
9
|
+
* Result handling trusts FFF's native score: re-sort by score
|
|
10
|
+
* descending (shorter path breaks ties), no substring or git-ignore
|
|
11
|
+
* post-filtering.
|
|
12
|
+
*
|
|
9
13
|
* KNOWN FFF LIMITATION (v0.9.4):
|
|
10
14
|
* FFF only returns directories that directly contain files. Intermediate
|
|
11
15
|
* folders that only hold subdirectories (e.g. product/module/apmanage/ where
|
|
@@ -24,7 +28,6 @@ import type { Module, Skeleton } from "./skeleton.js";
|
|
|
24
28
|
const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
|
|
25
29
|
|
|
26
30
|
const AUTOCOMPLETE_LIMIT = 20;
|
|
27
|
-
const FFF_SUPERSET = 100;
|
|
28
31
|
const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
|
|
29
32
|
|
|
30
33
|
interface AutocompleteItem {
|
|
@@ -45,10 +48,6 @@ function atPrefix(text: string): string | null {
|
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
function isGitIgnored(item: MixedItem): boolean {
|
|
49
|
-
return item.type === "file" && item.item.gitStatus === "ignored";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
51
|
function toAutocompleteItem(item: MixedItem): AutocompleteItem {
|
|
53
52
|
const path = item.item.relativePath;
|
|
54
53
|
const label = item.type === "file"
|
|
@@ -65,23 +64,20 @@ interface BuiltResult {
|
|
|
65
64
|
items: AutocompleteItem[];
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
/** Take FFF's
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
function buildResult(items: MixedItem[],
|
|
73
|
-
const
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
(it) =>
|
|
77
|
-
!lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
|
|
78
|
-
)
|
|
67
|
+
/** Take FFF's mixed-search results and re-sort by native score descending
|
|
68
|
+
* (score ties broken by shorter path first), then map to autocomplete
|
|
69
|
+
* items. Trust FFF's fuzzy+frecency score; no substring or git-ignore
|
|
70
|
+
* post-filtering. */
|
|
71
|
+
function buildResult(items: MixedItem[], scores: { total: number }[]): BuiltResult | null {
|
|
72
|
+
const ranked = items
|
|
73
|
+
.map((item, index) => ({ item, score: scores[index]?.total ?? 0 }))
|
|
74
|
+
.sort((a, b) => b.score - a.score || a.item.item.relativePath.length - b.item.item.relativePath.length)
|
|
79
75
|
.slice(0, AUTOCOMPLETE_LIMIT)
|
|
80
|
-
.map(toAutocompleteItem);
|
|
81
|
-
return
|
|
76
|
+
.map((entry) => toAutocompleteItem(entry.item));
|
|
77
|
+
return ranked.length ? { items: ranked } : null;
|
|
82
78
|
}
|
|
83
79
|
|
|
84
|
-
export const __smartAtTest = { atPrefix };
|
|
80
|
+
export const __smartAtTest = { atPrefix, buildResult };
|
|
85
81
|
|
|
86
82
|
/** Active FileFinder for the current session; freed in session_shutdown
|
|
87
83
|
* to avoid leaking FFF's native handle + LMDB mmap regions. */
|
|
@@ -148,9 +144,8 @@ export const smartAtModule: Module = {
|
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
const query = prefix.slice(1);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
|
|
147
|
+
const r = finder.mixedSearch(query.toLowerCase(), {
|
|
148
|
+
pageSize: AUTOCOMPLETE_LIMIT,
|
|
154
149
|
});
|
|
155
150
|
if (!r.ok) {
|
|
156
151
|
ctx.ui.setWidget("smart-at", undefined);
|
|
@@ -178,7 +173,7 @@ export const smartAtModule: Module = {
|
|
|
178
173
|
return null;
|
|
179
174
|
}
|
|
180
175
|
|
|
181
|
-
const result = buildResult(r.value.items,
|
|
176
|
+
const result = buildResult(r.value.items, r.value.scores);
|
|
182
177
|
if (!result) {
|
|
183
178
|
ctx.ui.setWidget("smart-at", undefined);
|
|
184
179
|
return null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* thinking-label-strip — remove stray <thinking></thinking> tags from
|
|
3
|
+
* assistant thinking blocks.
|
|
4
|
+
*
|
|
5
|
+
* Some models wrap their own reasoning text in literal <thinking></thinking>
|
|
6
|
+
* tags inside the `thinking` field of a ThinkingContent block. The tag
|
|
7
|
+
* itself is noise — pi already marks the block as type "thinking", so the
|
|
8
|
+
* tag is redundant. When it leaks into the context it confuses downstream
|
|
9
|
+
* consumers (compaction, other models that echo it back) and pollutes the
|
|
10
|
+
* session transcript.
|
|
11
|
+
*
|
|
12
|
+
* This hook intercepts `message_end` for assistant messages and strips
|
|
13
|
+
* every <thinking></thinking> pair from each ThinkingContent block's text,
|
|
14
|
+
* preserving the reasoning between the tags. The edit is persistent: the
|
|
15
|
+
* stored message is replaced, so the context sent to subsequent LLM calls
|
|
16
|
+
* no longer contains the literal tag.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Module } from "./skeleton.js";
|
|
20
|
+
|
|
21
|
+
const THINKING_OPEN = "<thinking>";
|
|
22
|
+
const THINKING_CLOSE = "</thinking>";
|
|
23
|
+
|
|
24
|
+
/** Strip a leading <thinking> and/or trailing </thinking> from `text`.
|
|
25
|
+
* Models often emit these tags as delimiters inside the `thinking` field;
|
|
26
|
+
* pi already marks the block type, so the tags are noise that leaks into
|
|
27
|
+
* the context and the TUI. We only trim at the head/tail — tags buried in
|
|
28
|
+
* the middle are part of the reasoning text and left alone, which avoids
|
|
29
|
+
* corrupting the model's argument if it literally discusses the tag. */
|
|
30
|
+
export function stripThinkingLabels(text: string): string {
|
|
31
|
+
if (!text.includes(THINKING_OPEN) && !text.includes(THINKING_CLOSE)) {
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
let out = text;
|
|
35
|
+
// trimStart would loop-strip repeating opens; we want a single leading
|
|
36
|
+
// open tag (optionally with whitespace before it) removed.
|
|
37
|
+
const leadingOpen = out.match(/^\s*<thinking>/);
|
|
38
|
+
if (leadingOpen) {
|
|
39
|
+
out = out.slice(leadingOpen[0].length);
|
|
40
|
+
}
|
|
41
|
+
const trailingClose = out.match(/<\/thinking>\s*$/);
|
|
42
|
+
if (trailingClose) {
|
|
43
|
+
out = out.slice(0, out.length - trailingClose[0].length);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Return a new content array with thinking blocks cleaned, or undefined
|
|
49
|
+
* if nothing changed. */
|
|
50
|
+
function cleanContent(content: any[]): any[] | undefined {
|
|
51
|
+
let changed = false;
|
|
52
|
+
const next = content.map((block) => {
|
|
53
|
+
if (block && block.type === "thinking" && typeof block.thinking === "string") {
|
|
54
|
+
const cleaned = stripThinkingLabels(block.thinking);
|
|
55
|
+
if (cleaned !== block.thinking) {
|
|
56
|
+
changed = true;
|
|
57
|
+
return { ...block, thinking: cleaned };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return block;
|
|
61
|
+
});
|
|
62
|
+
return changed ? next : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const thinkingLabelStripModule: Module = {
|
|
66
|
+
name: "thinking-label-strip",
|
|
67
|
+
hooks: {
|
|
68
|
+
message_end: [
|
|
69
|
+
(event: any) => {
|
|
70
|
+
const message = event?.message;
|
|
71
|
+
if (!message || message.role !== "assistant") return undefined;
|
|
72
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
73
|
+
const cleaned = cleanContent(message.content);
|
|
74
|
+
if (!cleaned) return undefined;
|
|
75
|
+
return { message: { ...message, content: cleaned } };
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
};
|