@matyah00/openpi 0.1.2

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.
Files changed (98) hide show
  1. package/README.md +117 -0
  2. package/agents/agent-chain.yaml +113 -0
  3. package/agents/backend.md +13 -0
  4. package/agents/basher.md +27 -0
  5. package/agents/builder.md +14 -0
  6. package/agents/code-searcher.md +27 -0
  7. package/agents/context-pruner.md +29 -0
  8. package/agents/directory-lister.md +25 -0
  9. package/agents/documenter.md +13 -0
  10. package/agents/editor.md +27 -0
  11. package/agents/file-picker.md +27 -0
  12. package/agents/frontend.md +14 -0
  13. package/agents/glob-matcher.md +25 -0
  14. package/agents/librarian.md +27 -0
  15. package/agents/loop-controller.md +41 -0
  16. package/agents/pi-pi/agent-expert.md +97 -0
  17. package/agents/pi-pi/cli-expert.md +41 -0
  18. package/agents/pi-pi/config-expert.md +63 -0
  19. package/agents/pi-pi/ext-expert.md +43 -0
  20. package/agents/pi-pi/keybinding-expert.md +134 -0
  21. package/agents/pi-pi/pi-orchestrator.md +57 -0
  22. package/agents/pi-pi/prompt-expert.md +70 -0
  23. package/agents/pi-pi/skill-expert.md +42 -0
  24. package/agents/pi-pi/theme-expert.md +40 -0
  25. package/agents/pi-pi/tui-expert.md +85 -0
  26. package/agents/plan-reviewer.md +22 -0
  27. package/agents/planner.md +14 -0
  28. package/agents/problem-architect.md +55 -0
  29. package/agents/red-team.md +13 -0
  30. package/agents/reviewer.md +14 -0
  31. package/agents/rule-verifier.md +35 -0
  32. package/agents/scout.md +14 -0
  33. package/agents/security-auditor.md +35 -0
  34. package/agents/ship-guard.md +34 -0
  35. package/agents/spec-reviewer.md +41 -0
  36. package/agents/teams.yaml +73 -0
  37. package/agents/tester.md +27 -0
  38. package/agents/thinker.md +26 -0
  39. package/agents/worker.md +27 -0
  40. package/damage-control-rules.yaml +277 -0
  41. package/extensions/agent-chain.ts +293 -0
  42. package/extensions/agent-team.ts +312 -0
  43. package/extensions/audit-tools.ts +260 -0
  44. package/extensions/commands.ts +169 -0
  45. package/extensions/damage-control-continue.ts +243 -0
  46. package/extensions/lib/packagePaths.ts +13 -0
  47. package/extensions/minimal.ts +34 -0
  48. package/extensions/openpi.ts +255 -0
  49. package/extensions/pure-focus.ts +24 -0
  50. package/extensions/purpose-gate.ts +84 -0
  51. package/extensions/search-tools.ts +277 -0
  52. package/extensions/state-tools.ts +276 -0
  53. package/extensions/system-select.ts +120 -0
  54. package/extensions/theme-cycler.ts +181 -0
  55. package/extensions/themeMap.ts +145 -0
  56. package/extensions/tool-counter-widget.ts +68 -0
  57. package/extensions/tool-counter.ts +102 -0
  58. package/extensions/workflow.ts +642 -0
  59. package/package.json +60 -0
  60. package/prompts/blueprint.md +66 -0
  61. package/prompts/clarify.md +26 -0
  62. package/prompts/compress.md +23 -0
  63. package/prompts/debate.md +23 -0
  64. package/prompts/deep.md +36 -0
  65. package/prompts/deps.md +24 -0
  66. package/prompts/explore.md +22 -0
  67. package/prompts/ghost-test.md +22 -0
  68. package/prompts/goal.md +26 -0
  69. package/prompts/parallel.md +42 -0
  70. package/prompts/plan-team.md +31 -0
  71. package/prompts/prime.md +17 -0
  72. package/prompts/review.md +23 -0
  73. package/prompts/sentinel.md +29 -0
  74. package/prompts/ship.md +30 -0
  75. package/prompts/snapshot.md +26 -0
  76. package/prompts/spec.md +58 -0
  77. package/prompts/test.md +13 -0
  78. package/prompts/validate.md +19 -0
  79. package/skills/bowser/SKILL.md +114 -0
  80. package/skills/env-scanner/SKILL.md +25 -0
  81. package/skills/security-guard/SKILL.md +24 -0
  82. package/skills/session-continuity/SKILL.md +20 -0
  83. package/skills/spec-driven/SKILL.md +25 -0
  84. package/skills/test-first/SKILL.md +23 -0
  85. package/skills/ultrathink/SKILL.md +27 -0
  86. package/themes/catppuccin-mocha.json +86 -0
  87. package/themes/cyberpunk.json +81 -0
  88. package/themes/dracula.json +81 -0
  89. package/themes/everforest.json +82 -0
  90. package/themes/gruvbox.json +80 -0
  91. package/themes/midnight-ocean.json +76 -0
  92. package/themes/nord.json +84 -0
  93. package/themes/ocean-breeze.json +83 -0
  94. package/themes/rose-pine.json +82 -0
  95. package/themes/synthwave.json +82 -0
  96. package/themes/tokyo-night.json +83 -0
  97. package/tsconfig.json +15 -0
  98. package/types/pi-shims.d.ts +102 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Minimal — Model name + context meter in a compact footer
