refactor pi resource wiring by directory
This commit is contained in:
parent
d1bbc5bf6f
commit
b465e0aed0
19 changed files with 2087 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ let
|
|||
mkOption
|
||||
types
|
||||
mkIf
|
||||
mkMerge
|
||||
escapeShellArg
|
||||
;
|
||||
cfg = config.rsydn.aiTools;
|
||||
|
|
@ -172,6 +173,78 @@ in
|
|||
description = "Configuration for the Claude wrapper that targets the Kimi Code API.";
|
||||
};
|
||||
|
||||
piExtensions = mkOption {
|
||||
type = types.submodule {
|
||||
options = {
|
||||
enable = mkEnableOption "Install the global pi extensions directory.";
|
||||
source = mkOption {
|
||||
type = types.path;
|
||||
default = ../../../pi/extensions;
|
||||
description = "Source directory for the global pi extensions tree.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
enable = true;
|
||||
source = ../../../pi/extensions;
|
||||
};
|
||||
description = "Configuration for the global pi extensions directory deployment.";
|
||||
};
|
||||
|
||||
piSkills = mkOption {
|
||||
type = types.submodule {
|
||||
options = {
|
||||
enable = mkEnableOption "Install the global pi skills directory.";
|
||||
source = mkOption {
|
||||
type = types.path;
|
||||
default = ../../../pi/skills;
|
||||
description = "Source directory for the global pi skills tree.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
enable = true;
|
||||
source = ../../../pi/skills;
|
||||
};
|
||||
description = "Configuration for the global pi skills directory deployment.";
|
||||
};
|
||||
|
||||
piPrompts = mkOption {
|
||||
type = types.submodule {
|
||||
options = {
|
||||
enable = mkEnableOption "Install the global pi prompt templates directory.";
|
||||
source = mkOption {
|
||||
type = types.path;
|
||||
default = ../../../pi/prompts;
|
||||
description = "Source directory for the global pi prompt templates tree.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
enable = true;
|
||||
source = ../../../pi/prompts;
|
||||
};
|
||||
description = "Configuration for the global pi prompt templates directory deployment.";
|
||||
};
|
||||
|
||||
piThemes = mkOption {
|
||||
type = types.submodule {
|
||||
options = {
|
||||
enable = mkEnableOption "Install the global pi themes directory.";
|
||||
source = mkOption {
|
||||
type = types.path;
|
||||
default = ../../../pi/themes;
|
||||
description = "Source directory for the global pi themes tree.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
enable = true;
|
||||
source = ../../../pi/themes;
|
||||
};
|
||||
description = "Configuration for the global pi themes directory deployment.";
|
||||
};
|
||||
|
||||
extraPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
|
|
@ -192,5 +265,32 @@ in
|
|||
++ zaiWrapperPackages
|
||||
++ kimiWrapperPackages
|
||||
++ cfg.extraPackages;
|
||||
|
||||
home.file = mkMerge [
|
||||
(mkIf cfg.piExtensions.enable {
|
||||
".pi/agent/extensions" = {
|
||||
source = cfg.piExtensions.source;
|
||||
recursive = true;
|
||||
};
|
||||
})
|
||||
(mkIf cfg.piSkills.enable {
|
||||
".pi/agent/skills" = {
|
||||
source = cfg.piSkills.source;
|
||||
recursive = true;
|
||||
};
|
||||
})
|
||||
(mkIf cfg.piPrompts.enable {
|
||||
".pi/agent/prompts" = {
|
||||
source = cfg.piPrompts.source;
|
||||
recursive = true;
|
||||
};
|
||||
})
|
||||
(mkIf cfg.piThemes.enable {
|
||||
".pi/agent/themes" = {
|
||||
source = cfg.piThemes.source;
|
||||
recursive = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
55
pi/extensions/exa-tools/README.md
Normal file
55
pi/extensions/exa-tools/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# pi exa-tools scaffold
|
||||
|
||||
Global pi extension that adds Exa-backed retrieval tools.
|
||||
|
||||
## Tools
|
||||
|
||||
- `exa_search` — general web/docs/reference search
|
||||
- `exa_code` — coding context and open-source implementation examples
|
||||
|
||||
## Mental model
|
||||
|
||||
- use `exa_search` for official docs, release notes, blog posts, issues, comparisons, and general web research
|
||||
- use `exa_code` for library usage, framework patterns, API examples, and code-specific prior art
|
||||
|
||||
## Credentials
|
||||
|
||||
The extension looks for credentials in this order:
|
||||
|
||||
1. `EXA_API_KEY`
|
||||
2. `~/.config/secrets/exa-api-key`
|
||||
3. `~/.config/secrets/exa_api_key`
|
||||
|
||||
If none are found, the tools throw a helpful error.
|
||||
|
||||
Note: the Exa account also needs available credits. A valid key with no remaining credits will still fail at request time.
|
||||
|
||||
## Command
|
||||
|
||||
- `/exa-tools` — show credential status and available tools
|
||||
|
||||
## Installation
|
||||
|
||||
This repo deploys the extension declaratively through Home Manager to:
|
||||
|
||||
```text
|
||||
~/.pi/agent/extensions/exa-tools
|
||||
```
|
||||
|
||||
## Example usage
|
||||
|
||||
General web research:
|
||||
|
||||
```text
|
||||
Use exa_search to find the official docs for Exa context search
|
||||
```
|
||||
|
||||
Coding context:
|
||||
|
||||
```text
|
||||
Use exa_code to find examples of React hook state management patterns
|
||||
```
|
||||
|
||||
Subagent usage:
|
||||
|
||||
The `librarian` subagent can use these tools once the extension is globally installed.
|
||||
354
pi/extensions/exa-tools/index.ts
Normal file
354
pi/extensions/exa-tools/index.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type ExtensionAPI,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
truncateHead,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const EXA_API_BASE = "https://api.exa.ai";
|
||||
const DEFAULT_SEARCH_RESULTS = 5;
|
||||
const MAX_SEARCH_RESULTS = 10;
|
||||
const DEFAULT_HIGHLIGHT_CHARS = 1200;
|
||||
const DEFAULT_CODE_TOKENS = 5000;
|
||||
const MAX_CODE_TOKENS = 12000;
|
||||
|
||||
function clip(text: string, max = 400): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
return normalized.length > max ? `${normalized.slice(0, max)}…` : normalized;
|
||||
}
|
||||
|
||||
function maybeString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function readSecretFile(secretPath: string): string | undefined {
|
||||
try {
|
||||
if (!fs.existsSync(secretPath)) return undefined;
|
||||
const value = fs.readFileSync(secretPath, "utf8").trim();
|
||||
return value || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExaApiKey(): { key?: string; source: string } {
|
||||
const envKey = process.env.EXA_API_KEY?.trim();
|
||||
if (envKey) return { key: envKey, source: "EXA_API_KEY" };
|
||||
|
||||
const secretCandidates = [
|
||||
path.join(os.homedir(), ".config", "secrets", "exa-api-key"),
|
||||
path.join(os.homedir(), ".config", "secrets", "exa_api_key"),
|
||||
];
|
||||
|
||||
for (const secretPath of secretCandidates) {
|
||||
const value = readSecretFile(secretPath);
|
||||
if (value) return { key: value, source: secretPath };
|
||||
}
|
||||
|
||||
return { source: "missing" };
|
||||
}
|
||||
|
||||
async function postExa(pathname: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<any> {
|
||||
const auth = resolveExaApiKey();
|
||||
if (!auth.key) {
|
||||
throw new Error(
|
||||
"Exa API key not found. Set EXA_API_KEY or create ~/.config/secrets/exa-api-key.",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${EXA_API_BASE}${pathname}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": auth.key,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
data = { raw };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = typeof data === "object" && data && maybeString(data.error) ? data.error : clip(raw, 600);
|
||||
throw new Error(`Exa request failed (${response.status} ${response.statusText}): ${detail}`);
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data && maybeString(data.error)) {
|
||||
const tag = maybeString(data.tag);
|
||||
throw new Error(`Exa error${tag ? ` [${tag}]` : ""}: ${data.error}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function writeTempOutput(prefix: string, content: string): Promise<string> {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-exa-tools-"));
|
||||
const filePath = path.join(dir, `${prefix}.txt`);
|
||||
await fs.promises.writeFile(filePath, content, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function finalizeText(prefix: string, fullText: string): Promise<{ text: string; fullOutputPath?: string }> {
|
||||
const truncation = truncateHead(fullText, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
if (!truncation.truncated) {
|
||||
return { text: truncation.content };
|
||||
}
|
||||
|
||||
const fullOutputPath = await writeTempOutput(prefix, fullText);
|
||||
const notice = [
|
||||
"",
|
||||
`[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`,
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
text: `${truncation.content}${notice}`,
|
||||
fullOutputPath,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSearchResult(result: any, index: number, includeText: boolean): string {
|
||||
const title = maybeString(result?.title) ?? maybeString(result?.url) ?? `Result ${index + 1}`;
|
||||
const lines = [`${index + 1}. ${title}`];
|
||||
|
||||
const url = maybeString(result?.url);
|
||||
if (url) lines.push(` URL: ${url}`);
|
||||
|
||||
const published = maybeString(result?.publishedDate) ?? maybeString(result?.published_date);
|
||||
if (published) lines.push(` Published: ${published}`);
|
||||
|
||||
const author = maybeString(result?.author);
|
||||
if (author) lines.push(` Author: ${author}`);
|
||||
|
||||
const summary = maybeString(result?.summary);
|
||||
if (summary) lines.push(` Summary: ${clip(summary, 500)}`);
|
||||
|
||||
const highlights = stringArray(result?.highlights);
|
||||
if (highlights.length > 0) {
|
||||
lines.push(" Highlights:");
|
||||
for (const highlight of highlights.slice(0, 3)) {
|
||||
lines.push(` - ${clip(highlight, 350)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeText) {
|
||||
const text = maybeString(result?.text);
|
||||
if (text) lines.push(` Text: ${clip(text, 700)}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function buildSearchOutput(query: string, payload: Record<string, unknown>, data: any): Promise<{ text: string; fullOutputPath?: string }> {
|
||||
const results = Array.isArray(data?.results) ? data.results : [];
|
||||
const includeText = Boolean((payload.contents as any)?.text);
|
||||
const lines: string[] = [
|
||||
`Exa search results for: ${query}`,
|
||||
`Results returned: ${results.length}`,
|
||||
];
|
||||
|
||||
const requestId = maybeString(data?.requestId);
|
||||
if (requestId) lines.push(`Request ID: ${requestId}`);
|
||||
|
||||
const costTotal = data?.costDollars?.total;
|
||||
if (typeof costTotal === "number") lines.push(`Cost: $${costTotal}`);
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (results.length === 0) {
|
||||
lines.push("No results returned.");
|
||||
} else {
|
||||
for (const [index, result] of results.entries()) {
|
||||
lines.push(formatSearchResult(result, index, includeText));
|
||||
if (index < results.length - 1) lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeText("exa-search", lines.join("\n"));
|
||||
}
|
||||
|
||||
async function buildCodeOutput(query: string, data: any): Promise<{ text: string; fullOutputPath?: string }> {
|
||||
const context =
|
||||
maybeString(data?.context) ??
|
||||
maybeString(data?.text) ??
|
||||
maybeString(data?.output) ??
|
||||
maybeString(data?.result);
|
||||
|
||||
const lines: string[] = [`Exa code context for: ${query}`];
|
||||
|
||||
const requestId = maybeString(data?.requestId);
|
||||
if (requestId) lines.push(`Request ID: ${requestId}`);
|
||||
|
||||
if (typeof data?.resultsCount === "number") lines.push(`Results count: ${data.resultsCount}`);
|
||||
if (typeof data?.outputTokens === "number") lines.push(`Output tokens: ${data.outputTokens}`);
|
||||
if (typeof data?.searchTime === "number") lines.push(`Search time: ${Math.round(data.searchTime)}ms`);
|
||||
if (typeof data?.costDollars?.total === "number") lines.push(`Cost: $${data.costDollars.total}`);
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (context) {
|
||||
lines.push(context.trim());
|
||||
} else if (Array.isArray(data?.results)) {
|
||||
lines.push("No combined context field returned. Raw result excerpts:");
|
||||
lines.push("");
|
||||
for (const [index, result] of data.results.entries()) {
|
||||
const title = maybeString(result?.title) ?? maybeString(result?.url) ?? `Result ${index + 1}`;
|
||||
lines.push(`${index + 1}. ${title}`);
|
||||
const url = maybeString(result?.url);
|
||||
if (url) lines.push(` URL: ${url}`);
|
||||
const text = maybeString(result?.text) ?? maybeString(result?.snippet);
|
||||
if (text) lines.push(` ${clip(text, 900)}`);
|
||||
lines.push("");
|
||||
}
|
||||
} else {
|
||||
lines.push("No code context returned.");
|
||||
}
|
||||
|
||||
return finalizeText("exa-code", lines.join("\n"));
|
||||
}
|
||||
|
||||
const SearchType = StringEnum(["auto", "neural", "keyword", "deep", "deep-lite", "deep-reasoning"] as const);
|
||||
|
||||
const ExaSearchParams = Type.Object({
|
||||
query: Type.String({ description: "What to search for on the web" }),
|
||||
type: Type.Optional(SearchType),
|
||||
numResults: Type.Optional(Type.Integer({ description: `Number of results to return (max ${MAX_SEARCH_RESULTS})`, default: DEFAULT_SEARCH_RESULTS })),
|
||||
category: Type.Optional(Type.String({ description: "Optional Exa category, e.g. research paper, company, news" })),
|
||||
includeDomains: Type.Optional(Type.Array(Type.String(), { description: "Restrict results to these domains" })),
|
||||
excludeDomains: Type.Optional(Type.Array(Type.String(), { description: "Exclude these domains" })),
|
||||
includeText: Type.Optional(Type.Boolean({ description: "Include raw page text excerpts", default: false })),
|
||||
includeSummary: Type.Optional(Type.Boolean({ description: "Ask Exa for result summaries", default: true })),
|
||||
summaryQuery: Type.Optional(Type.String({ description: "Optional summary focus prompt" })),
|
||||
includeHighlights: Type.Optional(Type.Boolean({ description: "Ask Exa for result highlights", default: true })),
|
||||
highlightMaxCharacters: Type.Optional(Type.Integer({ description: "Maximum characters of highlights to request", default: DEFAULT_HIGHLIGHT_CHARS })),
|
||||
});
|
||||
|
||||
const ExaCodeParams = Type.Object({
|
||||
query: Type.String({ description: "Coding question, library usage pattern, or implementation topic to retrieve code context for" }),
|
||||
tokensNum: Type.Optional(Type.Integer({ description: `Approximate token budget for returned context (max ${MAX_CODE_TOKENS})`, default: DEFAULT_CODE_TOKENS })),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("exa-tools", {
|
||||
description: "Show Exa tools status and credential source",
|
||||
handler: async (_args, ctx) => {
|
||||
const auth = resolveExaApiKey();
|
||||
const lines = [
|
||||
"Exa tools status",
|
||||
`API key: ${auth.key ? "configured" : "missing"}`,
|
||||
`Source: ${auth.source}`,
|
||||
"",
|
||||
"Available tools:",
|
||||
"- exa_search -> general web/docs/reference retrieval",
|
||||
"- exa_code -> coding examples and library usage context",
|
||||
];
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setEditorText(lines.join("\n"));
|
||||
ctx.ui.notify(`Exa tools ${auth.key ? "ready" : "missing API key"}`, auth.key ? "info" : "warning");
|
||||
} else {
|
||||
console.log(lines.join("\n"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "exa_search",
|
||||
label: "Exa Search",
|
||||
description: "Search the web with Exa for official docs, articles, release notes, issues, and general references. Requires EXA_API_KEY.",
|
||||
promptSnippet: "Search the web for docs, references, release notes, articles, and current external information.",
|
||||
promptGuidelines: [
|
||||
"Use this for external web knowledge, not for files already in the current repository.",
|
||||
"Prefer this tool when the user asks for official docs, comparisons, release notes, or broader web research.",
|
||||
],
|
||||
parameters: ExaSearchParams,
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const numResults = Math.max(1, Math.min(params.numResults ?? DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS));
|
||||
const payload: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
type: params.type ?? "auto",
|
||||
numResults,
|
||||
};
|
||||
|
||||
if (params.category) payload.category = params.category;
|
||||
if (params.includeDomains && params.includeDomains.length > 0) payload.includeDomains = params.includeDomains;
|
||||
if (params.excludeDomains && params.excludeDomains.length > 0) payload.excludeDomains = params.excludeDomains;
|
||||
|
||||
const contents: Record<string, unknown> = {};
|
||||
if (params.includeText ?? false) contents.text = true;
|
||||
if (params.includeSummary ?? true) contents.summary = { query: params.summaryQuery ?? params.query };
|
||||
if (params.includeHighlights ?? true) {
|
||||
contents.highlights = { maxCharacters: params.highlightMaxCharacters ?? DEFAULT_HIGHLIGHT_CHARS };
|
||||
}
|
||||
if (Object.keys(contents).length > 0) payload.contents = contents;
|
||||
|
||||
const data = await postExa("/search", payload, signal);
|
||||
const rendered = await buildSearchOutput(params.query, payload, data);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: rendered.text }],
|
||||
details: {
|
||||
endpoint: "/search",
|
||||
query: params.query,
|
||||
requestId: data?.requestId,
|
||||
resultsCount: Array.isArray(data?.results) ? data.results.length : undefined,
|
||||
fullOutputPath: rendered.fullOutputPath,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "exa_code",
|
||||
label: "Exa Code",
|
||||
description: "Retrieve coding-specific context and open-source implementation examples with Exa Code. Requires EXA_API_KEY.",
|
||||
promptSnippet: "Find coding examples, library usage patterns, and implementation context from public code sources.",
|
||||
promptGuidelines: [
|
||||
"Use this when the user wants examples of how libraries, frameworks, or APIs are used in code.",
|
||||
"Prefer this over general web search when the task asks for implementation patterns or open-source examples.",
|
||||
],
|
||||
parameters: ExaCodeParams,
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const tokensNum = Math.max(500, Math.min(params.tokensNum ?? DEFAULT_CODE_TOKENS, MAX_CODE_TOKENS));
|
||||
const payload = {
|
||||
query: params.query,
|
||||
tokensNum,
|
||||
};
|
||||
|
||||
const data = await postExa("/context", payload, signal);
|
||||
const rendered = await buildCodeOutput(params.query, data);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: rendered.text }],
|
||||
details: {
|
||||
endpoint: "/context",
|
||||
query: params.query,
|
||||
requestId: data?.requestId,
|
||||
resultsCount: data?.resultsCount,
|
||||
outputTokens: data?.outputTokens,
|
||||
fullOutputPath: rendered.fullOutputPath,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
131
pi/extensions/subagent/README.md
Normal file
131
pi/extensions/subagent/README.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# pi subagent scaffold
|
||||
|
||||
Foundation for a global pi subagent extension with parallel and chained delegation.
|
||||
|
||||
## What this includes
|
||||
|
||||
- `index.ts` — main `subagent` tool and `/subagents` command
|
||||
- `agents.ts` — agent discovery and manifest parsing
|
||||
- `agents/` — starter bundled agents:
|
||||
- `scout` → `zai/glm-5.1`
|
||||
- `planner` → `openai-codex/gpt-5.4:xhigh`
|
||||
- `implementer` → `openai-codex/gpt-5.3-codex`
|
||||
- `reviewer` → `openai-codex/gpt-5.4:xhigh`
|
||||
- `librarian` → `zai/glm-5.1` + `exa_search` / `exa_code`
|
||||
- `prompts/` — starter prompt templates:
|
||||
- `/implement`
|
||||
- `/scout-and-plan`
|
||||
- `/implement-and-review`
|
||||
- `/parallel-scout`
|
||||
- `/research`
|
||||
|
||||
## Discovery model
|
||||
|
||||
This extension supports three agent sources:
|
||||
|
||||
- **bundled** — agents shipped in this directory (`./agents`)
|
||||
- **user** — `~/.pi/agent/agents`
|
||||
- **project** — nearest `.pi/agents` from the current working directory upward
|
||||
|
||||
Scope values:
|
||||
|
||||
- `global` — bundled + user
|
||||
- `project` — project only
|
||||
- `both` — bundled + user + project
|
||||
|
||||
Precedence when names collide:
|
||||
|
||||
1. bundled
|
||||
2. user
|
||||
3. project
|
||||
|
||||
So project-local agents override user/global ones, and user/global agents override bundled defaults.
|
||||
|
||||
## Parallel safety
|
||||
|
||||
Parallel mode is intentionally conservative.
|
||||
|
||||
- agents with `parallelSafe: true` can run in parallel
|
||||
- agents with `parallelSafe: false` are blocked in parallel mode
|
||||
|
||||
The bundled `implementer` is marked serial-only.
|
||||
|
||||
## Agent manifest format
|
||||
|
||||
Each agent is a markdown file with YAML frontmatter:
|
||||
|
||||
```md
|
||||
---
|
||||
name: planner
|
||||
description: Creates implementation plans
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
tools: read, grep, find, ls
|
||||
parallelSafe: true
|
||||
role: planner
|
||||
tags: planning, design
|
||||
---
|
||||
|
||||
System prompt goes here.
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `model` accepts normal pi `--model` values, including `provider/id` and optional `:thinking`
|
||||
- the current scaffold intentionally uses built-in pi providers (`openai-codex`, `zai`), so no custom provider extension is required yet
|
||||
- `tools` should usually be explicit to avoid recursive delegation
|
||||
- `parallelSafe` defaults to `false` if omitted
|
||||
|
||||
## Install globally from this repo
|
||||
|
||||
Recommended runtime target:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.pi/agent/extensions
|
||||
ln -sfn /Users/rasyidanakbar/Development/dotfiles/pi/extensions/subagent ~/.pi/agent/extensions/subagent
|
||||
```
|
||||
|
||||
Then start pi and run:
|
||||
|
||||
```text
|
||||
/reload
|
||||
```
|
||||
|
||||
Because prompts and bundled agents live inside the extension directory, a single directory symlink is enough.
|
||||
|
||||
## Usage examples
|
||||
|
||||
Single agent:
|
||||
|
||||
```text
|
||||
Use subagent planner to propose a plan for refactoring auth
|
||||
```
|
||||
|
||||
Parallel:
|
||||
|
||||
```text
|
||||
Use subagent with two tasks in parallel: scout auth flow, librarian find similar patterns
|
||||
```
|
||||
|
||||
Chain:
|
||||
|
||||
```text
|
||||
Use subagent chain: scout -> planner -> implementer
|
||||
```
|
||||
|
||||
Prompt templates:
|
||||
|
||||
```text
|
||||
/implement add request validation to the API
|
||||
/scout-and-plan refactor auth to support oauth
|
||||
/implement-and-review add pagination to the endpoint
|
||||
/parallel-scout investigate session handling
|
||||
/research evaluate the best pattern for background job retries in this codebase
|
||||
```
|
||||
|
||||
## Good next steps
|
||||
|
||||
1. add `parallel_then_reduce`
|
||||
2. add budget / timeout controls
|
||||
3. add a custom `index_search` tool for librarian workflows
|
||||
4. wire the whole `pi/extensions/` directory into Home Manager so `~/.pi/agent/extensions/` is managed automatically
|
||||
5. pair librarian with the separate `pi/extensions/exa-tools` extension for web/docs/code retrieval
|
||||
186
pi/extensions/subagent/agents.ts
Normal file
186
pi/extensions/subagent/agents.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export type AgentScope = "global" | "project" | "both";
|
||||
export type AgentSource = "bundled" | "user" | "project" | "unknown";
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
model?: string;
|
||||
systemPrompt: string;
|
||||
source: AgentSource;
|
||||
filePath: string;
|
||||
parallelSafe: boolean;
|
||||
role?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface AgentDiscoveryResult {
|
||||
agents: AgentConfig[];
|
||||
bundledAgentsDir: string;
|
||||
userAgentsDir: string;
|
||||
projectAgentsDir: string | null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value !== "string") return undefined;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "yes", "on", "1"].includes(normalized)) return true;
|
||||
if (["false", "no", "off", "0"].includes(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStringList(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const items = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDirectory(pathname: string): boolean {
|
||||
try {
|
||||
return fs.statSync(pathname).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledAgentsDir(): string {
|
||||
return fileURLToPath(new URL("./agents", import.meta.url));
|
||||
}
|
||||
|
||||
function getUserAgentsDir(): string {
|
||||
return path.join(getAgentDir(), "agents");
|
||||
}
|
||||
|
||||
function findNearestProjectAgentsDir(cwd: string): string | null {
|
||||
let currentDir = cwd;
|
||||
while (true) {
|
||||
const candidate = path.join(currentDir, ".pi", "agents");
|
||||
if (isDirectory(candidate)) return candidate;
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) return null;
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(dir: string, source: Exclude<AgentSource, "unknown">): AgentConfig[] {
|
||||
const agents: AgentConfig[] = [];
|
||||
|
||||
if (!isDirectory(dir)) return agents;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return agents;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
|
||||
const filePath = path.join(dir, entry.name);
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const name = readString(frontmatter.name);
|
||||
const description = readString(frontmatter.description);
|
||||
|
||||
if (!name || !description) continue;
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
description,
|
||||
tools: readStringList(frontmatter.tools),
|
||||
model: readString(frontmatter.model),
|
||||
systemPrompt: body.trim(),
|
||||
source,
|
||||
filePath,
|
||||
parallelSafe: readBoolean(frontmatter.parallelSafe) ?? false,
|
||||
role: readString(frontmatter.role),
|
||||
tags: readStringList(frontmatter.tags),
|
||||
});
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
||||
const bundledAgentsDir = getBundledAgentsDir();
|
||||
const userAgentsDir = getUserAgentsDir();
|
||||
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
||||
|
||||
const bundledAgents = loadAgentsFromDir(bundledAgentsDir, "bundled");
|
||||
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userAgentsDir, "user");
|
||||
const projectAgents = scope === "global" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
||||
|
||||
const agentMap = new Map<string, AgentConfig>();
|
||||
|
||||
if (scope === "global") {
|
||||
for (const agent of bundledAgents) agentMap.set(agent.name, agent);
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
}
|
||||
|
||||
if (scope === "project") {
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
}
|
||||
|
||||
if (scope === "both") {
|
||||
for (const agent of bundledAgents) agentMap.set(agent.name, agent);
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
}
|
||||
|
||||
return {
|
||||
agents: Array.from(agentMap.values()),
|
||||
bundledAgentsDir,
|
||||
userAgentsDir,
|
||||
projectAgentsDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
||||
if (agents.length === 0) return { text: "none", remaining: 0 };
|
||||
|
||||
const listed = agents.slice(0, maxItems);
|
||||
const remaining = Math.max(0, agents.length - listed.length);
|
||||
const text = listed
|
||||
.map((agent) => {
|
||||
const mode = agent.parallelSafe ? "parallel" : "serial";
|
||||
const role = agent.role ? ` ${agent.role}` : "";
|
||||
return `${agent.name} (${agent.source}, ${mode}${role ? `, ${role}` : ""})`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return { text, remaining };
|
||||
}
|
||||
34
pi/extensions/subagent/agents/implementer.md
Normal file
34
pi/extensions/subagent/agents/implementer.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
name: implementer
|
||||
description: Executes approved plans and makes code changes carefully
|
||||
tools: read, grep, find, ls, bash, edit, write
|
||||
model: openai-codex/gpt-5.3-codex
|
||||
parallelSafe: false
|
||||
role: implementer
|
||||
tags: implementation, coding, execution
|
||||
---
|
||||
|
||||
You are an implementation specialist operating in an isolated subagent context.
|
||||
|
||||
Your job is to make the requested change safely and completely.
|
||||
|
||||
Rules:
|
||||
- Follow the provided plan if one exists.
|
||||
- Prefer minimal, targeted edits.
|
||||
- Do not make unrelated refactors.
|
||||
- When using bash, keep commands focused on verification and repository-aware workflows.
|
||||
- If the task is ambiguous, make the smallest reasonable assumption and document it.
|
||||
|
||||
Output format:
|
||||
|
||||
## Completed
|
||||
What you changed.
|
||||
|
||||
## Files Changed
|
||||
- `path/to/file.ts` - summary of change
|
||||
|
||||
## Validation
|
||||
Commands run, checks performed, or why validation was not run.
|
||||
|
||||
## Notes
|
||||
Any assumptions, follow-up items, or risks for a reviewer.
|
||||
39
pi/extensions/subagent/agents/librarian.md
Normal file
39
pi/extensions/subagent/agents/librarian.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
name: librarian
|
||||
description: Retrieves references, patterns, prior art, and supporting context
|
||||
tools: read, grep, find, ls, exa_search, exa_code
|
||||
model: zai/glm-5.1
|
||||
parallelSafe: true
|
||||
role: librarian
|
||||
tags: search, references, indexing, docs
|
||||
---
|
||||
|
||||
You are a librarian.
|
||||
|
||||
Your job is to gather supporting references from the repository so other agents can reason faster.
|
||||
|
||||
Focus on:
|
||||
- similar implementations elsewhere in the repo
|
||||
- naming conventions and file patterns
|
||||
- related tests, docs, or configs
|
||||
- existing abstractions worth reusing
|
||||
- official docs, external references, and code examples when local context is not enough
|
||||
|
||||
Tool preference:
|
||||
- use `exa_search` for official docs, web references, release notes, or broader external research
|
||||
- use `exa_code` for library usage patterns, OSS examples, and coding-specific prior art
|
||||
- use local repo tools first when the answer is already likely in the current codebase
|
||||
|
||||
Output format:
|
||||
|
||||
## Relevant References
|
||||
- `path/to/file.ts` - why it is relevant
|
||||
|
||||
## Reusable Patterns
|
||||
Summarize useful conventions or implementation patterns.
|
||||
|
||||
## Related Tests / Docs
|
||||
Point to tests, fixtures, docs, or configs that should be consulted.
|
||||
|
||||
## Recommendation
|
||||
What other agent should do with these references.
|
||||
36
pi/extensions/subagent/agents/planner.md
Normal file
36
pi/extensions/subagent/agents/planner.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: planner
|
||||
description: Turns requirements and findings into a concrete execution plan
|
||||
tools: read, grep, find, ls
|
||||
model: openai-codex/gpt-5.4:xhigh
|
||||
parallelSafe: true
|
||||
role: planner
|
||||
tags: planning, design, execution
|
||||
---
|
||||
|
||||
You are a planning specialist.
|
||||
|
||||
You receive requirements, codebase findings, or both. Produce a concrete implementation plan that a separate implementation agent can follow.
|
||||
|
||||
Rules:
|
||||
- Do not modify code.
|
||||
- Do not invent files or architecture without stating that they are proposals.
|
||||
- Keep steps small, ordered, and testable.
|
||||
- Prefer minimal, low-risk changes over broad rewrites.
|
||||
|
||||
Output format:
|
||||
|
||||
## Goal
|
||||
One concise sentence.
|
||||
|
||||
## Plan
|
||||
Numbered, execution-ready steps.
|
||||
|
||||
## Files to Touch
|
||||
List likely files and the intended change in each.
|
||||
|
||||
## Validation
|
||||
List the checks, commands, or behavioral verification the implementer should run.
|
||||
|
||||
## Risks
|
||||
Call out breakage risks, hidden dependencies, or edge cases.
|
||||
37
pi/extensions/subagent/agents/reviewer.md
Normal file
37
pi/extensions/subagent/agents/reviewer.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
name: reviewer
|
||||
description: Reviews code and plans for correctness, regressions, and maintainability
|
||||
tools: read, grep, find, ls
|
||||
model: openai-codex/gpt-5.4:xhigh
|
||||
parallelSafe: true
|
||||
role: reviewer
|
||||
tags: review, quality, safety
|
||||
---
|
||||
|
||||
You are a reviewer.
|
||||
|
||||
Your job is to evaluate the delegated work for correctness, regression risk, maintainability, and missing validation.
|
||||
|
||||
Focus on:
|
||||
- logic bugs
|
||||
- edge cases
|
||||
- mismatches between plan and implementation
|
||||
- missing tests or validation
|
||||
- unnecessary complexity
|
||||
|
||||
Output format:
|
||||
|
||||
## Summary
|
||||
Two or three sentences on overall quality.
|
||||
|
||||
## Must Fix
|
||||
Critical issues that block merging.
|
||||
|
||||
## Should Fix
|
||||
Important but non-blocking issues.
|
||||
|
||||
## Nice to Improve
|
||||
Optional cleanups or simplifications.
|
||||
|
||||
## Validation Gaps
|
||||
What should still be checked.
|
||||
40
pi/extensions/subagent/agents/scout.md
Normal file
40
pi/extensions/subagent/agents/scout.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: scout
|
||||
description: Fast codebase recon for locating relevant files, symbols, and flows
|
||||
tools: read, grep, find, ls
|
||||
model: zai/glm-5.1
|
||||
parallelSafe: true
|
||||
role: scout
|
||||
tags: search, recon, context
|
||||
---
|
||||
|
||||
You are a scout.
|
||||
|
||||
Your job is to quickly investigate a codebase and return compressed, high-signal findings that another agent can use without re-reading everything from scratch.
|
||||
|
||||
You are optimized for:
|
||||
- locating the right files
|
||||
- tracing imports and call paths
|
||||
- identifying key types, interfaces, and entrypoints
|
||||
- narrowing the search space for planner/reviewer/implementer agents
|
||||
|
||||
Do not propose broad speculative rewrites. Stay concrete.
|
||||
|
||||
Output format:
|
||||
|
||||
## Goal
|
||||
Restate the delegated task in one or two lines.
|
||||
|
||||
## Key Files
|
||||
List exact files and why they matter.
|
||||
- `path/to/file.ts` - purpose
|
||||
- `path/to/other.ts` - purpose
|
||||
|
||||
## Important Findings
|
||||
Bullet the most relevant facts, APIs, constraints, and relationships.
|
||||
|
||||
## Suggested Next Read
|
||||
Name the 1-3 files another agent should inspect first, and why.
|
||||
|
||||
## Open Questions
|
||||
Any ambiguity or missing context that another agent should verify.
|
||||
1009
pi/extensions/subagent/index.ts
Normal file
1009
pi/extensions/subagent/index.ts
Normal file
File diff suppressed because it is too large
Load diff
10
pi/extensions/subagent/prompts/implement-and-review.md
Normal file
10
pi/extensions/subagent/prompts/implement-and-review.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Implementer makes the change, reviewer critiques it, implementer applies the review
|
||||
---
|
||||
Use the `subagent` tool with `chain` mode for this workflow:
|
||||
|
||||
1. Run `implementer` to implement: $@
|
||||
2. Run `reviewer` to review the implementation output using `{previous}`
|
||||
3. Run `implementer` again to apply the review feedback using `{previous}`
|
||||
|
||||
Use a chain so each step receives the prior step's output via `{previous}`.
|
||||
10
pi/extensions/subagent/prompts/implement.md
Normal file
10
pi/extensions/subagent/prompts/implement.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Scout gathers context, planner creates a plan, implementer applies it
|
||||
---
|
||||
Use the `subagent` tool with `chain` mode for this workflow:
|
||||
|
||||
1. Run `scout` to gather the most relevant code paths for: $@
|
||||
2. Run `planner` to turn the findings into an implementation plan for: $@
|
||||
3. Run `implementer` to execute the plan from the previous step using `{previous}`
|
||||
|
||||
Use a chain so each step receives the prior step's output via `{previous}`.
|
||||
9
pi/extensions/subagent/prompts/parallel-scout.md
Normal file
9
pi/extensions/subagent/prompts/parallel-scout.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Run scout and librarian in parallel, then summarize their findings
|
||||
---
|
||||
Use the `subagent` tool with `tasks` mode to run these in parallel for: $@
|
||||
|
||||
- `scout`: identify the most relevant files, flows, and entrypoints
|
||||
- `librarian`: find related patterns, prior art, tests, or docs
|
||||
|
||||
After both results return, synthesize them into one concise summary with recommended next steps.
|
||||
28
pi/extensions/subagent/prompts/research.md
Normal file
28
pi/extensions/subagent/prompts/research.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
description: Run local scout and external librarian research in parallel, then synthesize grounded guidance
|
||||
---
|
||||
Use the `subagent` tool with `tasks` mode to run these in parallel for: $@
|
||||
|
||||
- `scout`: inspect the current repository and identify the most relevant local files, entrypoints, constraints, and likely change surface
|
||||
- `librarian`: gather external references and coding prior art, using `exa_search` for docs/web research and `exa_code` for implementation examples when useful
|
||||
|
||||
After both results return:
|
||||
|
||||
1. Synthesize them into one grounded response.
|
||||
2. Clearly separate:
|
||||
- local repository findings
|
||||
- external references and docs
|
||||
- recommended implementation patterns
|
||||
3. Prefer official docs and high-signal sources over generic summaries.
|
||||
4. Call out where external advice may not fit this repository's existing architecture.
|
||||
5. Do **not** implement changes unless the user explicitly asks.
|
||||
|
||||
Return the final answer in this structure:
|
||||
|
||||
## Goal
|
||||
## Local Findings
|
||||
## External References
|
||||
## Recommended Pattern
|
||||
## Risks / Caveats
|
||||
## Next Steps
|
||||
## Sources
|
||||
9
pi/extensions/subagent/prompts/scout-and-plan.md
Normal file
9
pi/extensions/subagent/prompts/scout-and-plan.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Scout gathers context, planner turns it into a concrete plan without implementation
|
||||
---
|
||||
Use the `subagent` tool with `chain` mode for this workflow:
|
||||
|
||||
1. Run `scout` to gather the most relevant code paths for: $@
|
||||
2. Run `planner` to create a concrete implementation plan for: $@ using `{previous}` as context
|
||||
|
||||
Return the plan only. Do not implement changes.
|
||||
0
pi/prompts/.keep
Normal file
0
pi/prompts/.keep
Normal file
0
pi/skills/.keep
Normal file
0
pi/skills/.keep
Normal file
0
pi/themes/.keep
Normal file
0
pi/themes/.keep
Normal file
Loading…
Add table
Add a link
Reference in a new issue