pi-open-agents 0.1.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/ATTRIBUTION.md +38 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/index.ts +8 -0
- package/package.json +61 -0
- package/src/config/frontmatter.ts +247 -0
- package/src/config/paths.ts +120 -0
- package/src/config/schema.ts +313 -0
- package/src/discovery/loader.ts +180 -0
- package/src/index.ts +204 -0
- package/src/permission/evaluator.ts +160 -0
- package/src/permission/matcher.ts +99 -0
- package/src/permission/parser.ts +81 -0
- package/src/primary/executor.ts +167 -0
- package/src/primary/manager.ts +147 -0
- package/src/subagent/env.ts +116 -0
- package/src/subagent/executor.ts +679 -0
- package/src/subagent/prompt.ts +79 -0
- package/src/subagent/render.ts +251 -0
- package/src/subagent/skills.ts +274 -0
- package/src/subagent/tool.ts +299 -0
- package/src/tui/banner.ts +49 -0
- package/src/tui/selector.ts +331 -0
- package/src/tui/tools.ts +147 -0
- package/src/types.ts +152 -0
- package/src/utils/format.ts +99 -0
package/src/tui/tools.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic tools for agent switching.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - set_agent: LLM-callable tool to switch agents
|
|
6
|
+
* - search_agents: LLM-callable tool to search agents
|
|
7
|
+
*
|
|
8
|
+
* These tools can access all agents (including hidden ones),
|
|
9
|
+
* bypassing the TUI mode-based filtering.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import type { AgentDefinition } from "../types.ts";
|
|
15
|
+
import { selectableAgents } from "../discovery/loader.ts";
|
|
16
|
+
import type { AgentManager } from "../primary/manager.ts";
|
|
17
|
+
import { updateBanner } from "./banner.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register the set_agent and search_agents tools.
|
|
21
|
+
*/
|
|
22
|
+
export function registerAgentTools(manager: AgentManager, pi: ExtensionAPI): void {
|
|
23
|
+
// ─── set_agent tool ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
pi.registerTool({
|
|
26
|
+
name: "set_agent",
|
|
27
|
+
label: "Set Agent",
|
|
28
|
+
description:
|
|
29
|
+
"Switch to a different agent mode programmatically. Use this when the current task requires a different set of tools or specialized behavior.",
|
|
30
|
+
parameters: Type.Object({
|
|
31
|
+
agent: Type.String({ description: "Name of the agent to switch to" }),
|
|
32
|
+
reason: Type.Optional(Type.String({ description: "Reason for switching agents" })),
|
|
33
|
+
}),
|
|
34
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
35
|
+
const { agent: name, reason } = params;
|
|
36
|
+
|
|
37
|
+
const agent = manager.getAgent(name);
|
|
38
|
+
if (!agent) {
|
|
39
|
+
const available = manager.getAgents().map((a) => a.name).join(", ") || "(none defined)";
|
|
40
|
+
throw new Error(`Unknown agent "${name}". Available: ${available}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const success = await manager.apply(name, pi, ctx);
|
|
44
|
+
if (!success) {
|
|
45
|
+
throw new Error(`Failed to apply agent "${name}"`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const msg = reason ? `Switched to agent "${name}". Reason: ${reason}` : `Switched to agent "${name}"`;
|
|
49
|
+
ctx.ui.notify(msg, "info");
|
|
50
|
+
updateBanner(ctx.ui, manager.getActive(), selectableAgents(manager.getAgents()).length);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: msg }],
|
|
54
|
+
details: { agent: name, reason },
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── search_agents tool ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
pi.registerTool({
|
|
62
|
+
name: "search_agents",
|
|
63
|
+
label: "Search Agents",
|
|
64
|
+
description:
|
|
65
|
+
"Search available agents by name, description, or body content. Returns ranked results with relevance scores and matching snippets.",
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
query: Type.String({ description: "Search query to match against agent name, description, or body content" }),
|
|
68
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of results to return (default: 5)" })),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
|
|
71
|
+
const { query, limit = 5 } = params;
|
|
72
|
+
const agents = manager.getAgents();
|
|
73
|
+
const results = rankAgents(agents, query).slice(0, limit);
|
|
74
|
+
|
|
75
|
+
if (results.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: `No agents found matching "${query}".` }],
|
|
78
|
+
details: { query, results: [] },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lines = results.map((r, i) => {
|
|
83
|
+
const active = manager.isActive(r.name) ? " [ACTIVE]" : "";
|
|
84
|
+
let line = `${i + 1}. ${r.name}${active} (score: ${r.score})`;
|
|
85
|
+
if (r.agent.description) line += `\n Description: ${r.agent.description}`;
|
|
86
|
+
if (r.agent.model) line += `\n Model: ${r.agent.model}`;
|
|
87
|
+
if (r.snippets.length > 0) {
|
|
88
|
+
line += `\n Matches: ${r.snippets.slice(0, 2).join(" | ")}`;
|
|
89
|
+
}
|
|
90
|
+
return line;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const text = `Found ${results.length} agent(s) matching "${query}":\n\n${lines.join("\n\n")}`;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text }],
|
|
97
|
+
details: { query, totalResults: results.length, results },
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Agent Ranking ───────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
interface RankedAgent {
|
|
106
|
+
name: string;
|
|
107
|
+
agent: AgentDefinition;
|
|
108
|
+
score: number;
|
|
109
|
+
snippets: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rankAgents(agents: AgentDefinition[], query: string): RankedAgent[] {
|
|
113
|
+
const q = query.toLowerCase();
|
|
114
|
+
const results: RankedAgent[] = [];
|
|
115
|
+
|
|
116
|
+
for (const agent of agents) {
|
|
117
|
+
let score = 0;
|
|
118
|
+
|
|
119
|
+
if (agent.name.toLowerCase() === q) score += 100;
|
|
120
|
+
else if (agent.name.toLowerCase().includes(q)) score += 50;
|
|
121
|
+
|
|
122
|
+
if (agent.description?.toLowerCase().includes(q)) score += 30;
|
|
123
|
+
if (agent.model?.toLowerCase().includes(q)) score += 20;
|
|
124
|
+
|
|
125
|
+
const bodyLower = agent.prompt.toLowerCase();
|
|
126
|
+
const bodyMatches = (bodyLower.match(new RegExp(q, "g")) || []).length;
|
|
127
|
+
score += Math.min(bodyMatches * 5, 25);
|
|
128
|
+
|
|
129
|
+
if (score < 1) continue;
|
|
130
|
+
|
|
131
|
+
const snippets: string[] = [];
|
|
132
|
+
if (agent.description) {
|
|
133
|
+
const idx = agent.description.toLowerCase().indexOf(q);
|
|
134
|
+
if (idx !== -1) snippets.push(agent.description.slice(0, 80));
|
|
135
|
+
}
|
|
136
|
+
const bodyIdx = bodyLower.indexOf(q);
|
|
137
|
+
if (bodyIdx !== -1) {
|
|
138
|
+
const start = Math.max(0, bodyIdx - 20);
|
|
139
|
+
const end = Math.min(agent.prompt.length, bodyIdx + q.length + 20);
|
|
140
|
+
snippets.push(agent.prompt.slice(start, end).replace(/\n/g, " "));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
results.push({ name: agent.name, agent, score, snippets });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return results.sort((a, b) => b.score - a.score);
|
|
147
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for pi-open-agents.
|
|
3
|
+
*
|
|
4
|
+
* AgentDefinition is the unified superset of OpenCode and pi agent formats.
|
|
5
|
+
* Every .md agent file — regardless of source path or frontmatter dialect —
|
|
6
|
+
* is parsed into this single type.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Enums ───────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type AgentMode = "primary" | "subagent" | "all";
|
|
12
|
+
|
|
13
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
14
|
+
|
|
15
|
+
export type SystemPromptMode = "append" | "replace" | "replace-all";
|
|
16
|
+
|
|
17
|
+
export type PermissionAction = "allow" | "deny" | "ask";
|
|
18
|
+
|
|
19
|
+
export type AgentSource = "global" | "project";
|
|
20
|
+
|
|
21
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
22
|
+
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export const SYSTEM_PROMPT_MODES: readonly SystemPromptMode[] = [
|
|
26
|
+
"append", "replace", "replace-all",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export const AGENT_MODES: readonly AgentMode[] = [
|
|
30
|
+
"primary", "subagent", "all",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export const PERMISSION_ACTIONS: readonly PermissionAction[] = [
|
|
34
|
+
"allow", "deny", "ask",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
// ─── Permission ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Permission config as written in frontmatter.
|
|
41
|
+
*
|
|
42
|
+
* Simple form (string per tool):
|
|
43
|
+
* bash: allow
|
|
44
|
+
*
|
|
45
|
+
* Pattern form (nested object):
|
|
46
|
+
* edit:
|
|
47
|
+
* "*.env": deny
|
|
48
|
+
* "*.md": allow
|
|
49
|
+
*/
|
|
50
|
+
export type PermissionConfig = Record<string, PermissionAction | Record<string, PermissionAction>>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Flattened permission rule after parsing.
|
|
54
|
+
* Evaluation is last-match-wins (like CSS specificity).
|
|
55
|
+
*/
|
|
56
|
+
export interface PermissionRule {
|
|
57
|
+
/** Tool or permission category, e.g. "bash", "edit", "read" */
|
|
58
|
+
permission: string;
|
|
59
|
+
/** Glob pattern for the argument, e.g. "*.env", "*" */
|
|
60
|
+
pattern: string;
|
|
61
|
+
/** Action to take */
|
|
62
|
+
action: PermissionAction;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Agent Definition ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The unified agent definition — superset of OpenCode and pi formats.
|
|
69
|
+
*
|
|
70
|
+
* Fields are grouped by purpose. OpenCode-compatible fields are marked (OC),
|
|
71
|
+
* pi-specific extensions are marked (PI).
|
|
72
|
+
*/
|
|
73
|
+
export interface AgentDefinition {
|
|
74
|
+
// === Identity ===
|
|
75
|
+
name: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
/** (OC) Controls visibility: primary = user-selectable, subagent = delegation only, all = both */
|
|
78
|
+
mode: AgentMode;
|
|
79
|
+
/** (OC) Hide from TUI selectors, keep programmatic access */
|
|
80
|
+
hidden: boolean;
|
|
81
|
+
/** (OC) UI accent color */
|
|
82
|
+
color?: string;
|
|
83
|
+
/** (OC) Remove agent entirely */
|
|
84
|
+
disable: boolean;
|
|
85
|
+
|
|
86
|
+
// === Model ===
|
|
87
|
+
/** "provider/model-id" */
|
|
88
|
+
model?: string;
|
|
89
|
+
/** (OC) Model variant name, e.g. "thinking" */
|
|
90
|
+
variant?: string;
|
|
91
|
+
/** (OC) Generation temperature */
|
|
92
|
+
temperature?: number;
|
|
93
|
+
/** (OC) Generation top_p */
|
|
94
|
+
topP?: number;
|
|
95
|
+
|
|
96
|
+
// === Execution Control ===
|
|
97
|
+
/** (PI) Reasoning effort level */
|
|
98
|
+
thinking: ThinkingLevel;
|
|
99
|
+
/** (OC) Max agentic iterations before forced text-only */
|
|
100
|
+
steps?: number;
|
|
101
|
+
/** (PI) How agent body interacts with system prompt */
|
|
102
|
+
systemPrompt: SystemPromptMode;
|
|
103
|
+
|
|
104
|
+
// === Tools & Permissions ===
|
|
105
|
+
/** (OC) Permission rules for tool usage */
|
|
106
|
+
permission?: PermissionConfig;
|
|
107
|
+
/** (PI) Simple tool whitelist (converted to permission if no permission block) */
|
|
108
|
+
tools?: string[];
|
|
109
|
+
|
|
110
|
+
// === Subagent Controls ===
|
|
111
|
+
/** (PI) Maximum recursion depth when spawning subagents */
|
|
112
|
+
maxDepth: number;
|
|
113
|
+
/** (PI) Which subagents this agent is allowed to spawn */
|
|
114
|
+
allowedAgents?: string[];
|
|
115
|
+
/** (PI) Per-agent skill loading with wildcard support */
|
|
116
|
+
skills?: string[];
|
|
117
|
+
|
|
118
|
+
// === Content & Metadata ===
|
|
119
|
+
/** Markdown body (content after frontmatter) */
|
|
120
|
+
prompt: string;
|
|
121
|
+
/** Discovery origin */
|
|
122
|
+
source: AgentSource;
|
|
123
|
+
/** Absolute path to the .md file */
|
|
124
|
+
filePath: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Loading Results ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export interface AgentLoadWarning {
|
|
130
|
+
filePath: string;
|
|
131
|
+
message: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface AgentLoadResult {
|
|
135
|
+
agents: AgentDefinition[];
|
|
136
|
+
warnings: AgentLoadWarning[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Filesystem Abstraction (for testing) ────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export interface AgentFs {
|
|
142
|
+
listFiles(dir: string): Promise<string[]>;
|
|
143
|
+
readFile(filePath: string): Promise<string>;
|
|
144
|
+
exists(dir: string): Promise<boolean>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Model Reference ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export interface ModelRef {
|
|
150
|
+
provider: string;
|
|
151
|
+
modelId: string;
|
|
152
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format utilities for subagent TUI rendering and output.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from tool-args.ts and subagent-render.ts (pi-subagents reference).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Truncate text with ellipsis.
|
|
11
|
+
*/
|
|
12
|
+
export function preview(text: string, length: number): string {
|
|
13
|
+
return text.length > length ? `${text.slice(0, length)}...` : text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shorten a file path by replacing the home directory with ~.
|
|
18
|
+
*/
|
|
19
|
+
export function shortenPath(value: unknown): string | undefined {
|
|
20
|
+
if (typeof value !== "string") return undefined;
|
|
21
|
+
const home = homedir();
|
|
22
|
+
return value.startsWith(`${home}/`) ? `~/${value.slice(home.length + 1)}` : value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Type-safe string extraction from a record.
|
|
27
|
+
*/
|
|
28
|
+
export function stringArg(args: Record<string, unknown>, key: string): string | undefined {
|
|
29
|
+
const value = args[key];
|
|
30
|
+
return typeof value === "string" ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Type-safe number extraction from a record.
|
|
35
|
+
*/
|
|
36
|
+
export function numberArg(args: Record<string, unknown>, key: string): number | undefined {
|
|
37
|
+
const value = args[key];
|
|
38
|
+
return typeof value === "number" ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Escape XML special characters.
|
|
43
|
+
*/
|
|
44
|
+
export function escapeXml(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">")
|
|
49
|
+
.replace(/"/g, """)
|
|
50
|
+
.replace(/'/g, "'");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format a token count compactly (e.g. 1500 → "1.5k", 2000000 → "2m").
|
|
55
|
+
*/
|
|
56
|
+
export function formatTokens(value: number): string {
|
|
57
|
+
if (Math.abs(value) >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}m`;
|
|
58
|
+
if (Math.abs(value) >= 1_000) return `${Number((value / 1_000).toFixed(1))}k`;
|
|
59
|
+
return String(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sanitize a string for use as a filename component.
|
|
64
|
+
*/
|
|
65
|
+
export function safeFilePart(value: string): string {
|
|
66
|
+
return value.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get byte length of a UTF-8 string.
|
|
71
|
+
*/
|
|
72
|
+
export function byteLength(text: string): number {
|
|
73
|
+
return Buffer.byteLength(text, "utf8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Keep the tail of a string within a byte budget.
|
|
78
|
+
*/
|
|
79
|
+
export function keepTailByBytes(text: string, maxBytes: number): string {
|
|
80
|
+
let kept = text;
|
|
81
|
+
while (byteLength(kept) > maxBytes) kept = kept.slice(1);
|
|
82
|
+
return kept;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Truncate content from the head, keeping the tail.
|
|
87
|
+
* Returns undefined if no truncation needed.
|
|
88
|
+
*/
|
|
89
|
+
export function truncateHeadContent(
|
|
90
|
+
text: string,
|
|
91
|
+
maxBytes: number,
|
|
92
|
+
maxLines: number,
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
if (byteLength(text) <= maxBytes && lines.length <= maxLines) return undefined;
|
|
96
|
+
|
|
97
|
+
const lineLimited = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : text;
|
|
98
|
+
return keepTailByBytes(lineLimited, maxBytes);
|
|
99
|
+
}
|