decorated-pi 0.5.4 → 0.6.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/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -1,55 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* rtk — rewrite bash commands through system-installed RTK.
|
|
3
3
|
*
|
|
4
|
-
* Uses `rtk rewrite` as a preflight
|
|
5
|
-
*
|
|
6
|
-
* command is executed once as a fallback.
|
|
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.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { createBashToolDefinition, createLocalBashOperations
|
|
9
|
+
import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
11
10
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
11
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
12
|
import * as fs from "node:fs";
|
|
14
13
|
import * as os from "node:os";
|
|
15
14
|
import * as path from "node:path";
|
|
15
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
interface PiShellSettings {
|
|
20
|
-
shellPath?: string;
|
|
21
|
-
shellCommandPrefix?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface DependencyStatus {
|
|
25
|
-
module: string;
|
|
26
|
-
label: string;
|
|
27
|
-
state: "ok" | "missing" | "n/a";
|
|
28
|
-
detail?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function readJsonObject(filePath: string): Record<string, unknown> {
|
|
32
|
-
try {
|
|
33
|
-
if (!fs.existsSync(filePath)) return {};
|
|
34
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
35
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
36
|
-
} catch {
|
|
37
|
-
return {};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function loadPiShellSettings(cwd: string): PiShellSettings {
|
|
42
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
43
|
-
const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
|
|
44
|
-
const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
|
|
45
|
-
const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
|
|
46
|
-
const result: PiShellSettings = {};
|
|
47
|
-
if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
|
|
48
|
-
if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
|
|
49
|
-
result.shellCommandPrefix = merged.shellCommandPrefix;
|
|
50
|
-
}
|
|
51
|
-
return result;
|
|
52
|
-
}
|
|
17
|
+
// ─── Locating RTK ─────────────────────────────────────────────────────────
|
|
53
18
|
|
|
54
19
|
export function findSystemRtk(): string | null {
|
|
55
20
|
try {
|
|
@@ -74,27 +39,47 @@ export function buildRtkCommand(raw: string, rtkBinaryPath: string): string {
|
|
|
74
39
|
}
|
|
75
40
|
|
|
76
41
|
export function rewriteWithRtk(command: string, rtkPath: string): string | null {
|
|
77
|
-
|
|
78
|
-
// NOTE:
|
|
79
|
-
// Some RTK versions return a non-zero exit code even when `rtk rewrite`
|
|
80
|
-
// successfully prints a rewritten command to stdout (observed locally with
|
|
81
|
-
// RTK 0.42.0 returning exit code 3 on success). Because of that, we treat
|
|
82
|
-
// non-empty stdout as the source of truth and ignore the process exit code
|
|
83
|
-
// here. Empty stdout still means “no rewrite available”.
|
|
84
|
-
const result = spawnSync(rtkPath, ["rewrite", command], {
|
|
85
|
-
encoding: "utf-8",
|
|
86
|
-
timeout: 2000,
|
|
87
|
-
});
|
|
42
|
+
const result = spawnSync(rtkPath, ["rewrite", command], { encoding: "utf-8", timeout: 2000 });
|
|
88
43
|
const raw = (result.stdout ?? "").trim();
|
|
89
44
|
if (!raw) return null;
|
|
90
45
|
return buildRtkCommand(raw, rtkPath);
|
|
91
46
|
}
|
|
92
47
|
|
|
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
|
+
}
|
|
77
|
+
|
|
93
78
|
export function appendStatus(text: string, status: string): string {
|
|
94
79
|
return text ? `${text}\n\n${status}` : status;
|
|
95
80
|
}
|
|
96
81
|
|
|
97
|
-
function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
|
|
82
|
+
export function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
|
|
98
83
|
const command = typeof args?.command === "string" ? args.command : null;
|
|
99
84
|
const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
|
|
100
85
|
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
|
@@ -114,56 +99,75 @@ export async function executeOriginalBash(command: string, cwd: string, timeout:
|
|
|
114
99
|
const output = getOutput() || "(no output)";
|
|
115
100
|
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
116
101
|
return {
|
|
117
|
-
content: [{ type: "text" as const, text:
|
|
102
|
+
content: [{ type: "text" as const, text: output ? `${output}\n\nCommand exited with code ${result.exitCode}` : `Command exited with code ${result.exitCode}` }],
|
|
118
103
|
details: undefined,
|
|
119
104
|
isError: true,
|
|
120
105
|
};
|
|
121
106
|
}
|
|
122
|
-
return {
|
|
123
|
-
content: [{ type: "text" as const, text: output }],
|
|
124
|
-
details: undefined,
|
|
125
|
-
isError: false,
|
|
126
|
-
};
|
|
107
|
+
return { content: [{ type: "text" as const, text: output }], details: undefined, isError: false };
|
|
127
108
|
} catch (err) {
|
|
128
109
|
const output = getOutput();
|
|
129
110
|
if (err instanceof Error && err.message === "aborted") {
|
|
130
|
-
return {
|
|
131
|
-
content: [{ type: "text" as const, text: appendStatus(output, "Command aborted") }],
|
|
132
|
-
details: undefined,
|
|
133
|
-
isError: true,
|
|
134
|
-
};
|
|
111
|
+
return { content: [{ type: "text" as const, text: output ? `${output}\n\nCommand aborted` : "Command aborted" }], details: undefined, isError: true };
|
|
135
112
|
}
|
|
136
113
|
if (err instanceof Error && err.message.startsWith("timeout:")) {
|
|
137
114
|
const timeoutSecs = err.message.split(":")[1];
|
|
138
|
-
return {
|
|
139
|
-
content: [{ type: "text" as const, text: appendStatus(output, `Command timed out after ${timeoutSecs} seconds`) }],
|
|
140
|
-
details: undefined,
|
|
141
|
-
isError: true,
|
|
142
|
-
};
|
|
115
|
+
return { content: [{ type: "text" as const, text: output ? `${output}\n\nCommand timed out after ${timeoutSecs} seconds` : `Command timed out after ${timeoutSecs} seconds` }], details: undefined, isError: true };
|
|
143
116
|
}
|
|
144
|
-
return {
|
|
145
|
-
content: [{ type: "text" as const, text: appendStatus(output, err instanceof Error ? err.message : "Command failed") }],
|
|
146
|
-
details: undefined,
|
|
147
|
-
isError: true,
|
|
148
|
-
};
|
|
117
|
+
return { content: [{ type: "text" as const, text: output ? `${output}\n\n${err instanceof Error ? err.message : "Command failed"}` : (err instanceof Error ? err.message : "Command failed") }], details: undefined, isError: true };
|
|
149
118
|
}
|
|
150
119
|
}
|
|
151
120
|
|
|
152
|
-
|
|
153
|
-
return [{
|
|
154
|
-
module: "rtk",
|
|
155
|
-
label: "rtk",
|
|
156
|
-
state: findSystemRtk() ? "ok" : "missing",
|
|
157
|
-
detail: "Install RTK so bash rewrite/tagging can activate.",
|
|
158
|
-
}];
|
|
159
|
-
}
|
|
121
|
+
// ─── Module + setup ──────────────────────────────────────────────────────
|
|
160
122
|
|
|
161
|
-
|
|
123
|
+
let rtkBinary: string | null = null;
|
|
124
|
+
const rewrittenCommands = new Map<string, { originalCommand: string; timeout?: number }>();
|
|
125
|
+
const rewriteabilityCache = new Map<string, boolean>();
|
|
126
|
+
|
|
127
|
+
export const rtkModule: Module = {
|
|
128
|
+
name: "rtk",
|
|
129
|
+
hooks: {
|
|
130
|
+
tool_call: [
|
|
131
|
+
(event) => {
|
|
132
|
+
if (event.toolName !== "bash") return;
|
|
133
|
+
const command = event.input?.command;
|
|
134
|
+
if (!command || typeof command !== "string" || !command.trim()) return;
|
|
135
|
+
const rewritten = rewriteWithRtk(command, rtkBinary!);
|
|
136
|
+
rewriteabilityCache.set(command, rewritten !== null);
|
|
137
|
+
if (!rewritten) return;
|
|
138
|
+
rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input?.timeout });
|
|
139
|
+
event.input.command = rewritten;
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
tool_result: [
|
|
143
|
+
async (event, ctx) => {
|
|
144
|
+
if (event.toolName !== "bash") return;
|
|
145
|
+
const pending = rewrittenCommands.get(event.toolCallId);
|
|
146
|
+
if (!pending) return;
|
|
147
|
+
rewrittenCommands.delete(event.toolCallId);
|
|
148
|
+
if (!event.isError) return;
|
|
149
|
+
return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
session_shutdown: [
|
|
153
|
+
() => {
|
|
154
|
+
rewrittenCommands.clear();
|
|
155
|
+
rewriteabilityCache.clear();
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function setupRtk(sk: Skeleton, pi: ExtensionAPI): void {
|
|
162
162
|
rtkBinary = findSystemRtk();
|
|
163
|
+
sk.declareDependency({
|
|
164
|
+
label: "rtk",
|
|
165
|
+
check: () => findSystemRtk() !== null,
|
|
166
|
+
hint: "Install RTK so bash rewrite/tagging can activate.",
|
|
167
|
+
});
|
|
163
168
|
if (!rtkBinary) return;
|
|
164
169
|
|
|
165
|
-
|
|
166
|
-
const rewriteabilityCache = new Map<string, boolean>();
|
|
170
|
+
// Register a wrapped bash tool that shows [RTK] tag in TUI.
|
|
167
171
|
const shellSettings = loadPiShellSettings(process.cwd());
|
|
168
172
|
const bashTool = createBashToolDefinition(process.cwd(), {
|
|
169
173
|
shellPath: shellSettings.shellPath,
|
|
@@ -172,9 +176,7 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
|
|
|
172
176
|
const baseRenderCall = bashTool.renderCall?.bind(bashTool);
|
|
173
177
|
|
|
174
178
|
if (baseRenderCall) {
|
|
175
|
-
bashTool.renderCall = (args, theme, context) => {
|
|
176
|
-
// Avoid calling baseRenderCall when args.command is not yet available
|
|
177
|
-
// to prevent flashing "<invalid command>" in TUI
|
|
179
|
+
bashTool.renderCall = (args: any, theme: any, context: any) => {
|
|
178
180
|
const command = typeof args?.command === "string" ? args.command : "";
|
|
179
181
|
if (!command) {
|
|
180
182
|
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
@@ -182,7 +184,6 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
|
|
|
182
184
|
text.setText(theme.fg("toolTitle", theme.bold(`$ ${placeholder}`)));
|
|
183
185
|
return text;
|
|
184
186
|
}
|
|
185
|
-
|
|
186
187
|
const component = baseRenderCall(args, theme, context);
|
|
187
188
|
const predicted = command
|
|
188
189
|
? (rewriteabilityCache.get(command) ?? (() => {
|
|
@@ -193,36 +194,12 @@ export function setupRtkIntegration(pi: ExtensionAPI) {
|
|
|
193
194
|
: false;
|
|
194
195
|
const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
|
|
195
196
|
if (component instanceof Text) {
|
|
196
|
-
component.setText(formatBashCallWithTag(args
|
|
197
|
+
component.setText(formatBashCallWithTag(args, theme, rewritten));
|
|
197
198
|
}
|
|
198
199
|
return component;
|
|
199
200
|
};
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
pi.registerTool(bashTool);
|
|
203
|
-
|
|
204
|
-
pi.on("tool_call", (event) => {
|
|
205
|
-
if (!isToolCallEventType("bash", event)) return;
|
|
206
|
-
const command = event.input.command;
|
|
207
|
-
if (!command.trim()) return;
|
|
208
|
-
const rewritten = rewriteWithRtk(command, rtkBinary!);
|
|
209
|
-
rewriteabilityCache.set(command, rewritten !== null);
|
|
210
|
-
if (!rewritten) return;
|
|
211
|
-
rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input.timeout });
|
|
212
|
-
event.input.command = rewritten;
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
216
|
-
if (event.toolName !== "bash") return;
|
|
217
|
-
const pending = rewrittenCommands.get(event.toolCallId);
|
|
218
|
-
if (!pending) return;
|
|
219
|
-
rewrittenCommands.delete(event.toolCallId);
|
|
220
|
-
if (!event.isError) return;
|
|
221
|
-
return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
pi.on("session_shutdown", () => {
|
|
225
|
-
rewrittenCommands.clear();
|
|
226
|
-
rewriteabilityCache.clear();
|
|
227
|
-
});
|
|
204
|
+
sk.register(rtkModule);
|
|
228
205
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-title — auto-derive session name from the first user message.
|
|
3
|
+
* Skips if user already manually /renamed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { Module } from "./skeleton.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_SESSION_TITLE_LENGTH = 80;
|
|
10
|
+
|
|
11
|
+
interface SessionEntryLike {
|
|
12
|
+
type: string;
|
|
13
|
+
message?: { role: string; content?: unknown };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pickFirstUserText(content: unknown): string | undefined {
|
|
17
|
+
if (typeof content === "string" && content.trim()) return content.trim();
|
|
18
|
+
if (Array.isArray(content)) {
|
|
19
|
+
for (const part of content) {
|
|
20
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
21
|
+
return part.text.trim();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract a single-line title from the first user message. */
|
|
29
|
+
export function extractFirstMessage(entries: SessionEntryLike[]): string | undefined {
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
|
|
32
|
+
const raw = pickFirstUserText(entry.message.content);
|
|
33
|
+
if (!raw) continue;
|
|
34
|
+
const nl = raw.indexOf("\n");
|
|
35
|
+
const oneLine = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
36
|
+
if (!oneLine) continue;
|
|
37
|
+
if (oneLine.length <= MAX_SESSION_TITLE_LENGTH) return oneLine;
|
|
38
|
+
return oneLine.slice(0, MAX_SESSION_TITLE_LENGTH - 1) + "…";
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sessionTitleModule: Module = {
|
|
44
|
+
name: "session-title",
|
|
45
|
+
hooks: {
|
|
46
|
+
session_start: [
|
|
47
|
+
(_event, ctx, pi) => {
|
|
48
|
+
if (ctx.sessionManager.getSessionName()) return;
|
|
49
|
+
const title = extractFirstMessage(ctx.sessionManager.getBranch());
|
|
50
|
+
if (title) (pi as any).setSessionName(title);
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeleton — the only place that calls pi.on(...) for hooks.
|
|
3
|
+
*
|
|
4
|
+
* sk.register(module) → installs module's hook handlers
|
|
5
|
+
* sk.declareDependency({...}) → skeleton checks on session_start
|
|
6
|
+
* sk.declareGuideline("...") → skeleton injects on before_agent_start
|
|
7
|
+
* sk.install(pi) → call once, after all setup<X> calls
|
|
8
|
+
*
|
|
9
|
+
* Handlers receive (event, ctx, pi) so they can call pi.* APIs
|
|
10
|
+
* (setSessionName, registerTool, appendEntry, etc.).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
// ─── Event union ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type HookEvent =
|
|
18
|
+
| "session_start"
|
|
19
|
+
| "session_shutdown"
|
|
20
|
+
| "session_compact"
|
|
21
|
+
| "session_before_compact"
|
|
22
|
+
| "before_agent_start"
|
|
23
|
+
| "agent_start"
|
|
24
|
+
| "agent_end"
|
|
25
|
+
| "input"
|
|
26
|
+
| "tool_call"
|
|
27
|
+
| "tool_result";
|
|
28
|
+
|
|
29
|
+
// ─── Handler modes ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Parallel: all handlers run, return values ignored. */
|
|
32
|
+
export type ParallelHandler<E extends HookEvent> = (
|
|
33
|
+
event: any,
|
|
34
|
+
ctx: ExtensionContext,
|
|
35
|
+
pi: ExtensionAPI,
|
|
36
|
+
) => void | Promise<void>;
|
|
37
|
+
|
|
38
|
+
/** Compose: next handler receives previous return value. */
|
|
39
|
+
export type ComposeHandler<E extends HookEvent> = (
|
|
40
|
+
event: any,
|
|
41
|
+
ctx: ExtensionContext,
|
|
42
|
+
pi: ExtensionAPI,
|
|
43
|
+
) => any | Promise<any>;
|
|
44
|
+
|
|
45
|
+
export interface Module {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly hooks: {
|
|
48
|
+
session_start?: ParallelHandler<"session_start">[];
|
|
49
|
+
session_shutdown?: ParallelHandler<"session_shutdown">[];
|
|
50
|
+
session_compact?: ParallelHandler<"session_compact">[];
|
|
51
|
+
session_before_compact?: ParallelHandler<"session_before_compact">[];
|
|
52
|
+
before_agent_start?: ComposeHandler<"before_agent_start">[];
|
|
53
|
+
agent_start?: ParallelHandler<"agent_start">[];
|
|
54
|
+
agent_end?: ParallelHandler<"agent_end">[];
|
|
55
|
+
input?: ParallelHandler<"input">[];
|
|
56
|
+
tool_call?: ComposeHandler<"tool_call">[];
|
|
57
|
+
tool_result?: ComposeHandler<"tool_result">[];
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Declarations ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface Dependency {
|
|
64
|
+
label: string;
|
|
65
|
+
check: () => boolean;
|
|
66
|
+
hint?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Collected result shape for a module's declared dependency. Modules
|
|
70
|
+
* expose a function that returns a list of these so the UI can show
|
|
71
|
+
* "what's currently broken" for the user. The skeleton's own
|
|
72
|
+
* `session_start` handler also walks declared dependencies, but it
|
|
73
|
+
* uses `Dependency.check` directly rather than collecting statuses. */
|
|
74
|
+
export interface DependencyStatus {
|
|
75
|
+
module: string;
|
|
76
|
+
label: string;
|
|
77
|
+
state: "ok" | "missing";
|
|
78
|
+
detail: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Skeleton ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const COMPOSE_EVENTS = new Set<HookEvent>([
|
|
84
|
+
"before_agent_start",
|
|
85
|
+
"tool_call",
|
|
86
|
+
"tool_result",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export interface Skeleton {
|
|
90
|
+
register(module: Module): void;
|
|
91
|
+
declareDependency(dep: Dependency): void;
|
|
92
|
+
install(pi: ExtensionAPI): void;
|
|
93
|
+
inspect(): Inspection;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Inspection {
|
|
97
|
+
modules: string[];
|
|
98
|
+
events: Record<string, Array<{ module: string; order: number }>>;
|
|
99
|
+
dependencies: Array<{ label: string; hint?: string }>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createSkeleton(): Skeleton {
|
|
103
|
+
const modules: Module[] = [];
|
|
104
|
+
const registry = new Map<HookEvent, Array<{ module: string; handler: (event: any, ctx: ExtensionContext, pi: ExtensionAPI) => any }>>();
|
|
105
|
+
const dependencies: Dependency[] = [];
|
|
106
|
+
|
|
107
|
+
function collect(mod: Module) {
|
|
108
|
+
for (const [event, handlers] of Object.entries(mod.hooks)) {
|
|
109
|
+
if (!handlers?.length) continue;
|
|
110
|
+
const list = registry.get(event as HookEvent) ?? [];
|
|
111
|
+
for (const handler of handlers) {
|
|
112
|
+
list.push({ module: mod.name, handler: handler as any });
|
|
113
|
+
}
|
|
114
|
+
registry.set(event as HookEvent, list);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
register(mod) {
|
|
120
|
+
modules.push(mod);
|
|
121
|
+
collect(mod);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
declareDependency(dep) {
|
|
125
|
+
dependencies.push(dep);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
install(pi) {
|
|
129
|
+
// Install one pi.on per event, walking registered handlers in order.
|
|
130
|
+
for (const [event, handlers] of registry) {
|
|
131
|
+
if (handlers.length === 0) continue;
|
|
132
|
+
if (COMPOSE_EVENTS.has(event)) {
|
|
133
|
+
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
134
|
+
let current = event;
|
|
135
|
+
for (const { handler } of handlers) {
|
|
136
|
+
const result = await handler(current, ctx, pi);
|
|
137
|
+
if (result !== undefined) current = result;
|
|
138
|
+
}
|
|
139
|
+
return current === event ? undefined : current;
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
143
|
+
for (const { handler } of handlers) await handler(event, ctx, pi);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Skeleton-owned: dependency check on session_start.
|
|
149
|
+
pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
|
|
150
|
+
if (!ctx.hasUI) return;
|
|
151
|
+
const hints: string[] = [];
|
|
152
|
+
let anyMissing = false;
|
|
153
|
+
for (const dep of dependencies) {
|
|
154
|
+
let ok = false;
|
|
155
|
+
try { ok = dep.check(); } catch { ok = false; }
|
|
156
|
+
if (!ok) {
|
|
157
|
+
anyMissing = true;
|
|
158
|
+
if (dep.hint) hints.push(` [${dep.label}] ${dep.hint}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (anyMissing) {
|
|
162
|
+
ctx.ui.notify(`[decorated-pi] missing dependencies:\n${hints.join("\n")}`, "info");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Skeleton-owned: system-prompt options sort for cache stability.
|
|
167
|
+
// Guideline injection is owned by `index.ts`.
|
|
168
|
+
pi.on("before_agent_start", async (event: any) => {
|
|
169
|
+
if (event.systemPromptOptions) sortSystemPromptOptions(event.systemPromptOptions);
|
|
170
|
+
return undefined;
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
inspect() {
|
|
175
|
+
const events: Inspection["events"] = {};
|
|
176
|
+
for (const [event, handlers] of registry) {
|
|
177
|
+
events[event] = handlers.map((h, i) => ({ module: h.module, order: i }));
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
modules: modules.map((m) => m.name),
|
|
181
|
+
events,
|
|
182
|
+
dependencies: dependencies.map((d) => ({ label: d.label, hint: d.hint })),
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── System-prompt option sorting ─────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** Sort all fields in systemPromptOptions alphabetically for stable system prompt. */
|
|
191
|
+
export function sortSystemPromptOptions(opts: {
|
|
192
|
+
toolSnippets?: Record<string, string>;
|
|
193
|
+
selectedTools?: string[];
|
|
194
|
+
promptGuidelines?: string[];
|
|
195
|
+
skills?: Array<{ name: string; description: string; filePath: string }>;
|
|
196
|
+
}) {
|
|
197
|
+
const sortedToolNames = Object.keys(opts.toolSnippets ?? {}).sort((a, b) => a.localeCompare(b));
|
|
198
|
+
const sortedToolSnippets: Record<string, string> = {};
|
|
199
|
+
for (const name of sortedToolNames) {
|
|
200
|
+
sortedToolSnippets[name] = opts.toolSnippets![name];
|
|
201
|
+
}
|
|
202
|
+
opts.toolSnippets = sortedToolSnippets;
|
|
203
|
+
if (opts.selectedTools) {
|
|
204
|
+
opts.selectedTools = sortedToolNames;
|
|
205
|
+
}
|
|
206
|
+
if (opts.promptGuidelines) {
|
|
207
|
+
opts.promptGuidelines = [...opts.promptGuidelines].sort((a, b) => a.localeCompare(b));
|
|
208
|
+
}
|
|
209
|
+
if (opts.skills) {
|
|
210
|
+
opts.skills = [...opts.skills].sort((a, b) => a.name.localeCompare(b.name));
|
|
211
|
+
}
|
|
212
|
+
}
|