decorated-pi 0.7.2 → 0.8.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/README.md +21 -3
- package/commands/dp-settings.ts +3 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +52 -157
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +57 -19
- package/hooks/smart-at.ts +69 -30
- package/hooks/thinking-label-strip.ts +79 -0
- package/hooks/wakatime.ts +17 -30
- package/index.ts +207 -170
- package/package.json +3 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +1 -4
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +113 -24
- package/tools/patch/encoding.ts +172 -0
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/README.md
CHANGED
|
@@ -14,11 +14,13 @@ 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
|
|
|
21
|
-
**Codegraph** — integrates [codegraph](https://github.com/colbymchenry/codegraph) to offer a code map of your project, so the LLM can navigate symbols and call graphs without chaining `ls` → `grep` → `read`. **You should manage the code source index yourself, see[codegraph doc](https://github.com/colbymchenry/codegraph/blob/main/README.md)**.
|
|
23
|
+
**Codegraph** — integrates [codegraph](https://github.com/colbymchenry/codegraph) to offer a code map of your project, so the LLM can navigate symbols and call graphs without chaining `ls` → `grep` → `read`. **You should manage the code source index yourself, see [codegraph doc](https://github.com/colbymchenry/codegraph/blob/main/README.md)**.
|
|
22
24
|
|
|
23
25
|
**Auxiliary Models** — offloads heavy-but-dumb tasks to cheaper models so your primary model only pays for the hard work:
|
|
24
26
|
|
|
@@ -38,6 +40,10 @@ Multiple layers of token savings that compound across every session.
|
|
|
38
40
|
- 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
41
|
- unregister the native `write` tool, since `bash` can handle it
|
|
40
42
|
|
|
43
|
+
**Large Result Externalization**
|
|
44
|
+
|
|
45
|
+
- a `tool_result` hook that catches ANY tool output (bash, read, MCP, etc.) exceeding 30 KB and saves the full content to `/tmp/decorated-pi-results/<tool>-<callId>.txt`. Pi's native truncation still keeps a chunk of the original content in the prompt; this replaces it entirely with a one‑line pointer (`[Output too long, saved to /tmp/…]`) so the LLM knows where the result is and reads it on demand — strictly fewer tokens per turn
|
|
46
|
+
|
|
41
47
|
### 2. Smarter Tools
|
|
42
48
|
|
|
43
49
|
Drop‑in replacements for Pi's built‑in tools, with better UX and fewer wasted turns.
|
|
@@ -150,7 +156,7 @@ Example redaction on a `read` / `bash` output:
|
|
|
150
156
|
|
|
151
157
|
## Configuration
|
|
152
158
|
|
|
153
|
-
Runtime settings in `~/.pi/agent/decorated-pi.json`.
|
|
159
|
+
Runtime settings in `~/.pi/agent/decorated-pi.json`. run `/dp-settings` to configure it.
|
|
154
160
|
|
|
155
161
|
```json
|
|
156
162
|
{
|
|
@@ -171,10 +177,22 @@ Runtime settings in `~/.pi/agent/decorated-pi.json`. Modules can be toggled via
|
|
|
171
177
|
"retry": true,
|
|
172
178
|
"usage": true
|
|
173
179
|
}
|
|
180
|
+
},
|
|
181
|
+
"dependencies": {
|
|
182
|
+
"rtk": {
|
|
183
|
+
"path": "/custom/bin/rtk",
|
|
184
|
+
"dontBother": false
|
|
185
|
+
},
|
|
186
|
+
"wakatime-cli": {
|
|
187
|
+
"dontBother": true
|
|
188
|
+
}
|
|
174
189
|
}
|
|
175
190
|
}
|
|
176
191
|
```
|
|
177
192
|
|
|
193
|
+
- `modules` can be toggled on/off to enable/disable features. All are enabled by default.
|
|
194
|
+
- `dependencies[binaryName].path` overrides the lookup location for a binary (file or directory). `dependencies[binaryName].dontBother` silences missing-dependency notifications for that binary. Both are optional
|
|
195
|
+
|
|
178
196
|
## License
|
|
179
197
|
|
|
180
198
|
MIT
|
package/commands/dp-settings.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /dp-settings —
|
|
2
|
+
* /dp-settings — settings for decorated-pi.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -8,12 +8,12 @@ import { ModuleSettingsComponent } from "../ui/module-settings.js";
|
|
|
8
8
|
|
|
9
9
|
export function registerDpSettingsCommand(pi: ExtensionAPI): void {
|
|
10
10
|
pi.registerCommand("dp-settings", {
|
|
11
|
-
description: "
|
|
11
|
+
description: "Settings for decorated-pi",
|
|
12
12
|
handler: async (_args, ctx) => {
|
|
13
13
|
if (ctx.hasUI) {
|
|
14
14
|
await ctx.ui.custom<void>(
|
|
15
15
|
(tui, theme, _kb, done) =>
|
|
16
|
-
new ModuleSettingsComponent(tui, theme, () => done(undefined))
|
|
16
|
+
new ModuleSettingsComponent(tui, theme, ctx.ui, () => done(undefined))
|
|
17
17
|
);
|
|
18
18
|
// Only prompt for reload when the effective settings differ from
|
|
19
19
|
// the snapshot taken when pi loaded the extension.
|
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,92 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* compaction — custom compaction model
|
|
2
|
+
* compaction — custom compaction model.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* On `session_before_compact`, runs pi-coding-agent's `compact()` against
|
|
5
|
+
* the model configured in settings (rather than the agent's current model)
|
|
6
|
+
* and returns the result so pi uses our summary instead of running its
|
|
7
|
+
* default. If the configured model is missing, auth fails, or the call
|
|
8
|
+
* throws, we fall through (return undefined) and pi runs its own compaction.
|
|
9
|
+
*
|
|
10
|
+
* Auto-retry/resume is handled by pi natively; `reason` and `willRetry`
|
|
11
|
+
* on compaction events describe manual, threshold, and overflow flows.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
|
-
import
|
|
10
|
-
import { generateSummary, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { compact } from "@earendil-works/pi-coding-agent";
|
|
11
15
|
import type { Model } from "@earendil-works/pi-ai";
|
|
12
|
-
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
13
|
-
import * as fs from "node:fs";
|
|
14
|
-
import * as os from "node:os";
|
|
15
|
-
import { resolve } from "node:path";
|
|
16
16
|
import { getCompactModelKey } from "../settings.js";
|
|
17
17
|
import { parseModelKey } from "../settings.js";
|
|
18
18
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
enabled: boolean;
|
|
22
|
-
reserveTokens: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface AutoCompactionCandidate {
|
|
26
|
-
messages: any[];
|
|
27
|
-
usage: { tokens: number | null; contextWindow: number } | undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const DEFAULT_PI_COMPACTION_SETTINGS: PiCompactionSettings = {
|
|
31
|
-
enabled: true,
|
|
32
|
-
reserveTokens: 16_384,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function readJsonObject(filePath: string): any | undefined {
|
|
36
|
-
try {
|
|
37
|
-
if (!fs.existsSync(filePath)) return undefined;
|
|
38
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
39
|
-
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
40
|
-
} catch {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function loadPiCompactionSettings(cwd: string): PiCompactionSettings {
|
|
46
|
-
const globalSettings = readJsonObject(resolve(os.homedir(), ".pi", "agent", "settings.json"));
|
|
47
|
-
const projectSettings = readJsonObject(resolve(cwd, ".pi", "settings.json"));
|
|
48
|
-
const merged = {
|
|
49
|
-
...DEFAULT_PI_COMPACTION_SETTINGS,
|
|
50
|
-
...(globalSettings?.compaction ?? {}),
|
|
51
|
-
...(projectSettings?.compaction ?? {}),
|
|
52
|
-
};
|
|
53
|
-
return {
|
|
54
|
-
enabled: merged.enabled !== false,
|
|
55
|
-
reserveTokens: typeof merged.reserveTokens === "number" ? merged.reserveTokens : DEFAULT_PI_COMPACTION_SETTINGS.reserveTokens,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function getLastAssistantMessage(messages: any[]): any | undefined {
|
|
60
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
61
|
-
if (messages[i]?.role === "assistant") return messages[i];
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
20
|
+
type CompactionReason = "manual" | "threshold" | "overflow";
|
|
65
21
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
settings: PiCompactionSettings,
|
|
70
|
-
): boolean {
|
|
71
|
-
if (!settings.enabled) return false;
|
|
72
|
-
const lastAssistant = getLastAssistantMessage(messages);
|
|
73
|
-
if (!lastAssistant) return false;
|
|
74
|
-
const contextWindow = usage?.contextWindow ?? 0;
|
|
75
|
-
if (contextWindow > 0 && isContextOverflow(lastAssistant, contextWindow)) return true;
|
|
76
|
-
if (!usage || usage.tokens === null) return false;
|
|
77
|
-
return usage.tokens > usage.contextWindow - settings.reserveTokens;
|
|
22
|
+
interface CompactionEventMetadata {
|
|
23
|
+
reason: CompactionReason;
|
|
24
|
+
willRetry: boolean;
|
|
78
25
|
}
|
|
79
26
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
postAgentEndCandidate: AutoCompactionCandidate | null,
|
|
83
|
-
settings: PiCompactionSettings,
|
|
84
|
-
customInstructions?: string,
|
|
85
|
-
): boolean {
|
|
86
|
-
if (customInstructions !== undefined) return false;
|
|
87
|
-
if (prePromptCompactionPending) return true;
|
|
88
|
-
if (!postAgentEndCandidate) return false;
|
|
89
|
-
return shouldExpectAutoCompaction(postAgentEndCandidate.messages, postAgentEndCandidate.usage, settings);
|
|
27
|
+
function formatCompactionMode(event: CompactionEventMetadata): string {
|
|
28
|
+
return event.willRetry ? `${event.reason}, retrying` : event.reason;
|
|
90
29
|
}
|
|
91
30
|
|
|
92
31
|
function getConfiguredCompactModel(registry: any): Model<any> | null {
|
|
@@ -97,100 +36,57 @@ function getConfiguredCompactModel(registry: any): Model<any> | null {
|
|
|
97
36
|
return registry.find(parsed.provider, parsed.modelId) ?? null;
|
|
98
37
|
}
|
|
99
38
|
|
|
100
|
-
const TURN_PREFIX_PROMPT = `Summarize this turn prefix to provide context for the retained suffix. Be concise. Focus on what's needed to understand the kept suffix.`;
|
|
101
|
-
|
|
102
|
-
async function generateTurnPrefixSummary(
|
|
103
|
-
messages: any[], model: Model<any>, reserveTokens: number,
|
|
104
|
-
apiKey: string, headers: Record<string, string> | undefined, signal: AbortSignal,
|
|
105
|
-
): Promise<string> {
|
|
106
|
-
const { complete } = await import("@earendil-works/pi-ai");
|
|
107
|
-
const ct = serializeConversation(convertToLlm(messages));
|
|
108
|
-
const resp = await complete(model, {
|
|
109
|
-
systemPrompt: "You are a context summarization assistant. Produce a structured summary only.",
|
|
110
|
-
messages: [{ role: "user" as const, content: [{ type: "text" as const, text: `<conversation>\n${ct}\n</conversation>\n\n${TURN_PREFIX_PROMPT}` }], timestamp: Date.now() }],
|
|
111
|
-
}, { maxTokens: Math.floor(0.5 * reserveTokens), signal, apiKey, headers });
|
|
112
|
-
if (resp.stopReason === "error") throw new Error(resp.errorMessage ?? "Turn prefix summarization failed");
|
|
113
|
-
return resp.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join("\n");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
let prePromptCompactionPending = false;
|
|
117
|
-
let postAgentEndCandidate: AutoCompactionCandidate | null = null;
|
|
118
|
-
let currentCompactionIsAuto = false;
|
|
119
|
-
|
|
120
39
|
export const compactionModule: Module = {
|
|
121
40
|
name: "compaction",
|
|
122
41
|
hooks: {
|
|
123
|
-
input: [() => { prePromptCompactionPending = true; postAgentEndCandidate = null; }],
|
|
124
|
-
before_agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
|
|
125
|
-
agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
|
|
126
|
-
agent_end: [
|
|
127
|
-
(event, ctx) => {
|
|
128
|
-
prePromptCompactionPending = false;
|
|
129
|
-
postAgentEndCandidate = { messages: event.messages, usage: ctx.getContextUsage() };
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
42
|
session_before_compact: [
|
|
133
|
-
async (event, ctx) => {
|
|
134
|
-
const compactionSettings = loadPiCompactionSettings(ctx.cwd);
|
|
135
|
-
const { isContextOverflow } = await import("@earendil-works/pi-ai");
|
|
136
|
-
const isAuto = shouldExpectAutoCompaction(
|
|
137
|
-
postAgentEndCandidate?.messages ?? [],
|
|
138
|
-
postAgentEndCandidate?.usage,
|
|
139
|
-
compactionSettings,
|
|
140
|
-
);
|
|
141
|
-
// For simplicity, treat session_before_compact as auto if recent agent_end was likely-overflow
|
|
142
|
-
// and no custom instructions given.
|
|
143
|
-
const isAutoResume = isAuto && !event.customInstructions;
|
|
144
|
-
currentCompactionIsAuto = isAutoResume;
|
|
145
|
-
prePromptCompactionPending = false;
|
|
146
|
-
postAgentEndCandidate = null;
|
|
147
|
-
|
|
43
|
+
async (event, ctx, pi) => {
|
|
148
44
|
const model = getConfiguredCompactModel(ctx.modelRegistry);
|
|
149
|
-
if (!model) return;
|
|
45
|
+
if (!model) return; // No custom compact model configured → let pi run its default compaction.
|
|
150
46
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
151
47
|
if (!auth.ok) {
|
|
152
48
|
if (ctx.hasUI) ctx.ui.notify(`Compact model auth failed: ${auth.error}`, "warning");
|
|
153
|
-
return;
|
|
49
|
+
return; // Auth failed → fall through to default compaction; pi handles its own resume.
|
|
154
50
|
}
|
|
155
51
|
const { preparation, customInstructions, signal } = event;
|
|
156
|
-
|
|
157
|
-
|
|
52
|
+
if (ctx.hasUI) {
|
|
53
|
+
ctx.ui.notify(
|
|
54
|
+
`📦 Compacting with ${model.id} (${formatCompactionMode(event)}, ${preparation.tokensBefore.toLocaleString()} tokens)...`,
|
|
55
|
+
"info",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// Mirror pi's native behavior: compact() uses the agent session's
|
|
59
|
+
// current thinking level (agent-session.js passes this.thinkingLevel
|
|
60
|
+
// to compact() in both manual and auto compaction paths).
|
|
61
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
158
62
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// return a value (skeleton discards compose returns for
|
|
173
|
-
// non-compose events). The summary is intentionally lost; the
|
|
174
|
-
// session_compact handler below only reads the
|
|
175
|
-
// currentCompactionIsAuto flag and does not consume the summary.
|
|
63
|
+
// Delegate to pi-coding-agent's compact() — it does both
|
|
64
|
+
// summarization (using our model) and file-op extraction, and
|
|
65
|
+
// returns the exact CompactionResult shape pi's runner expects.
|
|
66
|
+
const result = await compact(
|
|
67
|
+
preparation,
|
|
68
|
+
model,
|
|
69
|
+
auth.apiKey ?? "",
|
|
70
|
+
auth.headers,
|
|
71
|
+
customInstructions,
|
|
72
|
+
signal,
|
|
73
|
+
thinkingLevel,
|
|
74
|
+
);
|
|
75
|
+
return { compaction: result };
|
|
176
76
|
} catch (err) {
|
|
177
77
|
if (signal.aborted) return;
|
|
178
|
-
|
|
78
|
+
// Returning undefined lets pi run its default compaction with the
|
|
79
|
+
// active agent model. Surface the failure so the user knows their
|
|
80
|
+
// custom model didn't take effect.
|
|
81
|
+
if (ctx.hasUI) {
|
|
82
|
+
ctx.ui.notify(
|
|
83
|
+
`Custom compact failed (${err instanceof Error ? err.message : err}); using default model.`,
|
|
84
|
+
"warning",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
179
87
|
}
|
|
180
88
|
},
|
|
181
89
|
],
|
|
182
|
-
session_compact: [
|
|
183
|
-
(_event, _ctx, pi) => {
|
|
184
|
-
const shouldResume = currentCompactionIsAuto;
|
|
185
|
-
currentCompactionIsAuto = false;
|
|
186
|
-
if (!shouldResume) return;
|
|
187
|
-
pi.sendMessage({
|
|
188
|
-
customType: "auto_compact_resume",
|
|
189
|
-
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.",
|
|
190
|
-
display: false,
|
|
191
|
-
}, { triggerTurn: true });
|
|
192
|
-
},
|
|
193
|
-
],
|
|
194
90
|
},
|
|
195
91
|
};
|
|
196
92
|
|
|
@@ -199,6 +95,5 @@ export function setupCompaction(sk: Skeleton): void {
|
|
|
199
95
|
}
|
|
200
96
|
|
|
201
97
|
export const __modelIntegrationTest = {
|
|
202
|
-
|
|
203
|
-
shouldAutoResumeCompaction,
|
|
98
|
+
formatCompactionMode,
|
|
204
99
|
};
|
package/hooks/externalize.ts
CHANGED
|
@@ -14,7 +14,10 @@ import * as path from "node:path";
|
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import type { Module } from "./skeleton.js";
|
|
16
16
|
|
|
17
|
-
export const TOOL_OUTPUT_TEMP_DIR = path.join(
|
|
17
|
+
export const TOOL_OUTPUT_TEMP_DIR = path.join(
|
|
18
|
+
os.tmpdir(),
|
|
19
|
+
"decorated-pi-results",
|
|
20
|
+
);
|
|
18
21
|
export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000;
|
|
19
22
|
|
|
20
23
|
/** Write content to a temp file under TOOL_OUTPUT_TEMP_DIR.
|
|
@@ -22,49 +25,59 @@ export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000;
|
|
|
22
25
|
* Exported so other modules (e.g. tools/mcp/externalize.ts) can
|
|
23
26
|
* write to the same location. */
|
|
24
27
|
export function writeOutputToTemp(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
toolName: string,
|
|
29
|
+
toolCallId: string,
|
|
30
|
+
content: string,
|
|
28
31
|
): string | undefined {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR))
|
|
34
|
+
fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
|
|
35
|
+
const id = toolCallId
|
|
36
|
+
? toolCallId.slice(0, 12)
|
|
37
|
+
: randomBytes(8).toString("hex");
|
|
38
|
+
const filePath = path.join(
|
|
39
|
+
TOOL_OUTPUT_TEMP_DIR,
|
|
40
|
+
`${toolName}-${id}.txt`,
|
|
41
|
+
);
|
|
42
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
43
|
+
return filePath;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/** Externalize a tool_result event if content is above the threshold.
|
|
41
50
|
* Returns the modified event, or undefined to leave the original untouched. */
|
|
42
51
|
export function maybeExternalizeToolResult(event: any): any | undefined {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
if (!Array.isArray(event.content) || event.content.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
const first = event.content[0];
|
|
55
|
+
if (!first || first.type !== "text" || typeof first.text !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
const text = first.text;
|
|
58
|
+
if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
|
|
61
|
+
if (!filePath) return undefined;
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
return {
|
|
64
|
+
...event,
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text" as const,
|
|
68
|
+
text: `[Output too long, saved to ${filePath}.]`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
export const externalizeModule: Module = {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
name: "externalize",
|
|
76
|
+
hooks: {
|
|
77
|
+
tool_result: [
|
|
78
|
+
(event) => {
|
|
79
|
+
return maybeExternalizeToolResult(event);
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
70
83
|
};
|
package/hooks/rtk.ts
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* rtk — rewrite bash commands through system-installed RTK.
|
|
3
3
|
*
|
|
4
|
-
* Uses `rtk rewrite` as a preflight
|
|
5
|
-
* When a rewritten RTK command fails,
|
|
4
|
+
* Uses `rtk rewrite` as a preflight against pi's built-in bash tool. If RTK is
|
|
5
|
+
* not installed, this module is inactive. When a rewritten RTK command fails,
|
|
6
|
+
* the original command is executed once as fallback.
|
|
7
|
+
*
|
|
8
|
+
* We do NOT register our own bash tool. We hook the existing one via
|
|
9
|
+
* `tool_call` (rewrite the command before it runs) and `tool_result` (fall
|
|
10
|
+
* back to the original on error). Overriding bash via `pi.registerTool` would
|
|
11
|
+
* conflict with other extensions (e.g. pi-sandbox) that also override bash.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
|
-
import * as fs from "node:fs";
|
|
13
|
-
import * as os from "node:os";
|
|
14
|
+
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
14
16
|
import * as path from "node:path";
|
|
15
17
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
18
|
+
import { resolveDependency } from "../settings.js";
|
|
16
19
|
|
|
17
20
|
// ─── Locating RTK ─────────────────────────────────────────────────────────
|
|
18
21
|
|
|
19
22
|
export function findSystemRtk(): string | null {
|
|
20
|
-
|
|
21
|
-
if (process.platform === "win32") {
|
|
22
|
-
const output = execFileSync("where", ["rtk"], { encoding: "utf-8" }).trim();
|
|
23
|
-
return output.split(/\r?\n/)[0] || null;
|
|
24
|
-
}
|
|
25
|
-
const shell = process.env.SHELL || "sh";
|
|
26
|
-
return execFileSync(shell, ["-lc", "command -v rtk"], { encoding: "utf-8" }).trim() || null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
23
|
+
return resolveDependency("rtk");
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export function shellQuote(value: string): string {
|
|
@@ -45,49 +39,12 @@ export function rewriteWithRtk(command: string, rtkPath: string): string | null
|
|
|
45
39
|
return buildRtkCommand(raw, rtkPath);
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
// ───
|
|
49
|
-
|
|
50
|
-
interface PiShellSettings {
|
|
51
|
-
shellPath?: string;
|
|
52
|
-
shellCommandPrefix?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readJsonObject(filePath: string): Record<string, unknown> {
|
|
56
|
-
try {
|
|
57
|
-
if (!fs.existsSync(filePath)) return {};
|
|
58
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
59
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
60
|
-
} catch {
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function loadPiShellSettings(cwd: string): PiShellSettings {
|
|
66
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
67
|
-
const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
|
|
68
|
-
const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
|
|
69
|
-
const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
|
|
70
|
-
const result: PiShellSettings = {};
|
|
71
|
-
if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
|
|
72
|
-
if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
|
|
73
|
-
result.shellCommandPrefix = merged.shellCommandPrefix;
|
|
74
|
-
}
|
|
75
|
-
return result;
|
|
76
|
-
}
|
|
42
|
+
// ─── Fallback execution ──────────────────────────────────────────────────
|
|
77
43
|
|
|
78
44
|
export function appendStatus(text: string, status: string): string {
|
|
79
45
|
return text ? `${text}\n\n${status}` : status;
|
|
80
46
|
}
|
|
81
47
|
|
|
82
|
-
export function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
|
|
83
|
-
const command = typeof args?.command === "string" ? args.command : null;
|
|
84
|
-
const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
|
|
85
|
-
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
|
86
|
-
const commandDisplay = command === null ? theme.fg("error", "<invalid command>") : command || theme.fg("toolOutput", "...");
|
|
87
|
-
const tag = showTag ? theme.fg("borderAccent", " [RTK]") : "";
|
|
88
|
-
return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix + tag;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
48
|
export async function executeOriginalBash(command: string, cwd: string, timeout: number | undefined, signal?: AbortSignal) {
|
|
92
49
|
const ops = createLocalBashOperations();
|
|
93
50
|
const chunks: Buffer[] = [];
|
|
@@ -158,49 +115,19 @@ export const rtkModule: Module = {
|
|
|
158
115
|
},
|
|
159
116
|
};
|
|
160
117
|
|
|
161
|
-
export function setupRtk(sk: Skeleton
|
|
118
|
+
export function setupRtk(sk: Skeleton): void {
|
|
162
119
|
rtkBinary = findSystemRtk();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Register a wrapped bash tool that shows [RTK] tag in TUI.
|
|
172
|
-
const shellSettings = loadPiShellSettings(process.cwd());
|
|
173
|
-
const bashTool = createBashToolDefinition(process.cwd(), {
|
|
174
|
-
shellPath: shellSettings.shellPath,
|
|
175
|
-
commandPrefix: shellSettings.shellCommandPrefix,
|
|
176
|
-
});
|
|
177
|
-
const baseRenderCall = bashTool.renderCall?.bind(bashTool);
|
|
178
|
-
|
|
179
|
-
if (baseRenderCall) {
|
|
180
|
-
bashTool.renderCall = (args: any, theme: any, context: any) => {
|
|
181
|
-
const command = typeof args?.command === "string" ? args.command : "";
|
|
182
|
-
if (!command) {
|
|
183
|
-
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
184
|
-
const placeholder = theme.fg("toolOutput", "...");
|
|
185
|
-
text.setText(theme.fg("toolTitle", theme.bold(`$ ${placeholder}`)));
|
|
186
|
-
return text;
|
|
187
|
-
}
|
|
188
|
-
const component = baseRenderCall(args, theme, context);
|
|
189
|
-
const predicted = command
|
|
190
|
-
? (rewriteabilityCache.get(command) ?? (() => {
|
|
191
|
-
const value = rewriteWithRtk(command, rtkBinary!) !== null;
|
|
192
|
-
rewriteabilityCache.set(command, value);
|
|
193
|
-
return value;
|
|
194
|
-
})())
|
|
195
|
-
: false;
|
|
196
|
-
const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
|
|
197
|
-
if (component instanceof Text) {
|
|
198
|
-
component.setText(formatBashCallWithTag(args, theme, rewritten));
|
|
199
|
-
}
|
|
200
|
-
return component;
|
|
201
|
-
};
|
|
120
|
+
if (!rtkBinary) {
|
|
121
|
+
sk.declareMissing({
|
|
122
|
+
name: "rtk",
|
|
123
|
+
module: "rtk",
|
|
124
|
+
hint: "Install RTK so bash rewrite can activate.",
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
202
127
|
}
|
|
203
128
|
|
|
204
|
-
pi.
|
|
129
|
+
// Hook pi's built-in bash tool via tool_call/tool_result. We deliberately do
|
|
130
|
+
// not call pi.registerTool — that would shadow pi's bash and conflict with
|
|
131
|
+
// any other extension that also overrides bash (e.g. pi-sandbox).
|
|
205
132
|
sk.register(rtkModule);
|
|
206
133
|
}
|