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 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
- return `${hitRate.toFixed(1)}%`;
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";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * compaction — custom compaction model + auto-resume.
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
- * On `session_compact`, if the compaction was auto-triggered, sends a
11
- * "continue" message to resume the agent loop.
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
- interface PiCompactionSettings {
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
- function loadPiCompactionSettings(cwd: string): PiCompactionSettings {
49
- const globalSettings = readJsonObject(resolve(os.homedir(), ".pi", "agent", "settings.json"));
50
- const projectSettings = readJsonObject(resolve(cwd, ".pi", "settings.json"));
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 getLastAssistantMessage(messages: any[]): any | undefined {
62
- for (let i = messages.length - 1; i >= 0; i--) {
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
- `🗜️ Compacting with ${model.id} (${preparation.tokensBefore.toLocaleString()} tokens)...`,
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
- shouldAutoResumeCompaction,
98
+ formatCompactionMode,
234
99
  };
@@ -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
- if (!getImageModelKey()) return;
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: `[Image analysis via ${parsed.provider}/${parsed.modelId}]\n\n${analysis}` }],
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: ${error instanceof Error ? error.message : error}` }],
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 && ctx.hasUI) {
365
- ctx.ui.notify(`mcp schema changed! please '/reload'`, "warning");
372
+ if (schemaChanges.length > 0 && hasUI && notify) {
373
+ notify(`mcp schema changed! please '/reload'`, "warning");
366
374
  }
367
375
  });
368
376
  },
@@ -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 → replaced by patch
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", "write", "grep", "find", "ls"]);
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 ranked results, drop git-ignored files, and for non-empty
69
- * queries keep only paths that contain the query as a substring. This
70
- * stops FFF's loose fuzzy matching from ranking every file in a directory
71
- * that happens to share a few letters (e.g. "mm/" for query "mmc"). */
72
- function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
73
- const filtered = items
74
- .filter((it) => !isGitIgnored(it))
75
- .filter(
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 filtered.length ? { items: filtered } : null;
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 lowerQuery = query.toLowerCase();
152
- const r = finder.mixedSearch(lowerQuery, {
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, lowerQuery);
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
+ };