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 +21 -0
- package/README.md +218 -0
- package/extensions/extend-model.ts +410 -0
- package/extensions/guidance.ts +21 -0
- package/extensions/index.ts +24 -0
- package/extensions/lsp/client.ts +525 -0
- package/extensions/lsp/env.ts +12 -0
- package/extensions/lsp/format.ts +349 -0
- package/extensions/lsp/index.ts +14 -0
- package/extensions/lsp/prompt.ts +39 -0
- package/extensions/lsp/server-manager.ts +303 -0
- package/extensions/lsp/servers.ts +229 -0
- package/extensions/lsp/tools.ts +530 -0
- package/extensions/lsp/trust.ts +39 -0
- package/extensions/safety.ts +370 -0
- package/extensions/session-title.ts +40 -0
- package/extensions/settings.ts +62 -0
- package/extensions/slash.ts +67 -0
- package/extensions/smart-at.ts +220 -0
- package/extensions/subdir-agents.ts +121 -0
- package/index.ts +1 -0
- package/package.json +42 -0
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
|
+
}
|