decorated-pi 0.7.2 → 0.7.3

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
@@ -18,7 +18,7 @@ Multiple layers of token savings that compound across every session.
18
18
 
19
19
  **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
20
 
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)**.
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)**.
22
22
 
23
23
  **Auxiliary Models** — offloads heavy-but-dumb tasks to cheaper models so your primary model only pays for the hard work:
24
24
 
@@ -38,6 +38,10 @@ Multiple layers of token savings that compound across every session.
38
38
  - 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
39
  - unregister the native `write` tool, since `bash` can handle it
40
40
 
41
+ **Large Result Externalization**
42
+
43
+ - 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
44
+
41
45
  ### 2. Smarter Tools
42
46
 
43
47
  Drop‑in replacements for Pi's built‑in tools, with better UX and fewer wasted turns.
@@ -150,7 +154,7 @@ Example redaction on a `read` / `bash` output:
150
154
 
151
155
  ## Configuration
152
156
 
153
- Runtime settings in `~/.pi/agent/decorated-pi.json`. Modules can be toggled via `/dp-settings` (changes take effect after `/reload`).
157
+ Runtime settings in `~/.pi/agent/decorated-pi.json`. run `/dp-settings` to configure it.
154
158
 
155
159
  ```json
156
160
  {
@@ -171,10 +175,22 @@ Runtime settings in `~/.pi/agent/decorated-pi.json`. Modules can be toggled via
171
175
  "retry": true,
172
176
  "usage": true
173
177
  }
178
+ },
179
+ "dependencies": {
180
+ "rtk": {
181
+ "path": "/custom/bin/rtk",
182
+ "dontBother": false
183
+ },
184
+ "wakatime-cli": {
185
+ "dontBother": true
186
+ }
174
187
  }
175
188
  }
176
189
  ```
177
190
 
