@rahularya01/pi-essentials 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/LICENSE +21 -0
- package/README.md +324 -0
- package/examples/mcp.json +30 -0
- package/examples/pi-essentials.json +32 -0
- package/examples/pi-settings.json +5 -0
- package/package.json +88 -0
- package/skills/pi-essentials/SKILL.md +50 -0
- package/src/config.ts +351 -0
- package/src/errors.ts +96 -0
- package/src/index.ts +43 -0
- package/src/mcp/commands.ts +390 -0
- package/src/mcp/config.ts +157 -0
- package/src/mcp/credential-store.ts +153 -0
- package/src/mcp/index.ts +67 -0
- package/src/mcp/manager.ts +941 -0
- package/src/mcp/oauth.ts +262 -0
- package/src/mcp/proxy-tool.ts +213 -0
- package/src/mcp/render.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/paths.ts +48 -0
- package/src/questions/ask.ts +134 -0
- package/src/questions/index.ts +72 -0
- package/src/questions/render.ts +69 -0
- package/src/questions/validate.ts +85 -0
- package/src/security/env.ts +132 -0
- package/src/security/limits.ts +20 -0
- package/src/security/ssrf.ts +237 -0
- package/src/subagents/activity.ts +132 -0
- package/src/subagents/builtins/oracle.md +11 -0
- package/src/subagents/builtins/reviewer.md +11 -0
- package/src/subagents/builtins/scout.md +12 -0
- package/src/subagents/builtins/worker.md +11 -0
- package/src/subagents/discover.ts +54 -0
- package/src/subagents/herdr.ts +150 -0
- package/src/subagents/index.ts +642 -0
- package/src/subagents/inspector-tail.d.mts +1 -0
- package/src/subagents/inspector-tail.mjs +140 -0
- package/src/subagents/render.ts +464 -0
- package/src/subagents/runner.ts +468 -0
- package/src/subagents/schema.ts +107 -0
- package/src/subagents/types.ts +131 -0
- package/src/subagents/worktree.ts +131 -0
- package/src/todos/index.ts +170 -0
- package/src/todos/render.ts +198 -0
- package/src/todos/state.ts +310 -0
- package/src/ui/render.ts +215 -0
- package/src/web/activity.ts +91 -0
- package/src/web/cache.ts +153 -0
- package/src/web/extract.ts +75 -0
- package/src/web/fetch.ts +167 -0
- package/src/web/html-to-markdown.ts +284 -0
- package/src/web/http.ts +238 -0
- package/src/web/index.ts +214 -0
- package/src/web/providers/brave.ts +27 -0
- package/src/web/providers/duckduckgo.ts +60 -0
- package/src/web/providers/exa.ts +29 -0
- package/src/web/providers/jina.ts +25 -0
- package/src/web/providers/searxng.ts +29 -0
- package/src/web/providers/tavily.ts +31 -0
- package/src/web/providers/types.ts +75 -0
- package/src/web/render.ts +130 -0
- package/src/web/search.ts +108 -0
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type McpLifecycle = "lazy" | "eager" | "keep-alive";
|
|
2
|
+
export type McpAuth = "bearer" | "oauth";
|
|
3
|
+
|
|
4
|
+
export interface McpOAuthConfig {
|
|
5
|
+
clientId?: string;
|
|
6
|
+
clientSecret?: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
redirectUri?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface McpServerDefinition {
|
|
12
|
+
command?: string;
|
|
13
|
+
args?: string[];
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
auth?: McpAuth;
|
|
19
|
+
bearerToken?: string;
|
|
20
|
+
bearerTokenEnv?: string;
|
|
21
|
+
oauth?: McpOAuthConfig;
|
|
22
|
+
lifecycle?: McpLifecycle;
|
|
23
|
+
idleTimeout?: number;
|
|
24
|
+
requestTimeoutMs?: number;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
includeTools?: string[];
|
|
27
|
+
excludeTools?: string[];
|
|
28
|
+
toolPrefix?: "server" | "none";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface McpFileShape {
|
|
32
|
+
mcpServers?: Record<string, McpServerDefinition>;
|
|
33
|
+
settings?: {
|
|
34
|
+
requestTimeoutMs?: number;
|
|
35
|
+
idleTimeout?: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolvedServer {
|
|
40
|
+
name: string;
|
|
41
|
+
definition: McpServerDefinition;
|
|
42
|
+
source: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CachedTool {
|
|
46
|
+
server: string;
|
|
47
|
+
name: string;
|
|
48
|
+
prefixedName: string;
|
|
49
|
+
description: string;
|
|
50
|
+
inputSchema?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ServerStatus = "idle" | "connecting" | "connected" | "failed" | "needs-auth" | "disabled";
|
|
54
|
+
|
|
55
|
+
export interface ServerSnapshot {
|
|
56
|
+
name: string;
|
|
57
|
+
status: ServerStatus;
|
|
58
|
+
toolCount: number;
|
|
59
|
+
disabled: boolean;
|
|
60
|
+
transport: "stdio" | "http" | "unknown";
|
|
61
|
+
error?: string;
|
|
62
|
+
source: string;
|
|
63
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const PROJECT_PI_DIR = ".pi";
|
|
5
|
+
export const PACKAGE_DIR_NAME = "pi-essentials";
|
|
6
|
+
|
|
7
|
+
export function getAgentDir(): string {
|
|
8
|
+
if (process.env.PI_CODING_AGENT_DIR?.trim()) {
|
|
9
|
+
return process.env.PI_CODING_AGENT_DIR.trim();
|
|
10
|
+
}
|
|
11
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getProjectPiDir(cwd: string): string {
|
|
15
|
+
return path.join(cwd, PROJECT_PI_DIR);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getPiEssentialsDir(): string {
|
|
19
|
+
return path.join(getAgentDir(), PACKAGE_DIR_NAME);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getUserConfigPath(): string {
|
|
23
|
+
return path.join(getAgentDir(), "pi-essentials.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getProjectConfigPath(cwd: string): string {
|
|
27
|
+
return path.join(getProjectPiDir(cwd), "pi-essentials.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getOAuthStorePath(): string {
|
|
31
|
+
return path.join(getPiEssentialsDir(), "mcp-oauth.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getWebCacheDir(): string {
|
|
35
|
+
return path.join(getPiEssentialsDir(), "web-cache");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mcpConfigCandidates(cwd: string): string[] {
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
return [
|
|
41
|
+
path.join(home, ".config", "mcp", "mcp.json"),
|
|
42
|
+
path.join(home, ".agents", "mcp.json"),
|
|
43
|
+
path.join(home, ".agents", "mcp", "mcp.json"),
|
|
44
|
+
path.join(getAgentDir(), "mcp.json"),
|
|
45
|
+
path.join(cwd, ".mcp.json"),
|
|
46
|
+
path.join(getProjectPiDir(cwd), "mcp.json"),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isMultiple, type QuestionAnswer, type QuestionOption, type QuestionSpec } from "./validate.ts";
|
|
3
|
+
|
|
4
|
+
const OTHER_LABEL = "Other (type your own answer)";
|
|
5
|
+
const DONE_LABEL = "Done (finish this question)";
|
|
6
|
+
|
|
7
|
+
export interface AskResult {
|
|
8
|
+
answers: QuestionAnswer[];
|
|
9
|
+
cancelled: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options are shown with a numeric prefix so every entry is unique, which lets the
|
|
14
|
+
* selection be mapped back by array index instead of by parsing the label text.
|
|
15
|
+
*/
|
|
16
|
+
export function displayOptions(options: QuestionOption[]): string[] {
|
|
17
|
+
return options.map((option, index) => {
|
|
18
|
+
const description = option.description?.trim();
|
|
19
|
+
return `${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function askQuestions(
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
questions: QuestionSpec[],
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
): Promise<AskResult> {
|
|
28
|
+
if (!ctx.hasUI) return { answers: [], cancelled: true };
|
|
29
|
+
|
|
30
|
+
const answers: QuestionAnswer[] = [];
|
|
31
|
+
for (const question of questions) {
|
|
32
|
+
if (signal?.aborted) return { answers, cancelled: true };
|
|
33
|
+
const answer = isMultiple(question)
|
|
34
|
+
? await askMultiSelect(ctx, question, signal)
|
|
35
|
+
: await askSingle(ctx, question, signal);
|
|
36
|
+
if (!answer) return { answers, cancelled: true };
|
|
37
|
+
answers.push(answer);
|
|
38
|
+
}
|
|
39
|
+
return { answers, cancelled: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function askSingle(
|
|
43
|
+
ctx: ExtensionContext,
|
|
44
|
+
question: QuestionSpec,
|
|
45
|
+
signal?: AbortSignal,
|
|
46
|
+
): Promise<QuestionAnswer | undefined> {
|
|
47
|
+
const labels = [...displayOptions(question.options), ...(question.allowOther === false ? [] : [OTHER_LABEL])];
|
|
48
|
+
// Pi's select API currently has no initial-index option, so `recommended`
|
|
49
|
+
// remains metadata and option order/display stay backward compatible.
|
|
50
|
+
const picked = await ctx.ui.select(question.question, labels, { signal });
|
|
51
|
+
if (picked === undefined || picked === null) return undefined;
|
|
52
|
+
|
|
53
|
+
const index = labels.indexOf(String(picked));
|
|
54
|
+
if (index === -1) return undefined;
|
|
55
|
+
if (question.allowOther !== false && index === question.options.length) {
|
|
56
|
+
const custom = await askCustom(ctx, question, signal);
|
|
57
|
+
return custom === undefined
|
|
58
|
+
? undefined
|
|
59
|
+
: answerBase(question, [], [], custom);
|
|
60
|
+
}
|
|
61
|
+
const option = question.options[index];
|
|
62
|
+
if (!option) return undefined;
|
|
63
|
+
return answerBase(question, [option.label], [index]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function askMultiSelect(
|
|
67
|
+
ctx: ExtensionContext,
|
|
68
|
+
question: QuestionSpec,
|
|
69
|
+
signal?: AbortSignal,
|
|
70
|
+
): Promise<QuestionAnswer | undefined> {
|
|
71
|
+
const remaining = question.options.map((option, index) => ({ option, index }));
|
|
72
|
+
const selected: string[] = [];
|
|
73
|
+
const selectedIndices: number[] = [];
|
|
74
|
+
let custom: string | undefined;
|
|
75
|
+
|
|
76
|
+
while (remaining.length > 0) {
|
|
77
|
+
const rows = remaining.map(({ option, index }) => {
|
|
78
|
+
const description = option.description?.trim();
|
|
79
|
+
return `${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`;
|
|
80
|
+
});
|
|
81
|
+
const labels = [
|
|
82
|
+
...rows,
|
|
83
|
+
...(question.allowOther === false ? [] : [OTHER_LABEL]),
|
|
84
|
+
...(selected.length > 0 || custom ? [DONE_LABEL] : []),
|
|
85
|
+
];
|
|
86
|
+
const title = selected.length > 0 ? `${question.question} (selected: ${selected.join(", ")})` : question.question;
|
|
87
|
+
|
|
88
|
+
const picked = await ctx.ui.select(title, labels, { signal });
|
|
89
|
+
if (picked === undefined || picked === null) return undefined;
|
|
90
|
+
const choice = String(picked);
|
|
91
|
+
if (choice === DONE_LABEL) break;
|
|
92
|
+
if (question.allowOther !== false && choice === OTHER_LABEL) {
|
|
93
|
+
const typed = await askCustom(ctx, question, signal);
|
|
94
|
+
if (typed === undefined) return undefined;
|
|
95
|
+
custom = custom ? `${custom}; ${typed}` : typed;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const position = rows.indexOf(choice);
|
|
99
|
+
if (position === -1) return undefined;
|
|
100
|
+
selected.push(remaining[position].option.label);
|
|
101
|
+
selectedIndices.push(remaining[position].index);
|
|
102
|
+
remaining.splice(position, 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (selected.length === 0 && !custom) return undefined;
|
|
106
|
+
return answerBase(question, selected, selectedIndices, custom);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function answerBase(
|
|
110
|
+
question: QuestionSpec,
|
|
111
|
+
selected: string[],
|
|
112
|
+
selectedIndices: number[],
|
|
113
|
+
custom?: string,
|
|
114
|
+
): QuestionAnswer {
|
|
115
|
+
return {
|
|
116
|
+
id: question.id,
|
|
117
|
+
question: question.question,
|
|
118
|
+
header: question.header,
|
|
119
|
+
selected,
|
|
120
|
+
selectedIndices,
|
|
121
|
+
selectedValues: selectedIndices.map((index) => question.options[index]?.value ?? question.options[index]?.label ?? ""),
|
|
122
|
+
custom,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function askCustom(
|
|
127
|
+
ctx: ExtensionContext,
|
|
128
|
+
question: QuestionSpec,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
): Promise<string | undefined> {
|
|
131
|
+
const typed = await ctx.ui.input(question.header?.trim() || "Your answer", "", { signal });
|
|
132
|
+
const value = typed === undefined || typed === null ? "" : String(typed).trim();
|
|
133
|
+
return value.length > 0 ? value : undefined;
|
|
134
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { errorMessage, toolFailure, toolText } from "../errors.ts";
|
|
4
|
+
import { MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS } from "../security/limits.ts";
|
|
5
|
+
import { askQuestions } from "./ask.ts";
|
|
6
|
+
import { renderAskCall, renderAskResult } from "./render.ts";
|
|
7
|
+
import { formatAnswers, validateQuestions, type QuestionSpec } from "./validate.ts";
|
|
8
|
+
|
|
9
|
+
const OptionSchema = Type.Object({
|
|
10
|
+
label: Type.String({ description: "Option label" }),
|
|
11
|
+
description: Type.Optional(Type.String({ description: "What this choice means" })),
|
|
12
|
+
value: Type.Optional(Type.String({ description: "Machine-readable value returned for this option" })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const QuestionSchema = Type.Object({
|
|
16
|
+
id: Type.Optional(Type.String({ description: "Caller-defined question id, echoed in the answer" })),
|
|
17
|
+
question: Type.String({ description: "Question text" }),
|
|
18
|
+
header: Type.Optional(Type.String({ description: "Short header, shown as the input title" })),
|
|
19
|
+
options: Type.Array(OptionSchema, { description: `${MIN_OPTIONS}-${MAX_OPTIONS} options` }),
|
|
20
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "Allow picking several options" })),
|
|
21
|
+
multiple: Type.Optional(Type.Boolean({ description: "Alias for multiSelect; must agree if both are present" })),
|
|
22
|
+
recommended: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based recommended option index" })),
|
|
23
|
+
allowOther: Type.Optional(Type.Boolean({ description: "Allow a free-form answer (default true)" })),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function registerQuestions(pi: ExtensionAPI): void {
|
|
27
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
28
|
+
// Nothing can answer in print/JSON mode, so keep the tool out of the model's list.
|
|
29
|
+
if (ctx.hasUI) return;
|
|
30
|
+
const active = pi.getActiveTools();
|
|
31
|
+
if (active.includes("ask_user_question")) {
|
|
32
|
+
pi.setActiveTools(active.filter((name) => name !== "ask_user_question"));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.registerTool({
|
|
37
|
+
name: "ask_user_question",
|
|
38
|
+
label: "Ask User",
|
|
39
|
+
description:
|
|
40
|
+
"Pause and ask the user 1-4 multiple-choice questions when a real decision is required. Free-form answers are allowed by default. Do not use this for information you can find yourself.",
|
|
41
|
+
promptSnippet: "Ask the user a structured question instead of guessing",
|
|
42
|
+
promptGuidelines: [
|
|
43
|
+
"Use ask_user_question when a genuine user decision is required and guessing would be expensive to undo.",
|
|
44
|
+
"Do not use ask_user_question for facts you can inspect in the repo or look up with web_search.",
|
|
45
|
+
],
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
questions: Type.Array(QuestionSchema, { description: `1-${MAX_QUESTIONS} questions` }),
|
|
48
|
+
}),
|
|
49
|
+
executionMode: "sequential",
|
|
50
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
51
|
+
const questions = params.questions as QuestionSpec[];
|
|
52
|
+
const invalid = validateQuestions(questions);
|
|
53
|
+
if (invalid) toolFailure(invalid, "QUESTION_INVALID");
|
|
54
|
+
if (!ctx.hasUI) {
|
|
55
|
+
toolFailure("Cannot ask the user in non-interactive mode; decide yourself and state the assumption.", "QUESTION_NO_UI");
|
|
56
|
+
}
|
|
57
|
+
if (signal?.aborted) toolFailure("Cancelled before the question was shown.", "QUESTION_CANCELLED");
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const result = await askQuestions(ctx, questions, signal);
|
|
61
|
+
return toolText(formatAnswers(result.answers, result.cancelled), {
|
|
62
|
+
answers: result.answers,
|
|
63
|
+
cancelled: result.cancelled,
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
toolFailure(`Could not ask the user: ${errorMessage(error)}`, "QUESTION_FAILED");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
renderCall: renderAskCall,
|
|
70
|
+
renderResult: renderAskResult,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
failLine,
|
|
5
|
+
firstText,
|
|
6
|
+
GLYPH,
|
|
7
|
+
meta,
|
|
8
|
+
okLine,
|
|
9
|
+
oneLine,
|
|
10
|
+
safeRender,
|
|
11
|
+
titleLine,
|
|
12
|
+
type RenderableResult,
|
|
13
|
+
type RenderSlot,
|
|
14
|
+
} from "../ui/render.ts";
|
|
15
|
+
import type { QuestionAnswer, QuestionSpec } from "./validate.ts";
|
|
16
|
+
|
|
17
|
+
interface AskDetails {
|
|
18
|
+
answers?: QuestionAnswer[];
|
|
19
|
+
cancelled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderAskCall(args: { questions?: QuestionSpec[] }, theme: Theme, context: RenderSlot): Text {
|
|
23
|
+
return safeRender(
|
|
24
|
+
() => {
|
|
25
|
+
const questions = args?.questions ?? [];
|
|
26
|
+
const first = questions[0]?.question;
|
|
27
|
+
return (
|
|
28
|
+
titleLine(theme, "ask_user_question", first ? oneLine(first, 56) : undefined) +
|
|
29
|
+
meta(theme, [questions.length > 1 ? `${questions.length} questions` : undefined])
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
"ask_user_question",
|
|
33
|
+
context,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderAskResult(
|
|
38
|
+
result: RenderableResult<AskDetails | undefined>,
|
|
39
|
+
_options: { expanded: boolean; isPartial: boolean },
|
|
40
|
+
theme: Theme,
|
|
41
|
+
context: RenderSlot,
|
|
42
|
+
): Text {
|
|
43
|
+
return safeRender(
|
|
44
|
+
() => {
|
|
45
|
+
if (context.isError) return failLine(theme, oneLine(firstText(result) || "could not ask", 96));
|
|
46
|
+
|
|
47
|
+
const details = result?.details ?? {};
|
|
48
|
+
const answers = details.answers ?? [];
|
|
49
|
+
if (details.cancelled && answers.length === 0) {
|
|
50
|
+
return `${theme.fg("warning", GLYPH.pending)} ${theme.fg("muted", "cancelled")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The answer is the point, so it is always shown in full.
|
|
54
|
+
let out = okLine(theme, theme.fg("text", `${answers.length} answered`));
|
|
55
|
+
for (const answer of answers) {
|
|
56
|
+
const label = answer.header?.trim() ? theme.fg("muted", `[${answer.header.trim()}] `) : "";
|
|
57
|
+
const picked = [answer.selected.join(", "), answer.custom && `"${answer.custom}"`]
|
|
58
|
+
.filter((part): part is string => Boolean(part))
|
|
59
|
+
.join(" | ");
|
|
60
|
+
out += `\n ${label}${theme.fg("dim", oneLine(answer.question, 52))}`;
|
|
61
|
+
out += `\n ${theme.fg("accent", GLYPH.arrow)} ${theme.fg("text", oneLine(picked || "(no answer)", 60))}`;
|
|
62
|
+
}
|
|
63
|
+
if (details.cancelled) out += theme.fg("warning", `\n ${GLYPH.sep} remaining questions cancelled`);
|
|
64
|
+
return out;
|
|
65
|
+
},
|
|
66
|
+
oneLine(firstText(result), 120),
|
|
67
|
+
context,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS } from "../security/limits.ts";
|
|
2
|
+
|
|
3
|
+
export interface QuestionOption {
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
value?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface QuestionSpec {
|
|
10
|
+
id?: string;
|
|
11
|
+
question: string;
|
|
12
|
+
header?: string;
|
|
13
|
+
options: QuestionOption[];
|
|
14
|
+
multiSelect?: boolean;
|
|
15
|
+
/** OMP-compatible alias for multiSelect. */
|
|
16
|
+
multiple?: boolean;
|
|
17
|
+
recommended?: number;
|
|
18
|
+
allowOther?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QuestionAnswer {
|
|
22
|
+
id?: string;
|
|
23
|
+
question: string;
|
|
24
|
+
header?: string;
|
|
25
|
+
selected: string[];
|
|
26
|
+
selectedValues?: string[];
|
|
27
|
+
selectedIndices?: number[];
|
|
28
|
+
custom?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isMultiple(question: QuestionSpec): boolean {
|
|
32
|
+
return question.multiple ?? question.multiSelect ?? false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateQuestions(questions: QuestionSpec[]): string | undefined {
|
|
36
|
+
if (!Array.isArray(questions) || questions.length === 0) return "Provide at least one question.";
|
|
37
|
+
if (questions.length > MAX_QUESTIONS) return `Ask at most ${MAX_QUESTIONS} questions at a time.`;
|
|
38
|
+
for (const [index, question] of questions.entries()) {
|
|
39
|
+
const label = `Question ${index + 1}`;
|
|
40
|
+
if (!question?.question?.trim()) return `${label} is missing question text.`;
|
|
41
|
+
if (question.id !== undefined && (typeof question.id !== "string" || !question.id.trim())) {
|
|
42
|
+
return `${label} id must be a non-empty string.`;
|
|
43
|
+
}
|
|
44
|
+
if (question.multiple !== undefined && question.multiSelect !== undefined && question.multiple !== question.multiSelect) {
|
|
45
|
+
return `${label} has conflicting multiple and multiSelect values.`;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(question.options) || question.options.length < MIN_OPTIONS) {
|
|
48
|
+
return `${label} needs at least ${MIN_OPTIONS} options.`;
|
|
49
|
+
}
|
|
50
|
+
if (question.options.length > MAX_OPTIONS) {
|
|
51
|
+
return `${label} has more than ${MAX_OPTIONS} options.`;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
question.recommended !== undefined &&
|
|
55
|
+
(!Number.isInteger(question.recommended) || question.recommended < 0 || question.recommended >= question.options.length)
|
|
56
|
+
) {
|
|
57
|
+
return `${label} recommended must be a valid zero-based option index.`;
|
|
58
|
+
}
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const option of question.options) {
|
|
61
|
+
const text = option?.label?.trim();
|
|
62
|
+
if (!text) return `${label} has an option with an empty label.`;
|
|
63
|
+
if (option.value !== undefined && typeof option.value !== "string") return `${label} has an option with an invalid value.`;
|
|
64
|
+
const key = text.toLowerCase();
|
|
65
|
+
if (seen.has(key)) return `${label} repeats the option "${text}"; options must be distinct.`;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Render answers for the transcript and for the model's next turn. */
|
|
73
|
+
export function formatAnswers(answers: QuestionAnswer[], cancelled: boolean): string {
|
|
74
|
+
if (cancelled && answers.length === 0) return "User cancelled the questionnaire.";
|
|
75
|
+
const body = answers
|
|
76
|
+
.map((answer) => {
|
|
77
|
+
const parts: string[] = [];
|
|
78
|
+
if (answer.selected.length > 0) parts.push(answer.selected.join(", "));
|
|
79
|
+
if (answer.custom) parts.push(`wrote: ${answer.custom}`);
|
|
80
|
+
const prefix = answer.header?.trim() ? `[${answer.header.trim()}] ` : "";
|
|
81
|
+
return `Q: ${prefix}${answer.question}\nA: ${parts.length > 0 ? parts.join(" | ") : "(no answer)"}`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n\n");
|
|
84
|
+
return cancelled ? `${body}\n\nUser cancelled the remaining questions.` : body;
|
|
85
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const KEEP = new Set(["PATH", "HOME", "LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP", "USER", "LOGNAME", "SHELL"]);
|
|
2
|
+
|
|
3
|
+
const SECRET_NAME =
|
|
4
|
+
/^(?:.+_)?(?:API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS|ACCESS_KEY|PRIVATE_KEY)$|^MCP_.+$/i;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider credentials a child `pi` process needs to authenticate. Without these a
|
|
8
|
+
* subagent starts and immediately fails with "no API key", because every one of them
|
|
9
|
+
* is also secret-shaped. Mirrors the provider table in pi's docs/providers.md.
|
|
10
|
+
*/
|
|
11
|
+
const PROVIDER_CREDENTIAL_ENV = new Set(
|
|
12
|
+
[
|
|
13
|
+
"ANTHROPIC_API_KEY",
|
|
14
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
15
|
+
"ANTHROPIC_OAUTH_TOKEN",
|
|
16
|
+
"ANT_LING_API_KEY",
|
|
17
|
+
"AZURE_OPENAI_API_KEY",
|
|
18
|
+
"OPENAI_API_KEY",
|
|
19
|
+
"DEEPSEEK_API_KEY",
|
|
20
|
+
"NVIDIA_API_KEY",
|
|
21
|
+
"GEMINI_API_KEY",
|
|
22
|
+
"GOOGLE_API_KEY",
|
|
23
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
24
|
+
"AWS_BEARER_TOKEN_BEDROCK",
|
|
25
|
+
"MISTRAL_API_KEY",
|
|
26
|
+
"GROQ_API_KEY",
|
|
27
|
+
"CEREBRAS_API_KEY",
|
|
28
|
+
"CLOUDFLARE_API_KEY",
|
|
29
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
30
|
+
"CLOUDFLARE_GATEWAY_ID",
|
|
31
|
+
"XAI_API_KEY",
|
|
32
|
+
"OPENROUTER_API_KEY",
|
|
33
|
+
"AI_GATEWAY_API_KEY",
|
|
34
|
+
"ZAI_API_KEY",
|
|
35
|
+
"ZAI_CODING_CN_API_KEY",
|
|
36
|
+
"OPENCODE_API_KEY",
|
|
37
|
+
"RADIUS_API_KEY",
|
|
38
|
+
"HF_TOKEN",
|
|
39
|
+
"FIREWORKS_API_KEY",
|
|
40
|
+
"TOGETHER_API_KEY",
|
|
41
|
+
"BASETEN_API_KEY",
|
|
42
|
+
"KIMI_API_KEY",
|
|
43
|
+
"MINIMAX_API_KEY",
|
|
44
|
+
"MINIMAX_CN_API_KEY",
|
|
45
|
+
"QWEN_TOKEN_PLAN_API_KEY",
|
|
46
|
+
"QWEN_TOKEN_PLAN_CN_API_KEY",
|
|
47
|
+
"XIAOMI_API_KEY",
|
|
48
|
+
"XIAOMI_TOKEN_PLAN_CN_API_KEY",
|
|
49
|
+
"XIAOMI_TOKEN_PLAN_AMS_API_KEY",
|
|
50
|
+
"XIAOMI_TOKEN_PLAN_SGP_API_KEY",
|
|
51
|
+
].map((name) => name.toUpperCase()),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export function isSecretEnvKey(key: string): boolean {
|
|
55
|
+
return SECRET_NAME.test(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True for credentials a nested `pi` needs to reach a model provider. */
|
|
59
|
+
export function isProviderCredentialEnvKey(key: string): boolean {
|
|
60
|
+
return PROVIDER_CREDENTIAL_ENV.has(key.toUpperCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SanitizeEnvOptions {
|
|
64
|
+
/** Keep model-provider credentials so a child `pi` can authenticate. Default true. */
|
|
65
|
+
keepProviderCredentials?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Child-process env: keep the parent environment except secret-shaped keys. */
|
|
69
|
+
export function sanitizeEnv(
|
|
70
|
+
source: NodeJS.ProcessEnv = process.env,
|
|
71
|
+
extra: Record<string, string> = {},
|
|
72
|
+
options: SanitizeEnvOptions = {},
|
|
73
|
+
): Record<string, string> {
|
|
74
|
+
const keepProviderCredentials = options.keepProviderCredentials !== false;
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const [key, value] of Object.entries(source)) {
|
|
77
|
+
if (value === undefined) continue;
|
|
78
|
+
if (KEEP.has(key)) {
|
|
79
|
+
out[key] = value;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isSecretEnvKey(key)) {
|
|
83
|
+
if (keepProviderCredentials && isProviderCredentialEnvKey(key)) out[key] = value;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out[key] = value;
|
|
87
|
+
}
|
|
88
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
89
|
+
out[key] = value;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function interpolateEnvValue(raw: string, env: NodeJS.ProcessEnv = process.env, missing: string[] = []): string {
|
|
95
|
+
let value = raw.replace(/^~(?=\/|$)/, env.HOME ?? "");
|
|
96
|
+
const pattern = /\$\{([A-Z0-9_]+)\}|\$env:([A-Z0-9_]+)/gi;
|
|
97
|
+
value = value.replace(pattern, (_whole, brace: string | undefined, envStyle: string | undefined) => {
|
|
98
|
+
const name = brace ?? envStyle ?? "";
|
|
99
|
+
const found = env[name];
|
|
100
|
+
if (found === undefined) {
|
|
101
|
+
if (!missing.includes(name)) missing.push(name);
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
});
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function interpolateRecord(
|
|
110
|
+
input: Record<string, string> | undefined,
|
|
111
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
112
|
+
): { values: Record<string, string>; missing: string[] } {
|
|
113
|
+
const values: Record<string, string> = {};
|
|
114
|
+
const missing: string[] = [];
|
|
115
|
+
if (!input) return { values, missing };
|
|
116
|
+
for (const [key, raw] of Object.entries(input)) {
|
|
117
|
+
values[key] = interpolateEnvValue(raw, env, missing);
|
|
118
|
+
}
|
|
119
|
+
return { values, missing };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
|
|
123
|
+
const present = signals.filter((s): s is AbortSignal => Boolean(s));
|
|
124
|
+
if (present.length === 0) return new AbortController().signal;
|
|
125
|
+
if (present.length === 1) return present[0];
|
|
126
|
+
return AbortSignal.any(present);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function timeoutSignal(ms: number, parent?: AbortSignal): AbortSignal {
|
|
130
|
+
if (!Number.isFinite(ms) || ms <= 0) return parent ?? new AbortController().signal;
|
|
131
|
+
return combineSignals(parent, AbortSignal.timeout(ms));
|
|
132
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 30_000;
|
|
2
|
+
export const DEFAULT_MCP_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
3
|
+
export const DEFAULT_WEB_TIMEOUT_MS = 15_000;
|
|
4
|
+
export const DEFAULT_WEB_MAX_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
export const DEFAULT_WEB_MAX_CHARS = 32_000;
|
|
6
|
+
export const DEFAULT_SEARCH_RESULTS = 5;
|
|
7
|
+
export const MAX_SEARCH_RESULTS = 20;
|
|
8
|
+
export const DEFAULT_SUBAGENT_CONCURRENCY = 4;
|
|
9
|
+
export const MAX_PARALLEL_SUBAGENTS = 8;
|
|
10
|
+
export const DEFAULT_SUBAGENT_SPAWN_BUDGET = 16;
|
|
11
|
+
export const DEFAULT_SUBAGENT_OUTPUT_BYTES = 50 * 1024;
|
|
12
|
+
export const MAX_TOOL_RESULT_CHARS = 100_000;
|
|
13
|
+
export const WEB_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
14
|
+
export const WEB_CACHE_MAX_ENTRIES = 128;
|
|
15
|
+
export const WEB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
export const MAX_QUESTIONS = 4;
|
|
17
|
+
export const MIN_OPTIONS = 2;
|
|
18
|
+
export const MAX_OPTIONS = 4;
|
|
19
|
+
export const MAX_TODOS = 40;
|
|
20
|
+
export const CONNECT_TIMEOUT_MS = 20_000;
|