3
+ *
4
+ * Shows model ID and a 10-block context usage bar: [###-------] 30%
5
+ *
6
+ * Usage: pi -e extensions/minimal.ts
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { applyExtensionDefaults } from "./themeMap.ts";
11
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
12
+
13
+ export default function (pi: ExtensionAPI) {
14
+ pi.on("session_start", async (_event, ctx) => {
15
+ applyExtensionDefaults(import.meta.url, ctx);
16
+ ctx.ui.setFooter((_tui, theme, _footerData) => ({
17
+ dispose: () => {},
18
+ invalidate() {},
19
+ render(width: number): string[] {
20
+ const model = ctx.model?.id || "no-model";
21
+ const usage = ctx.getContextUsage();
22
+ const pct = (usage && usage.percent !== null) ? usage.percent : 0;
23
+ const filled = Math.round(pct / 10);
24
+ const bar = "#".repeat(filled) + "-".repeat(10 - filled);
25
+
26
+ const left = theme.fg("dim", ` ${model}`);
27
+ const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
28
+ const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
29
+
30
+ return [truncateToWidth(left + pad + right, width)];
31
+ },
32
+ }));
33
+ });
34
+ }
@@ -0,0 +1,255 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { extensionDir } from "./lib/packagePaths.ts";
6
+
7
+ type Profile = {
8
+ name: string;
9
+ description: string;
10
+ extensions: string[];
11
+ notes?: string[];
12
+ };
13
+
14
+ const PROFILES: Profile[] = [
15
+ {
16
+ name: "commands",
17
+ description: "Pi-native slash commands from bundled and project .pi/prompts.",
18
+ extensions: ["commands.ts", "search-tools.ts", "audit-tools.ts", "state-tools.ts", "minimal.ts", "theme-cycler.ts"],
19
+ },
20
+ {
21
+ name: "explore",
22
+ description: "Project tree, batched code search, and discovery commands.",
23
+ extensions: ["commands.ts", "search-tools.ts", "audit-tools.ts", "state-tools.ts", "workflow.ts", "minimal.ts", "theme-cycler.ts"],
24
+ },
25
+ {
26
+ name: "guard",
27
+ description: "Security, dependency, environment, test-integrity, and ship-readiness tools.",
28
+ extensions: ["commands.ts", "audit-tools.ts", "search-tools.ts", "state-tools.ts", "workflow.ts", "minimal.ts", "theme-cycler.ts"],
29
+ },
30
+ {
31
+ name: "workflow",
32
+ description: "Pi-native /add, /fix, /review workflows with isolated role-agent delegation.",
33
+ extensions: ["workflow.ts", "search-tools.ts", "audit-tools.ts", "state-tools.ts", "minimal.ts", "theme-cycler.ts"],
34
+ notes: ["This profile owns /review; the bundled prompt-template review command is exposed as /code-review."],
35
+ },
36
+ {
37
+ name: "focus",
38
+ description: "Distraction-free Pi UI with theme defaults.",
39
+ extensions: ["pure-focus.ts", "theme-cycler.ts"],
40
+ },
41
+ {
42
+ name: "purpose",
43
+ description: "Require a session purpose and keep it visible while working.",
44
+ extensions: ["purpose-gate.ts", "minimal.ts", "theme-cycler.ts"],
45
+ },
46
+ {
47
+ name: "metrics",
48
+ description: "Show model, context, token, cost, branch, and tool usage metrics.",
49
+ extensions: ["tool-counter.ts", "theme-cycler.ts"],
50
+ },
51
+ {
52
+ name: "tool-widget",
53
+ description: "Show live per-tool usage counts above the editor.",
54
+ extensions: ["tool-counter-widget.ts", "minimal.ts", "theme-cycler.ts"],
55
+ },
56
+ {
57
+ name: "safety",
58
+ description: "Load Damage-Control rules and return actionable feedback for blocked tool calls.",
59
+ extensions: ["damage-control-continue.ts", "minimal.ts", "theme-cycler.ts"],
60
+ },
61
+ {
62
+ name: "system",
63
+ description: "Switch the active system persona from Pi-native agents.",
64
+ extensions: ["system-select.ts", "minimal.ts", "theme-cycler.ts"],
65
+ },
66
+ {
67
+ name: "team",
68
+ description: "Dispatcher-only specialist-agent team with dispatch_agent.",
69
+ extensions: ["agent-team.ts"],
70
+ },
71
+ {
72
+ name: "chain",
73
+ description: "Sequential agent workflow runner with run_chain.",
74
+ extensions: ["agent-chain.ts"],
75
+ },
76
+ {
77
+ name: "full",
78
+ description: "Commands, workflow, purpose gate, safety, metrics, personas, team dispatcher, and chains.",
79
+ extensions: ["commands.ts", "search-tools.ts", "audit-tools.ts", "state-tools.ts", "workflow.ts", "purpose-gate.ts", "damage-control-continue.ts", "tool-counter.ts", "system-select.ts", "agent-team.ts", "agent-chain.ts", "theme-cycler.ts"],
80
+ notes: ["Team and chain both alter system prompts; enable together only when you want the full orchestration surface."],
81
+ },
82
+ ];
83
+
84
+ const PROFILE_BY_NAME = new Map(PROFILES.map((profile) => [profile.name, profile]));
85
+ const MANAGED_EXTENSION_FILES = new Set(["openpi.ts", ...PROFILES.flatMap((profile) => profile.extensions)]);
86
+ const MANAGED_EXTENSION_PATHS = new Set(
87
+ Array.from(MANAGED_EXTENSION_FILES).map((file) => normalizePath(path.join(extensionDir, file))),
88
+ );
89
+
90
+ function normalizePath(value: string): string {
91
+ return path.resolve(value).replace(/\\/g, "/").toLowerCase();
92
+ }
93
+
94
+ function extensionPath(file: string): string {
95
+ return path.join(extensionDir, file);
96
+ }
97
+
98
+ function readJsonFile(filePath: string): Record<string, unknown> {
99
+ if (!fs.existsSync(filePath)) return {};
100
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
101
+ }
102
+
103
+ function writeJsonFile(filePath: string, value: Record<string, unknown>) {
104
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
105
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
106
+ }
107
+
108
+ function stringArray(value: unknown): string[] {
109
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
110
+ }
111
+
112
+ function addUnique(items: string[], additions: string[]): string[] {
113
+ const seen = new Set(items.map((item) => normalizePath(item)));
114
+ const out = [...items];
115
+ for (const item of additions) {
116
+ const key = normalizePath(item);
117
+ if (seen.has(key)) continue;
118
+ seen.add(key);
119
+ out.push(item);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function isManagedExtensionEntry(entry: string, settingsDir: string): boolean {
125
+ const absolute = path.isAbsolute(entry) ? entry : path.resolve(settingsDir, entry);
126
+ return MANAGED_EXTENSION_PATHS.has(normalizePath(absolute));
127
+ }
128
+
129
+ function resolveSettingsPath(cwd: string, global: boolean): string {
130
+ return global ? path.join(os.homedir(), ".pi", "agent", "settings.json") : path.join(cwd, ".pi", "settings.json");
131
+ }
132
+
133
+ function applyProfileToSettings(settingsPath: string, profile: Profile): { added: string[]; removed: number } {
134
+ const settingsDir = path.dirname(settingsPath);
135
+ const settings = readJsonFile(settingsPath);
136
+ const currentExtensions = stringArray(settings.extensions);
137
+ const keptExtensions = currentExtensions.filter((entry) => !isManagedExtensionEntry(entry, settingsDir));
138
+ const added = profile.extensions.map(extensionPath);
139
+
140
+ settings.extensions = addUnique(keptExtensions, added);
141
+ writeJsonFile(settingsPath, settings);
142
+
143
+ return { added, removed: currentExtensions.length - keptExtensions.length };
144
+ }
145
+
146
+ function clearProfileFromSettings(settingsPath: string): { removed: number } {
147
+ const settingsDir = path.dirname(settingsPath);
148
+ const settings = readJsonFile(settingsPath);
149
+ const currentExtensions = stringArray(settings.extensions);
150
+ settings.extensions = currentExtensions.filter((entry) => !isManagedExtensionEntry(entry, settingsDir));
151
+ writeJsonFile(settingsPath, settings);
152
+ return { removed: currentExtensions.length - stringArray(settings.extensions).length };
153
+ }
154
+
155
+ function profileListMarkdown(): string {
156
+ return [
157
+ "# openpi profiles",
158
+ "",
159
+ "Use `/openpi use <profile>` to activate a profile for this project.",
160
+ "Use `/openpi use <profile> --global` to activate it globally.",
161
+ "Run `/reload` or restart Pi after changing profiles.",
162
+ "",
163
+ ...PROFILES.map((profile) => `- **${profile.name}** - ${profile.description}`),
164
+ ].join("\n");
165
+ }
166
+
167
+ function parseArgs(args: string | undefined): string[] {
168
+ return (args || "").trim().split(/\s+/).filter(Boolean);
169
+ }
170
+
171
+ function emit(pi: ExtensionAPI, content: string) {
172
+ pi.sendMessage({ customType: "openpi", content, display: true });
173
+ }
174
+
175
+ function registerProfileCommand(pi: ExtensionAPI, name: string, description: string) {
176
+ pi.registerCommand(name, {
177
+ description,
178
+ getArgumentCompletions: (prefix) => {
179
+ const words = prefix.trim().split(/\s+/);
180
+ const first = words[0] || "";
181
+ if (!prefix.includes(" ")) {
182
+ return ["list", "use", "clear", ...PROFILES.map((p) => p.name)]
183
+ .filter((value) => value.startsWith(first))
184
+ .map((value) => ({ value, label: value }));
185
+ }
186
+ if (words[0] === "use") {
187
+ const partial = words[1] || "";
188
+ return PROFILES.filter((profile) => profile.name.startsWith(partial)).map((profile) => ({
189
+ value: profile.name,
190
+ label: profile.name,
191
+ description: profile.description,
192
+ }));
193
+ }
194
+ return null;
195
+ },
196
+ handler: async (args, ctx) => {
197
+ const tokens = parseArgs(args);
198
+ const action = tokens[0] || "list";
199
+
200
+ if (action === "list" || action === "help") {
201
+ emit(pi, profileListMarkdown());
202
+ return;
203
+ }
204
+
205
+ if (PROFILE_BY_NAME.has(action)) tokens.unshift("use");
206
+
207
+ if (tokens[0] === "use") {
208
+ const profile = PROFILE_BY_NAME.get(tokens[1] || "");
209
+ if (!profile) {
210
+ ctx.ui.notify("Usage: /openpi use <profile> [--global]", "error");
211
+ return;
212
+ }
213
+ const global = tokens.includes("--global");
214
+ const settingsPath = resolveSettingsPath(ctx.cwd, global);
215
+ const result = applyProfileToSettings(settingsPath, profile);
216
+ emit(pi, [
217
+ `Activated **${profile.name}** in ${global ? "global" : "project"} Pi settings.`,
218
+ "",
219
+ `Settings: \`${settingsPath}\``,
220
+ `Removed previous openpi profile entries: ${result.removed}`,
221
+ "Added:",
222
+ ...result.added.map((entry) => `- \`${entry}\``),
223
+ "",
224
+ "Run `/reload` or restart Pi.",
225
+ ...(profile.notes?.length ? ["", ...profile.notes.map((note) => `- ${note}`)] : []),
226
+ ].join("\n"));
227
+ return;
228
+ }
229
+
230
+ if (tokens[0] === "clear") {
231
+ const global = tokens.includes("--global");
232
+ const settingsPath = resolveSettingsPath(ctx.cwd, global);
233
+ const result = clearProfileFromSettings(settingsPath);
234
+ emit(pi, `Cleared ${result.removed} openpi extension entr${result.removed === 1 ? "y" : "ies"} from \`${settingsPath}\`. Run \`/reload\` or restart Pi.`);
235
+ return;
236
+ }
237
+
238
+ emit(pi, profileListMarkdown());
239
+ },
240
+ });
241
+ }
242
+
243
+ export default function (pi: ExtensionAPI) {
244
+ registerProfileCommand(pi, "openpi", "Manage openpi native profiles");
245
+ registerProfileCommand(pi, "azpi", "Deprecated alias for /openpi");
246
+
247
+ pi.registerCommand("open-pi", {
248
+ description: "Show openpi native package profiles",
249
+ handler: async () => emit(pi, profileListMarkdown()),
250
+ });
251
+
252
+ pi.on("session_start", async (_event, ctx) => {
253
+ if (ctx.hasUI) ctx.ui.setStatus("openpi", `openpi profiles: ${PROFILES.length}`);
254
+ });
255
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure Focus — Strip all footer and status line UI
3
+ *
4
+ * Removes the footer bar and status line entirely, leaving only
5
+ * the conversation and editor. Pure distraction-free mode.
6
+ *
7
+ * Usage: pi -e examples/extensions/pure-focus.ts
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import { applyExtensionDefaults } from "./themeMap.ts";
12
+
13
+ export default function (pi: ExtensionAPI) {
14
+ pi.on("session_start", async (_event, ctx) => {
15
+ applyExtensionDefaults(import.meta.url, ctx);
16
+ ctx.ui.setFooter((_tui, _theme, _footerData) => ({
17
+ dispose: () => {},
18
+ invalidate() {},
19
+ render(_width: number): string[] {
20
+ return [];
21
+ },
22
+ }));
23
+ });
24
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Purpose Gate — Forces the engineer to declare intent before working
3
+ *
4
+ * On session start, immediately asks "What is the purpose of this agent?"
5
+ * via a text input dialog. A persistent widget shows the purpose for the
6
+ * rest of the session, keeping focus. Blocks all prompts until answered.
7
+ *
8
+ * Usage: pi -e extensions/purpose-gate.ts
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { applyExtensionDefaults } from "./themeMap.ts";
14
+
15
+ // synthwave: bgWarm #4a1e6a → rgb(74,30,106)
16
+ function bg(s: string): string {
17
+ return `\x1b[48;2;74;30;106m${s}\x1b[49m`;
18
+ }
19
+
20
+ // synthwave: pink #ff7edb
21
+ function pink(s: string): string {
22
+ return `\x1b[38;2;255;126;219m${s}\x1b[39m`;
23
+ }
24
+
25
+ // synthwave: cyan #36f9f6
26
+ function cyan(s: string): string {
27
+ return `\x1b[38;2;54;249;246m${s}\x1b[39m`;
28
+ }
29
+
30
+ function bold(s: string): string {
31
+ return `\x1b[1m${s}\x1b[22m`;
32
+ }
33
+
34
+ export default function (pi: ExtensionAPI) {
35
+ let purpose: string | undefined;
36
+
37
+ async function askForPurpose(ctx: any) {
38
+ while (!purpose) {
39
+ const answer = await ctx.ui.input(
40
+ "What is the purpose of this agent?",
41
+ "e.g. Refactor the auth module to use JWT"
42
+ );
43
+
44
+ if (answer && answer.trim()) {
45
+ purpose = answer.trim();
46
+ } else {
47
+ ctx.ui.notify("Purpose is required.", "warning");
48
+ }
49
+ }
50
+
51
+ ctx.ui.setWidget("purpose", () => {
52
+ return {
53
+ render(width: number): string[] {
54
+ const pad = bg(" ".repeat(width));
55
+ const label = pink(bold(" PURPOSE: "));
56
+ const msg = cyan(bold(purpose!));
57
+ const content = bg(truncateToWidth(label + msg + " ".repeat(width), width, ""));
58
+ return [pad, content, pad];
59
+ },
60
+ invalidate() {},
61
+ };
62
+ });
63
+ }
64
+
65
+ pi.on("session_start", async (_event, ctx) => {
66
+ applyExtensionDefaults(import.meta.url, ctx);
67
+ void askForPurpose(ctx);
68
+ });
69
+
70
+ pi.on("before_agent_start", async (event) => {
71
+ if (!purpose) return;
72
+ return {
73
+ systemPrompt: event.systemPrompt + `\n\n<purpose>\nYour singular purpose this session: ${purpose}\nStay focused on this goal. If a request drifts from this purpose, gently remind the user.\n</purpose>`,
74
+ };
75
+ });
76
+
77
+ pi.on("input", async (_event, ctx) => {
78
+ if (!purpose) {
79
+ ctx.ui.notify("Set a purpose first.", "warning");
80
+ return { action: "handled" as const };
81
+ }
82
+ return { action: "continue" as const };
83
+ });
84
+ }
@@ -0,0 +1,277 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { Type } from "typebox";
7
+
8
+ const DEFAULT_IGNORES = new Set([
9
+ ".git",
10
+ ".hg",
11
+ ".svn",
12
+ "node_modules",
13
+ ".next",
14
+ ".nuxt",
15
+ ".turbo",
16
+ ".cache",
17
+ "dist",
18
+ "build",
19
+ "out",
20
+ "coverage",
21
+ ".venv",
22
+ "venv",
23
+ "__pycache__",
24
+ ".pytest_cache",
25
+ ".mypy_cache",
26
+ ".ruff_cache",
27
+ "target",
28
+ ]);
29
+
30
+ type TreeNode = {
31
+ name: string;
32
+ relativePath: string;
33
+ isDirectory: boolean;
34
+ children?: TreeNode[];
35
+ };
36
+
37
+ type SearchQuery = {
38
+ pattern: string;
39
+ cwd?: string;
40
+ globs?: string[];
41
+ caseInsensitive?: boolean;
42
+ before?: number;
43
+ after?: number;
44
+ maxResults?: number;
45
+ };
46
+
47
+ function isInside(parent: string, candidate: string): boolean {
48
+ const relative = path.relative(parent, candidate);
49
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
50
+ }
51
+
52
+ function resolveProjectPath(projectRoot: string, requested?: string): string {
53
+ const resolved = path.resolve(projectRoot, requested || ".");
54
+ if (!isInside(path.resolve(projectRoot), resolved)) {
55
+ throw new Error(`Path must stay inside project root: ${requested}`);
56
+ }
57
+ return resolved;
58
+ }
59
+
60
+ function normalizeRelative(projectRoot: string, value: string): string {
61
+ const relative = path.relative(projectRoot, value).replace(/\\/g, "/");
62
+ return relative || ".";
63
+ }
64
+
65
+ function readIgnoreNames(dir: string): Set<string> {
66
+ const names = new Set(DEFAULT_IGNORES);
67
+ for (const ignoreFile of [".gitignore", ".piignore"]) {
68
+ const filePath = path.join(dir, ignoreFile);
69
+ if (!fs.existsSync(filePath)) continue;
70
+ const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
71
+ for (const rawLine of lines) {
72
+ const line = rawLine.trim();
73
+ if (!line || line.startsWith("#") || line.startsWith("!") || line.includes("*")) continue;
74
+ names.add(line.replace(/\/$/, ""));
75
+ }
76
+ }
77
+ return names;
78
+ }
79
+
80
+ function buildTree(params: {
81
+ projectRoot: string;
82
+ root: string;
83
+ maxDepth: number;
84
+ maxEntries: number;
85
+ includeHidden: boolean;
86
+ }): { nodes: TreeNode[]; omitted: number; visited: number } {
87
+ const ignoreNames = readIgnoreNames(params.projectRoot);
88
+ let visited = 0;
89
+ let omitted = 0;
90
+
91
+ const walk = (dir: string, depth: number): TreeNode[] => {
92
+ if (depth > params.maxDepth || visited >= params.maxEntries) return [];
93
+ let entries: fs.Dirent[];
94
+ try {
95
+ entries = fs.readdirSync(dir, { withFileTypes: true });
96
+ } catch {
97
+ omitted++;
98
+ return [];
99
+ }
100
+
101
+ entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
102
+ const nodes: TreeNode[] = [];
103
+ for (const entry of entries) {
104
+ if (visited >= params.maxEntries) {
105
+ omitted++;
106
+ continue;
107
+ }
108
+ if (!params.includeHidden && entry.name.startsWith(".")) continue;
109
+ if (ignoreNames.has(entry.name)) continue;
110
+
111
+ const absolutePath = path.join(dir, entry.name);
112
+ const relativePath = normalizeRelative(params.projectRoot, absolutePath);
113
+ visited++;
114
+
115
+ if (entry.isDirectory()) {
116
+ nodes.push({
117
+ name: entry.name,
118
+ relativePath,
119
+ isDirectory: true,
120
+ children: walk(absolutePath, depth + 1),
121
+ });
122
+ } else {
123
+ nodes.push({ name: entry.name, relativePath, isDirectory: false });
124
+ }
125
+ }
126
+ return nodes;
127
+ };
128
+
129
+ return { nodes: walk(params.root, 0), omitted, visited };
130
+ }
131
+
132
+ function renderTree(nodes: TreeNode[], prefix = ""): string[] {
133
+ const lines: string[] = [];
134
+ nodes.forEach((node, index) => {
135
+ const isLast = index === nodes.length - 1;
136
+ const connector = isLast ? "`-- " : "|-- ";
137
+ lines.push(`${prefix}${connector}${node.name}${node.isDirectory ? "/" : ""}`);
138
+ if (node.children?.length) {
139
+ lines.push(...renderTree(node.children, `${prefix}${isLast ? " " : "| "}`));
140
+ }
141
+ });
142
+ return lines;
143
+ }
144
+
145
+ function runRipgrep(projectRoot: string, query: SearchQuery): Promise<string> {
146
+ const cwd = resolveProjectPath(projectRoot, query.cwd);
147
+ const args = ["--line-number", "--column", "--no-heading", "--color", "never"];
148
+ if (query.caseInsensitive) args.push("-i");
149
+ if (query.before) args.push("-B", String(query.before));
150
+ if (query.after) args.push("-A", String(query.after));
151
+ for (const glob of query.globs || []) args.push("-g", glob);
152
+ if (query.maxResults && query.maxResults > 0) args.push("-m", String(query.maxResults));
153
+ args.push(query.pattern, ".");
154
+
155
+ return new Promise((resolve) => {
156
+ const proc = spawn("rg", args, { cwd, shell: false });
157
+ let stdout = "";
158
+ let stderr = "";
159
+ proc.stdout.on("data", (chunk) => {
160
+ stdout += chunk.toString();
161
+ });
162
+ proc.stderr.on("data", (chunk) => {
163
+ stderr += chunk.toString();
164
+ });
165
+ proc.on("error", (error) => {
166
+ resolve(`ERROR: ${error.message}`);
167
+ });
168
+ proc.on("close", (code) => {
169
+ if (code === 0 || code === 1) resolve(stdout.trim());
170
+ else resolve(`ERROR: rg exited ${code}\n${stderr.trim()}`);
171
+ });
172
+ });
173
+ }
174
+
175
+ function truncateLines(text: string, maxLines: number): string {
176
+ const lines = text.split(/\r?\n/).filter(Boolean);
177
+ if (lines.length <= maxLines) return lines.join("\n");
178
+ return `${lines.slice(0, maxLines).join("\n")}\n[truncated ${lines.length - maxLines} lines]`;
179
+ }
180
+
181
+ export default function searchToolsExtension(pi: ExtensionAPI) {
182
+ pi.registerTool({
183
+ name: "project_tree",
184
+ label: "Project Tree",
185
+ description: "Build a compact project tree while ignoring common generated folders.",
186
+ promptSnippet: "Use project_tree before broad exploration to understand repo shape without reading many files.",
187
+ promptGuidelines: [
188
+ "Use project_tree with maxDepth 2-4 before broad file discovery.",
189
+ "Keep maxEntries bounded; follow up with code_search_batch for specific symbols or terms.",
190
+ ],
191
+ parameters: Type.Object({
192
+ root: Type.Optional(Type.String({ description: "Project-relative root directory. Defaults to current project root." })),
193
+ maxDepth: Type.Optional(Type.Number({ description: "Maximum directory depth. Default 3." })),
194
+ maxEntries: Type.Optional(Type.Number({ description: "Maximum entries to include. Default 400." })),
195
+ includeHidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories. Default false." })),
196
+ }),
197
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
198
+ const root = resolveProjectPath(ctx.cwd, params.root as string | undefined);
199
+ const maxDepth = Math.max(0, Math.min(Number(params.maxDepth ?? 3), 8));
200
+ const maxEntries = Math.max(20, Math.min(Number(params.maxEntries ?? 400), 4000));
201
+ const includeHidden = Boolean(params.includeHidden);
202
+ const result = buildTree({ projectRoot: ctx.cwd, root, maxDepth, maxEntries, includeHidden });
203
+ const header = [
204
+ `root: ${normalizeRelative(ctx.cwd, root)}`,
205
+ `depth: ${maxDepth}`,
206
+ `entries: ${result.visited}`,
207
+ result.omitted ? `omitted: ${result.omitted}` : undefined,
208
+ ]
209
+ .filter(Boolean)
210
+ .join(" | ");
211
+ const tree = renderTree(result.nodes).join("\n") || "(empty)";
212
+ return {
213
+ content: [{ type: "text", text: `${header}\n\n${tree}` }],
214
+ details: result,
215
+ };
216
+ },
217
+ renderCall(args, theme) {
218
+ return new Text(`${theme.fg("toolTitle", theme.bold("project_tree "))}${theme.fg("accent", args.root || ".")}`, 0, 0);
219
+ },
220
+ });
221
+
222
+ pi.registerTool({
223
+ name: "code_search_batch",
224
+ label: "Code Search Batch",
225
+ description: "Run multiple read-only ripgrep searches and return compact line-oriented results.",
226
+ promptSnippet: "Use code_search_batch when several symbols, routes, keys, or error strings need to be searched together.",
227
+ promptGuidelines: [
228
+ "Batch related patterns instead of making many single searches.",
229
+ "Use globs to constrain language or test files when the task is narrow.",
230
+ "Ask for file paths, line numbers, and exact matching lines in follow-up analysis.",
231
+ ],
232
+ parameters: Type.Object({
233
+ queries: Type.Array(
234
+ Type.Object({
235
+ pattern: Type.String({ description: "ripgrep pattern to search for." }),
236
+ cwd: Type.Optional(Type.String({ description: "Project-relative directory to search in." })),
237
+ globs: Type.Optional(Type.Array(Type.String(), { description: "Optional ripgrep -g patterns." })),
238
+ caseInsensitive: Type.Optional(Type.Boolean({ description: "Use case-insensitive search." })),
239
+ before: Type.Optional(Type.Number({ description: "Context lines before each match." })),
240
+ after: Type.Optional(Type.Number({ description: "Context lines after each match." })),
241
+ maxResults: Type.Optional(Type.Number({ description: "Maximum matches per file from rg -m." })),
242
+ }),
243
+ ),
244
+ maxLinesPerQuery: Type.Optional(Type.Number({ description: "Maximum output lines per query. Default 80." })),
245
+ }),
246
+ async execute(_toolCallId, params, _signal, onUpdate, ctx) {
247
+ const queries = (params.queries || []) as SearchQuery[];
248
+ if (!queries.length) throw new Error("code_search_batch requires at least one query");
249
+ if (queries.length > 12) throw new Error("code_search_batch supports at most 12 queries");
250
+ const maxLinesPerQuery = Math.max(10, Math.min(Number(params.maxLinesPerQuery ?? 80), 300));
251
+
252
+ const sections: string[] = [];
253
+ for (let i = 0; i < queries.length; i++) {
254
+ const query = queries[i];
255
+ if (!query.pattern?.trim()) throw new Error(`query ${i + 1} is missing pattern`);
256
+ onUpdate?.({ content: [{ type: "text", text: `code_search_batch: ${i + 1}/${queries.length} ${query.pattern}` }] });
257
+ const output = await runRipgrep(ctx.cwd, query);
258
+ sections.push([
259
+ `## ${i + 1}. ${query.pattern}`,
260
+ query.cwd ? `cwd: ${query.cwd}` : undefined,
261
+ query.globs?.length ? `globs: ${query.globs.join(", ")}` : undefined,
262
+ "",
263
+ output ? truncateLines(output, maxLinesPerQuery) : "(no matches)",
264
+ ].filter((part) => part !== undefined).join("\n"));
265
+ }
266
+
267
+ return {
268
+ content: [{ type: "text", text: sections.join("\n\n---\n\n") }],
269
+ details: { queryCount: queries.length },
270
+ };
271
+ },
272
+ renderCall(args, theme) {
273
+ const count = Array.isArray(args.queries) ? args.queries.length : 0;
274
+ return new Text(`${theme.fg("toolTitle", theme.bold("code_search_batch "))}${theme.fg("accent", `${count} queries`)}`, 0, 0);
275
+ },
276
+ });
277
+ }