191
+ - `modules` can be toggled on/off to enable/disable features. All are enabled by default.
192
+ - `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
193
+
178
194
  ## License
179
195
 
180
196
  MIT
@@ -1,5 +1,5 @@
1
1
  /**
2
- * /dp-settings — toggle module on/off.
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: "Toggle decorated-pi modules on/off",
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.
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * compaction — custom compaction model + auto-resume.
3
3
  *
4
- * Uses the configured compact model (from settings.ts) to summarize messages
5
- * on session_before_compact. After auto-compaction, sends a "continue" message
6
- * to resume the agent loop.
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
+ * On `session_compact`, if the compaction was auto-triggered, sends a
11
+ * "continue" message to resume the agent loop.
7
12
  */
8
13
 
9
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
- import { generateSummary, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
15
+ import { compact } from "@earendil-works/pi-coding-agent";
11
16
  import type { Model } from "@earendil-works/pi-ai";
12
17
  import { isContextOverflow } from "@earendil-works/pi-ai";
13
18
  import * as fs from "node:fs";
@@ -19,7 +24,6 @@ import type { Module, Skeleton } from "./skeleton.js";
19
24
 
20
25
  interface PiCompactionSettings {
21
26
  enabled: boolean;
22
- reserveTokens: number;
23
27
  }
24
28
 
25
29
  interface AutoCompactionCandidate {
@@ -29,7 +33,6 @@ interface AutoCompactionCandidate {
29
33
 
30
34
  const DEFAULT_PI_COMPACTION_SETTINGS: PiCompactionSettings = {
31
35
  enabled: true,
32
- reserveTokens: 16_384,
33
36
  };
34
37
 
35
38
  function readJsonObject(filePath: string): any | undefined {
@@ -52,7 +55,6 @@ function loadPiCompactionSettings(cwd: string): PiCompactionSettings {
52
55
  };
53
56
  return {
54
57
  enabled: merged.enabled !== false,
55
- reserveTokens: typeof merged.reserveTokens === "number" ? merged.reserveTokens : DEFAULT_PI_COMPACTION_SETTINGS.reserveTokens,
56
58
  };
57
59
  }
58
60
 
@@ -63,30 +65,24 @@ function getLastAssistantMessage(messages: any[]): any | undefined {
63
65
  return undefined;
64
66
  }
65
67
 
66
- function shouldExpectAutoCompaction(
67
- messages: any[],
68
- usage: { tokens: number | null; contextWindow: number } | undefined,
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;
78
- }
79
-
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. */
80
75
  function shouldAutoResumeCompaction(
81
- prePromptCompactionPending: boolean,
82
- postAgentEndCandidate: AutoCompactionCandidate | null,
76
+ candidate: AutoCompactionCandidate | null,
83
77
  settings: PiCompactionSettings,
84
78
  customInstructions?: string,
85
79
  ): boolean {
86
80
  if (customInstructions !== undefined) return false;
87
- if (prePromptCompactionPending) return true;
88
- if (!postAgentEndCandidate) return false;
89
- return shouldExpectAutoCompaction(postAgentEndCandidate.messages, postAgentEndCandidate.usage, settings);
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);
90
86
  }
91
87
 
92
88
  function getConfiguredCompactModel(registry: any): Model<any> | null {
@@ -97,92 +93,127 @@ function getConfiguredCompactModel(registry: any): Model<any> | null {
97
93
  return registry.find(parsed.provider, parsed.modelId) ?? null;
98
94
  }
99
95
 
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");
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;
114
101
  }
115
102
 
116
- let prePromptCompactionPending = false;
117
- let postAgentEndCandidate: AutoCompactionCandidate | null = null;
118
- let currentCompactionIsAuto = false;
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
+ }
119
113
 
120
114
  export const compactionModule: Module = {
121
115
  name: "compaction",
122
116
  hooks: {
123
- input: [() => { prePromptCompactionPending = true; postAgentEndCandidate = null; }],
124
- before_agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
125
- agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
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
+ ],
126
138
  agent_end: [
127
139
  (event, ctx) => {
128
- prePromptCompactionPending = false;
129
- postAgentEndCandidate = { messages: event.messages, usage: ctx.getContextUsage() };
140
+ getSessionState(ctx.sessionManager.getSessionId()).postAgentEndCandidate = {
141
+ messages: event.messages,
142
+ usage: ctx.getContextUsage(),
143
+ };
130
144
  },
131
145
  ],
132
146
  session_before_compact: [
133
- async (event, ctx) => {
147
+ async (event, ctx, pi) => {
148
+ const sessionState = getSessionState(ctx.sessionManager.getSessionId());
134
149
  const compactionSettings = loadPiCompactionSettings(ctx.cwd);
135
- const { isContextOverflow } = await import("@earendil-works/pi-ai");
136
- const isAuto = shouldExpectAutoCompaction(
137
- postAgentEndCandidate?.messages ?? [],
138
- postAgentEndCandidate?.usage,
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,
139
156
  compactionSettings,
157
+ event.customInstructions,
140
158
  );
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;
159
+ sessionState.postAgentEndCandidate = null;
147
160
 
148
161
  const model = getConfiguredCompactModel(ctx.modelRegistry);
149
- if (!model) return;
162
+ if (!model) return; // No custom compact model configured → let pi run its default compaction.
150
163
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
151
164
  if (!auth.ok) {
152
165
  if (ctx.hasUI) ctx.ui.notify(`Compact model auth failed: ${auth.error}`, "warning");
153
- return;
166
+ return; // Auth failed → fall through to default compaction; pi handles its own resume.
154
167
  }
155
168
  const { preparation, customInstructions, signal } = event;
156
- const { messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, firstKeptEntryId, previousSummary, settings } = preparation;
157
- if (ctx.hasUI) ctx.ui.notify(`🗜️ Compacting with ${model.id} (${tokensBefore.toLocaleString()} tokens)...`, "info");
169
+ if (ctx.hasUI) {
170
+ ctx.ui.notify(
171
+ `🗜️ Compacting with ${model.id} (${preparation.tokensBefore.toLocaleString()} tokens)...`,
172
+ "info",
173
+ );
174
+ }
175
+ // Mirror pi's native behavior: compact() uses the agent session's
176
+ // current thinking level (agent-session.js passes this.thinkingLevel
177
+ // to compact() in both manual and auto compaction paths).
178
+ const thinkingLevel = pi.getThinkingLevel();
158
179
  try {
159
- let summary: string;
160
- if (isSplitTurn && turnPrefixMessages.length > 0) {
161
- const [hs, ps] = await Promise.all([
162
- messagesToSummarize.length > 0
163
- ? generateSummary(messagesToSummarize, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary)
164
- : Promise.resolve("No prior history."),
165
- generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal),
166
- ]);
167
- summary = `${hs}\n\n---\n\n**Turn Context (split turn):**\n\n${ps}`;
168
- } else {
169
- summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary);
170
- }
171
- // session_before_compact is a parallel event handlers may not
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.
180
+ // Delegate to pi-coding-agent's compact() — it does both
181
+ // summarization (using our model) and file-op extraction, and
182
+ // returns the exact CompactionResult shape pi's runner expects.
183
+ const result = await compact(
184
+ preparation,
185
+ model,
186
+ auth.apiKey ?? "",
187
+ auth.headers,
188
+ customInstructions,
189
+ signal,
190
+ thinkingLevel,
191
+ );
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
+ return { compaction: result };
176
198
  } catch (err) {
177
199
  if (signal.aborted) return;
178
- if (ctx.hasUI) ctx.ui.notify(`Compact failed: ${err instanceof Error ? err.message : err}`, "error");
200
+ // Returning undefined lets pi run its default compaction with the
201
+ // active agent model. Surface the failure so the user knows their
202
+ // custom model didn't take effect.
203
+ if (ctx.hasUI) {
204
+ ctx.ui.notify(
205
+ `Custom compact failed (${err instanceof Error ? err.message : err}); using default model.`,
206
+ "warning",
207
+ );
208
+ }
179
209
  }
180
210
  },
181
211
  ],
182
212
  session_compact: [
183
- (_event, _ctx, pi) => {
184
- const shouldResume = currentCompactionIsAuto;
185
- currentCompactionIsAuto = false;
213
+ (_event, ctx, pi) => {
214
+ const sessionState = getSessionState(ctx.sessionManager.getSessionId());
215
+ const shouldResume = sessionState.currentCompactionIsAuto;
216
+ sessionState.currentCompactionIsAuto = false;
186
217
  if (!shouldResume) return;
187
218
  pi.sendMessage({
188
219
  customType: "auto_compact_resume",
@@ -199,6 +230,5 @@ export function setupCompaction(sk: Skeleton): void {
199
230
  }
200
231
 
201
232
  export const __modelIntegrationTest = {
202
- shouldExpectAutoCompaction,
203
233
  shouldAutoResumeCompaction,
204
234
  };
@@ -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(os.tmpdir(), "decorated-pi-results");
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
- toolName: string,
26
- toolCallId: string,
27
- content: string,
28
+ toolName: string,
29
+ toolCallId: string,
30
+ content: string,
28
31
  ): string | undefined {
29
- try {
30
- if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR)) fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
31
- const id = toolCallId ? toolCallId.slice(0, 12) : randomBytes(8).toString("hex");
32
- const filePath = path.join(TOOL_OUTPUT_TEMP_DIR, `${toolName}-${id}.txt`);
33
- fs.writeFileSync(filePath, content, "utf-8");
34
- return filePath;
35
- } catch {
36
- return undefined;
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
- if (!Array.isArray(event.content) || event.content.length === 0) return undefined;
44
- const first = event.content[0];
45
- if (!first || first.type !== "text" || typeof first.text !== "string") return undefined;
46
- const text = first.text;
47
- if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
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
- const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
50
- if (!filePath) return undefined;
60
+ const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
61
+ if (!filePath) return undefined;
51
62
 
52
- return {
53
- ...event,
54
- content: [{
55
- type: "text" as const,
56
- text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
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
- name: "externalize",
63
- hooks: {
64
- tool_result: [
65
- (event) => {
66
- return maybeExternalizeToolResult(event);
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. If RTK is not installed, this module is inactive.
5
- * When a rewritten RTK command fails, the original command is executed once as fallback.
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
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
- try {
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
- // ─── Bash tool registration ──────────────────────────────────────────────
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, pi: ExtensionAPI): void {
118
+ export function setupRtk(sk: Skeleton): void {
162
119
  rtkBinary = findSystemRtk();
163
- const ready = sk.declareDependency({
164
- label: "rtk",
165
- module: "rtk",
166
- check: () => findSystemRtk() !== null,
167
- hint: "Install RTK so bash rewrite/tagging can activate.",
168
- });
169
- if (!ready) return;
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.registerTool(bashTool);
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
  }