decorated-pi 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raylan Liu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # decorated-pi
2
+
3
+ `decorated-pi` is a Pi extension bundle that adds safety gates, LSP tools, image/compaction model helpers, smarter `@` file completion, dynamic subdirectory `AGENTS.md` loading, and a few workflow quality-of-life improvements.
4
+
5
+ ## Status
6
+
7
+ Current scope is **functionally complete for local use**.
8
+
9
+ Recent audit highlights:
10
+ - Fixed stale LSP diagnostics caused by returning cached diagnostics after `didChange`
11
+ - Fixed `subdir-agents` path capture on `tool_call`
12
+ - Fixed a `smart-at` multi-token search bug
13
+ - Updated safety checks so shell overwrite attempts on existing files are treated as dangerous
14
+
15
+ ## Features
16
+
17
+ ### 1. Decorated Pi Guidance
18
+ Adds global system-prompt guidance via `before_agent_start.systemPrompt`:
19
+ - Restate understanding before acting
20
+ - Break medium/large tasks into discrete steps
21
+
22
+ ### 2. Safety Layer
23
+ Implemented in `extensions/safety.ts`.
24
+
25
+ - **Dangerous bash guard**
26
+ - Blocks or asks for confirmation on destructive commands such as:
27
+ - `rm`
28
+ - `sudo`
29
+ - `svn commit`
30
+ - `svn revert`
31
+ - `git reset`
32
+ - `git restore`
33
+ - `git clean`
34
+ - `git push`
35
+ - `git revert`
36
+ - **Shell overwrite detection**
37
+ - Detects `bash` commands that would overwrite an **existing regular file**, including:
38
+ - `>` / `>>`
39
+ - `1>` / `1>>`
40
+ - `2>` / `2>>`
41
+ - `&>` / `&>>`
42
+ - `tee`
43
+ - Aggregates **all** dangerous reasons found in one command
44
+ - **Protected paths**
45
+ - Blocks `write` / `edit` to sensitive locations such as `.env`, `.git/`, `.ssh/`, `node_modules/`, `*.pem`, `*.key`, etc.
46
+ - **Write guard**
47
+ - Blocks the `write` tool when it would overwrite a non-empty file
48
+ - Instructs the model to use `edit` instead
49
+ - **Secret redaction**
50
+ - Uses `secretlint` rules to redact secrets from tool output before they are fed back into context
51
+
52
+ ### 3. Smart `@` Completion
53
+ Implemented in `extensions/smart-at.ts`.
54
+
55
+ Replaces Pi's default file autocomplete behavior with a faster project-aware search strategy:
56
+ - Uses `git ls-files` in git repos
57
+ - Falls back to `fd` outside git repos
58
+ - Caches results for 10 seconds
59
+ - Scores primarily on **filename match quality**, not full-path fuzziness
60
+ - Penalizes hidden/cache/build directories
61
+ - Hides hidden paths from empty-query results
62
+ - Keeps Pi's original `applyCompletion` / `shouldTriggerFileCompletion` binding behavior intact
63
+
64
+ ### 4. LSP Tool Suite
65
+ Implemented in `extensions/lsp/`.
66
+
67
+ Registered tools:
68
+ - `lsp_diagnostics`
69
+ - `lsp_find_symbol`
70
+ - `lsp_hover`
71
+ - `lsp_definition`
72
+ - `lsp_references`
73
+ - `lsp_document_symbols`
74
+ - `lsp_rename`
75
+
76
+ Supported languages:
77
+ - c
78
+ - cpp
79
+ - go
80
+ - java
81
+ - lua
82
+ - python
83
+ - ruby
84
+ - rust
85
+ - svelte
86
+ - typescript
87
+
88
+ LSP integration includes:
89
+ - prompt snippets for `Available tools`
90
+ - tool-specific prompt guidelines
91
+ - parameter descriptions for the JSON schema
92
+ - trust checks for project-local LSP binaries
93
+ - an LSP-specific system-prompt section injected only when LSP tools are active
94
+
95
+ ### 5. Image Read Fallback
96
+ Implemented in `extensions/extend-model.ts`.
97
+
98
+ When the model reads an image file and an image model is configured:
99
+ - Detects supported image types via magic bytes
100
+ - Calls a configured vision-capable model
101
+ - Replaces the read result with image analysis text
102
+
103
+ Supported image types:
104
+ - jpeg
105
+ - png
106
+ - gif
107
+ - webp
108
+
109
+ ### 6. Custom Compact Model + Auto Resume
110
+ Also implemented in `extensions/extend-model.ts`.
111
+
112
+ - Supports a configured **compact model** through `session_before_compact`
113
+ - Preserves auto-resume behavior through `session_compact`
114
+ - Appends read/modified file summaries to compaction output
115
+
116
+ ### 7. `/extend-model`
117
+ Implemented in `extensions/slash.ts`.
118
+
119
+ Interactive command for configuring:
120
+ - image model
121
+ - compact model
122
+
123
+ ### 8. `/retry`
124
+ Implemented in `extensions/slash.ts`.
125
+
126
+ Allows continuing after interruption by:
127
+ - aborting the current run if needed
128
+ - sending a hidden continuation trigger
129
+ - injecting a one-turn retry note into the system prompt
130
+
131
+ ### 9. Dynamic Subdirectory `AGENTS.md` / `CLAUDE.md`
132
+ Implemented in `extensions/subdir-agents.ts`.
133
+
134
+ When the agent reads or edits a file:
135
+ - discovers `AGENTS.md` / `CLAUDE.md` in the file's directory and ancestor directories
136
+ - injects newly discovered guidance into tool results
137
+ - persists discovered files into the session so they are restored on resume
138
+
139
+ ### 10. Automatic Session Title
140
+ Implemented in `extensions/session-title.ts`.
141
+
142
+ - Derives the session name from the first user message
143
+ - Avoids overriding a manually assigned session name
144
+
145
+ ## Install
146
+
147
+ ### Local install
148
+
149
+ ```bash
150
+ pi install /path/to/decorated-pi
151
+ ```
152
+
153
+ Then reload Pi:
154
+
155
+ ```bash
156
+ /reload
157
+ ```
158
+
159
+ ### npm publish
160
+ Not published yet.
161
+
162
+ ## Configuration
163
+
164
+ Runtime settings are stored in:
165
+
166
+ ```text
167
+ ~/.pi/agent/extensions/decorated-pi.json
168
+ ```
169
+
170
+ Current keys:
171
+ - `imageModelKey`
172
+ - `compactModelKey`
173
+
174
+ Design rule:
175
+ - **only** `extensions/settings.ts` writes this file
176
+ - all other modules read through exported getters/setters
177
+
178
+ ## Architecture
179
+
180
+ Entry point:
181
+ - `extensions/index.ts`
182
+
183
+ Main modules:
184
+ - `extensions/guidance.ts` — global system prompt guidance
185
+ - `extensions/safety.ts` — command guard, protected paths, write guard, secret redaction
186
+ - `extensions/smart-at.ts` — smart `@` autocomplete
187
+ - `extensions/extend-model.ts` — image fallback, compact model, auto-resume
188
+ - `extensions/slash.ts` — `/extend-model`, `/retry`
189
+ - `extensions/subdir-agents.ts` — dynamic `AGENTS.md` / `CLAUDE.md`
190
+ - `extensions/session-title.ts` — session naming
191
+ - `extensions/lsp/*` — LSP client, server manager, prompt wiring, tools
192
+ - `extensions/settings.ts` — config I/O
193
+
194
+ ## Current Limitations
195
+
196
+ - `write` protection applies to the **LLM's `write` tool**; agent attempts to overwrite files via `bash` are handled separately by the dangerous-command gate
197
+ - shell overwrite detection only escalates when the target resolves to an **existing regular file**
198
+ - compact behavior still relies on Pi's normal compaction flow; this extension customizes the model and post-compact continuation, not the global compaction threshold UI
199
+ - npm distribution is not set up yet
200
+ - there is no dedicated automated test suite yet; current validation is based on Pi runtime checks, LSP diagnostics, and manual smoke tests
201
+
202
+ ## Development Notes
203
+
204
+ Helpful checks during development:
205
+
206
+ ```bash
207
+ pi install /path/to/decorated-pi
208
+ /reload
209
+ ```
210
+
211
+ For focused validation, use:
212
+ - `lsp_diagnostics` on edited source files
213
+ - real `pi "..."` smoke tests for safety behavior
214
+ - manual autocomplete checks for `smart-at`
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Extend Model — 模型 SDK
3
+ *
4
+ * 对外接口:
5
+ * analyzeImage(model, base64, mediaType, apiKey, headers) → Promise<string>
6
+ * getConfiguredImageModel(registry) → Model | null
7
+ *
8
+ * 内部事件:
9
+ * tool_call / tool_result: 图片 read → Vision API 回退
10
+ * session_before_compact: 自定义压缩模型
11
+ * session_compact: 压缩后自动继续
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ generateSummary, convertToLlm, serializeConversation,
17
+ DynamicBorder, keyHint, rawKeyHint, Theme,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ Container, fuzzyFilter, getKeybindings, Input,
21
+ Spacer, Text, type TUI,
22
+ } from "@earendil-works/pi-tui";
23
+ import OpenAI from "openai";
24
+ import { fileTypeFromFile } from "file-type";
25
+ import type { Model } from "@earendil-works/pi-ai";
26
+ import {
27
+ loadConfig, saveConfig, parseModelKey, formatModelKey,
28
+ getImageModelKey, getCompactModelKey,
29
+ setImageModelKey, setCompactModelKey,
30
+ } from "./settings.js";
31
+ import * as fs from "node:fs";
32
+ import { extname, resolve } from "node:path";
33
+
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+ // SDK 接口
36
+ // ═══════════════════════════════════════════════════════════════════════════
37
+
38
+ export function getConfiguredImageModel(registry: any): Model<any> | null {
39
+ const key = getImageModelKey();
40
+ if (!key) return null;
41
+ const parsed = parseModelKey(key);
42
+ if (!parsed) return null;
43
+ return registry.find(parsed.provider, parsed.modelId) ?? null;
44
+ }
45
+
46
+ const DEFAULT_PROMPT =
47
+ "Please describe this image in detail, including any text, diagrams, UI elements, or code visible in it.";
48
+
49
+ export async function analyzeImage(
50
+ model: Model<any>, imageBase64: string, mediaType: string,
51
+ apiKey: string, extraHeaders: Record<string, string>
52
+ ): Promise<string> {
53
+ if (model.api === "anthropic-messages") {
54
+ return analyzeAnthropic(model, imageBase64, mediaType, apiKey, extraHeaders);
55
+ }
56
+ return analyzeOpenAI(model, imageBase64, mediaType, apiKey, extraHeaders);
57
+ }
58
+
59
+ async function analyzeOpenAI(
60
+ model: Model<any>, imageBase64: string, mediaType: string,
61
+ apiKey: string, extraHeaders: Record<string, string>
62
+ ): Promise<string> {
63
+ const client = new OpenAI({ apiKey, baseURL: model.baseUrl, defaultHeaders: extraHeaders });
64
+ const resp = await client.chat.completions.create({
65
+ model: model.id,
66
+ messages: [{ role: "user", content: [
67
+ { type: "text", text: DEFAULT_PROMPT },
68
+ { type: "image_url", image_url: { url: `data:${mediaType};base64,${imageBase64}` } },
69
+ ]}],
70
+ max_completion_tokens: 4096,
71
+ }, { signal: AbortSignal.timeout(60_000) });
72
+ return resp.choices[0]?.message?.content ?? "No analysis returned.";
73
+ }
74
+
75
+ async function analyzeAnthropic(
76
+ model: Model<any>, imageBase64: string, mediaType: string,
77
+ apiKey: string, extraHeaders: Record<string, string>
78
+ ): Promise<string> {
79
+ const ep = `${model.baseUrl.replace(/\/+$/, "")}/messages`;
80
+ const resp = await fetch(ep, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01", ...extraHeaders },
83
+ body: JSON.stringify({
84
+ model: model.id, max_tokens: 4096,
85
+ messages: [{ role: "user", content: [
86
+ { type: "text", text: DEFAULT_PROMPT },
87
+ { type: "image", source: { type: "base64", media_type: mediaType, data: imageBase64 } },
88
+ ]}],
89
+ }),
90
+ signal: AbortSignal.timeout(60_000),
91
+ });
92
+ if (!resp.ok) throw new Error(`Vision API error ${resp.status}: ${(await resp.text()).slice(0, 300)}`);
93
+ const data = (await resp.json()) as any;
94
+ return data.content?.[0]?.text ?? "No analysis returned.";
95
+ }
96
+
97
+ // ═══════════════════════════════════════════════════════════════════════════
98
+ // 图片检测(magic bytes)
99
+ // ═══════════════════════════════════════════════════════════════════════════
100
+
101
+ const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
102
+
103
+ async function detectImageMimeType(filePath: string): Promise<string | null> {
104
+ try {
105
+ const type = await fileTypeFromFile(filePath);
106
+ if (!type || !SUPPORTED_IMAGE_TYPES.has(type.mime)) return null;
107
+ return type.mime;
108
+ } catch { return null; }
109
+ }
110
+
111
+ // ═══════════════════════════════════════════════════════════════════════════
112
+ // 图片 read 回退
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+
115
+ function setupImageReadFallback(pi: ExtensionAPI) {
116
+ const pendingFallbacks = new Set<string>();
117
+
118
+ pi.on("tool_call", async (event, ctx) => {
119
+ if (event.toolName !== "read") return;
120
+ const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
121
+ if (!filePath) return;
122
+
123
+ const mimeType = await detectImageMimeType(resolve(ctx.cwd, filePath));
124
+ if (!mimeType) return;
125
+ if (!getImageModelKey()) return;
126
+
127
+ pendingFallbacks.add(event.toolCallId);
128
+ });
129
+
130
+ pi.on("tool_result", async (event, ctx) => {
131
+ if (!pendingFallbacks.delete(event.toolCallId)) return;
132
+ const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
133
+ if (!filePath) return;
134
+
135
+ const imageKey = getImageModelKey();
136
+ if (!imageKey) return;
137
+ const parsed = parseModelKey(imageKey);
138
+ if (!parsed) return;
139
+
140
+ const imageModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
141
+ if (!imageModel) return;
142
+
143
+ try {
144
+ const absPath = resolve(ctx.cwd, filePath);
145
+ const imageData = fs.readFileSync(absPath);
146
+ const imageBase64 = imageData.toString("base64");
147
+ const mimeType = await detectImageMimeType(absPath) ?? "image/png";
148
+
149
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(imageModel as Model<any>);
150
+ if (!auth.ok) return;
151
+
152
+ const analysis = await analyzeImage(
153
+ imageModel as Model<any>, imageBase64, mimeType,
154
+ auth.apiKey ?? "", auth.headers ?? {}
155
+ );
156
+ return {
157
+ content: [{ type: "text", text: `[Image analysis via ${parsed.provider}/${parsed.modelId}]\n\n${analysis}` }],
158
+ details: { imageModel: imageKey, originalPath: filePath },
159
+ };
160
+ } catch (error) {
161
+ return {
162
+ content: [{ type: "text", text: `Image analysis failed: ${error instanceof Error ? error.message : error}` }],
163
+ };
164
+ }
165
+ });
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // 模型选择器组件
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ const TAB_IMAGE = 0;
173
+ const TAB_COMPACT = 1;
174
+
175
+ export class ModelPickerComponent extends Container {
176
+ private searchInput: Input;
177
+ private tui: TUI;
178
+ private theme: Theme;
179
+ private registry: any;
180
+ private onDone: () => void;
181
+ private activeTab = TAB_IMAGE;
182
+ private imageKey: string | null;
183
+ private compactKey: string | null;
184
+ private allItems: { label: string; desc: string; model: Model<any> | null; modelName?: string }[] = [];
185
+ private filtered: typeof this.allItems = [];
186
+ private selectedIndex = 0;
187
+ private tabTitle = new Text("", 1, 0);
188
+ private subtitleText: Text;
189
+ private listContainer: Container;
190
+
191
+ constructor(tui: TUI, theme: unknown, registry: any, onDone: () => void) {
192
+ super();
193
+ this.tui = tui;
194
+ this.theme = theme as Theme;
195
+ this.registry = registry;
196
+ this.onDone = onDone;
197
+ this.imageKey = getImageModelKey();
198
+ this.compactKey = getCompactModelKey();
199
+
200
+ this.addChild(new DynamicBorder());
201
+ this.addChild(new Spacer(1));
202
+ this.addChild(this.tabTitle);
203
+ this.subtitleText = new Text("", 1, 0);
204
+ this.addChild(this.subtitleText);
205
+ this.addChild(new Spacer(1));
206
+
207
+ this.searchInput = new Input();
208
+ this.searchInput.onSubmit = () => { const s = this.filtered[this.selectedIndex]; if (s) this.selectModel(s.model); };
209
+ this.addChild(this.searchInput);
210
+ this.addChild(new Spacer(1));
211
+
212
+ this.listContainer = new Container();
213
+ this.addChild(this.listContainer);
214
+ this.addChild(new Spacer(1));
215
+
216
+ this.addChild(new Text(
217
+ rawKeyHint("↑↓", "navigate") + " " + keyHint("tui.input.tab", "switch") + " " +
218
+ keyHint("tui.select.confirm", "select") + " " + keyHint("tui.select.cancel", "cancel"), 1, 0));
219
+ this.addChild(new Spacer(1));
220
+ this.addChild(new DynamicBorder());
221
+
222
+ this.loadModels().then(() => { this.switchTab(TAB_IMAGE); this.tui.requestRender(); });
223
+ }
224
+
225
+ private async loadModels() {
226
+ this.registry.refresh();
227
+ const available = this.registry.getAvailable() as Model<any>[];
228
+ this.allItems = [{ label: "clear", desc: "(unset)", model: null }];
229
+ for (const m of available) {
230
+ this.allItems.push({ label: m.id, desc: `[${m.provider}]`, model: m as Model<any>, modelName: m.name });
231
+ }
232
+ }
233
+
234
+ private currentKey() { return this.activeTab === TAB_IMAGE ? this.imageKey : this.compactKey; }
235
+ private currentKind() { return this.activeTab === TAB_IMAGE ? "image" : "compact"; }
236
+
237
+ private switchTab(tab: number) {
238
+ this.activeTab = tab;
239
+ const key = this.currentKey();
240
+ const [clearItem, ...rest] = this.allItems;
241
+ const items = rest.map(it => {
242
+ const isCurrent = it.model && formatModelKey(it.model) === key;
243
+ return { ...it, desc: `${it.desc}${isCurrent ? " ✓" : ""}` };
244
+ });
245
+ items.sort((a, b) => {
246
+ const aCur = a.model && formatModelKey(a.model) === key;
247
+ const bCur = b.model && formatModelKey(b.model) === key;
248
+ if (aCur && !bCur) return -1; if (!aCur && bCur) return 1; return 0;
249
+ });
250
+ this.filtered = [clearItem, ...items];
251
+ this.selectedIndex = 0;
252
+ if (key) { const ix = this.filtered.findIndex(m => m.model && formatModelKey(m.model) === key); if (ix >= 0) this.selectedIndex = ix; }
253
+ this.searchInput.setValue("");
254
+ this.updateHeader();
255
+ this.updateList();
256
+ }
257
+
258
+ private updateHeader() {
259
+ const t = this.theme;
260
+ const im = this.activeTab === TAB_IMAGE ? t.fg("accent", "●") : "○";
261
+ const cm = this.activeTab === TAB_COMPACT ? t.fg("accent", "●") : "○";
262
+ const il = this.activeTab === TAB_IMAGE ? t.bold("Image Model") : t.fg("dim", "Image Model");
263
+ const cl = this.activeTab === TAB_COMPACT ? t.bold("Compact Model") : t.fg("dim", "Compact Model");
264
+ this.tabTitle.setText(`${im} ${il} | ${cm} ${cl}`);
265
+ const key = this.currentKey();
266
+ this.subtitleText.setText(key ? t.fg("warning", `Current ${this.currentKind()} model: ${key}`) : t.fg("warning", `No ${this.currentKind()} model set`));
267
+ }
268
+
269
+ handleInput(keyData: string) {
270
+ const kb = getKeybindings();
271
+ if (kb.matches(keyData, "tui.input.tab")) { this.switchTab(this.activeTab === TAB_IMAGE ? TAB_COMPACT : TAB_IMAGE); this.tui.requestRender(); return; }
272
+ if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1; this.updateList(); return; }
273
+ if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); return; }
274
+ if (kb.matches(keyData, "tui.select.confirm")) { const s = this.filtered[this.selectedIndex]; if (s) this.selectModel(s.model); return; }
275
+ if (kb.matches(keyData, "tui.select.cancel")) { this.onDone(); return; }
276
+ this.searchInput.handleInput(keyData); this.applyFilter();
277
+ }
278
+
279
+ private applyFilter() {
280
+ const raw = this.searchInput.getValue();
281
+ if (!raw) { this.switchTab(this.activeTab); return; }
282
+ const [clearItem, ...rest] = this.filtered;
283
+ this.filtered = [clearItem, ...fuzzyFilter(rest, raw, ({ label, desc }) => `${label} ${desc}`)];
284
+ this.selectedIndex = 0; this.updateList();
285
+ }
286
+
287
+ private selectModel(model: Model<any> | null) {
288
+ const kind = this.currentKind();
289
+ if (model) {
290
+ if (kind === "image") setImageModelKey(formatModelKey(model));
291
+ else setCompactModelKey(formatModelKey(model));
292
+ } else {
293
+ if (kind === "image") setImageModelKey(null);
294
+ else setCompactModelKey(null);
295
+ }
296
+ if (kind === "image") this.imageKey = model ? formatModelKey(model) : null;
297
+ else this.compactKey = model ? formatModelKey(model) : null;
298
+ this.switchTab(this.activeTab); this.tui.requestRender();
299
+ }
300
+
301
+ private updateList() {
302
+ this.listContainer.clear();
303
+ const t = this.theme;
304
+ const mv = 10;
305
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(mv / 2), Math.max(0, this.filtered.length - mv)));
306
+ const end = Math.min(start + mv, this.filtered.length);
307
+ for (let i = start; i < end; i++) {
308
+ const item = this.filtered[i]; if (!item) continue;
309
+ const isClear = item.model === null;
310
+ const isSel = i === this.selectedIndex;
311
+ const line = isClear
312
+ ? (isSel ? t.fg("accent", "→ ") + t.fg("error", "clear") + t.fg("muted", " (unset)") : " " + t.fg("muted", "clear (unset)"))
313
+ : (isSel ? t.fg("accent", "→ ") + t.fg("accent", item.label) + " " + t.fg("muted", item.desc) : " " + item.label + " " + t.fg("muted", item.desc));
314
+ this.listContainer.addChild(new Text(line, 0, 0));
315
+ }
316
+ if (start > 0 || end < this.filtered.length) this.listContainer.addChild(new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 0, 0));
317
+ const sel = this.filtered[this.selectedIndex];
318
+ if (sel?.modelName) { this.listContainer.addChild(new Spacer(1)); this.listContainer.addChild(new Text(t.fg("muted", ` Name: ${sel.modelName}`), 0, 0)); }
319
+ }
320
+ }
321
+
322
+ // ═══════════════════════════════════════════════════════════════════════════
323
+ // 压缩辅助
324
+ // ═══════════════════════════════════════════════════════════════════════════
325
+
326
+ const TURN_PREFIX_PROMPT = `Summarize this turn prefix to provide context for the retained suffix.
327
+ Be concise. Focus on what's needed to understand the kept suffix.`;
328
+
329
+ async function generateTurnPrefixSummary(
330
+ messages: Parameters<typeof generateSummary>[0],
331
+ model: Parameters<typeof generateSummary>[1], reserveTokens: number,
332
+ apiKey: string, headers: Record<string, string> | undefined, signal: AbortSignal,
333
+ ): Promise<string> {
334
+ const { complete } = await import("@earendil-works/pi-ai");
335
+ const ct = serializeConversation(convertToLlm(messages));
336
+ const resp = await complete(model, {
337
+ systemPrompt: "You are a context summarization assistant. Produce a structured summary only.",
338
+ messages: [{ role: "user" as const, content: [{ type: "text" as const, text: `<conversation>\n${ct}\n</conversation>\n\n${TURN_PREFIX_PROMPT}` }], timestamp: Date.now() }],
339
+ }, { maxTokens: Math.floor(0.5 * reserveTokens), signal, apiKey, headers });
340
+ if (resp.stopReason === "error") throw new Error(resp.errorMessage ?? "Turn prefix summarization failed");
341
+ return resp.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join("\n");
342
+ }
343
+
344
+ function getConfiguredCompactModel(registry: any): Model<any> | null {
345
+ const key = getCompactModelKey();
346
+ if (!key) return null;
347
+ const parsed = parseModelKey(key);
348
+ if (!parsed) return null;
349
+ return registry.find(parsed.provider, parsed.modelId) ?? null;
350
+ }
351
+
352
+ // ═══════════════════════════════════════════════════════════════════════════
353
+ // 主入口(注册所有事件)
354
+ // ═══════════════════════════════════════════════════════════════════════════
355
+
356
+ export function setupExtendModel(pi: ExtensionAPI) {
357
+ setupImageReadFallback(pi);
358
+
359
+ // 自定义压缩模型
360
+ pi.on("session_before_compact", async (event, ctx) => {
361
+ const model = getConfiguredCompactModel(ctx.modelRegistry);
362
+ if (!model) return; // 没配 → Pi 默认
363
+
364
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
365
+ if (!auth.ok) { ctx.ui.notify(`Compact model auth failed: ${auth.error}`, "warning"); return; }
366
+
367
+ const { preparation, customInstructions, signal } = event;
368
+ const { messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore,
369
+ firstKeptEntryId, previousSummary, fileOps, settings } = preparation;
370
+
371
+ ctx.ui.notify(`🗜️ Compacting with ${model.id} (${tokensBefore.toLocaleString()} tokens)...`, "info");
372
+
373
+ try {
374
+ let summary: string;
375
+ if (isSplitTurn && turnPrefixMessages.length > 0) {
376
+ const [hs, ps] = await Promise.all([
377
+ messagesToSummarize.length > 0
378
+ ? generateSummary(messagesToSummarize, model, settings.reserveTokens,
379
+ auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary)
380
+ : Promise.resolve("No prior history."),
381
+ generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens,
382
+ auth.apiKey ?? "", auth.headers, signal),
383
+ ]);
384
+ summary = `${hs}\n\n---\n\n**Turn Context (split turn):**\n\n${ps}`;
385
+ } else {
386
+ summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens,
387
+ auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary);
388
+ }
389
+
390
+ const readFiles = [...fileOps.read].filter(f => !fileOps.edited.has(f) && !fileOps.written.has(f)).sort();
391
+ const modifiedFiles = [...new Set([...fileOps.edited, ...fileOps.written])].sort();
392
+ if (readFiles.length > 0) summary += `\n\n<read-files>\n${readFiles.join("\n")}\n</read-files>`;
393
+ if (modifiedFiles.length > 0) summary += `\n\n<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`;
394
+
395
+ return { compaction: { summary, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles } } };
396
+ } catch (err) {
397
+ if (signal.aborted) return;
398
+ ctx.ui.notify(`Compact failed: ${err instanceof Error ? err.message : err}`, "error");
399
+ }
400
+ });
401
+
402
+ // 压缩后自动继续
403
+ pi.on("session_compact", () => {
404
+ pi.sendMessage({
405
+ customType: "auto_compact_resume",
406
+ 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.",
407
+ display: false,
408
+ }, { triggerTurn: true });
409
+ });
410
+ }
@@ -0,0 +1,21 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const DECORATED_PI_GUIDANCE_MARKER = "## Decorated Pi Guidance";
4
+
5
+ export function setupGuidance(pi: ExtensionAPI) {
6
+ pi.on("before_agent_start", async (event) => {
7
+ if (event.systemPrompt.includes(DECORATED_PI_GUIDANCE_MARKER)) return;
8
+
9
+ const guidance = [
10
+ DECORATED_PI_GUIDANCE_MARKER,
11
+ "",
12
+ "Before taking any action on a user's prompt, briefly restate your understanding of what the user wants. If ambiguous, ask clarifying questions. Only proceed after intent is clear.",
13
+ "",
14
+ "For medium-to-large tasks (more than 3 steps or touching multiple files/systems), break the task into discrete steps. For small tasks (1-2 steps), do it directly.",
15
+ ].join("\n");
16
+
17
+ return {
18
+ systemPrompt: `${event.systemPrompt}\n\n${guidance}`,
19
+ };
20
+ });
21
+ }