@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/config.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MCP_IDLE_TIMEOUT_MS,
|
|
5
|
+
DEFAULT_MCP_REQUEST_TIMEOUT_MS,
|
|
6
|
+
DEFAULT_SEARCH_RESULTS,
|
|
7
|
+
DEFAULT_SUBAGENT_CONCURRENCY,
|
|
8
|
+
DEFAULT_SUBAGENT_OUTPUT_BYTES,
|
|
9
|
+
DEFAULT_SUBAGENT_SPAWN_BUDGET,
|
|
10
|
+
DEFAULT_WEB_MAX_BYTES,
|
|
11
|
+
DEFAULT_WEB_MAX_CHARS,
|
|
12
|
+
DEFAULT_WEB_TIMEOUT_MS,
|
|
13
|
+
MAX_PARALLEL_SUBAGENTS,
|
|
14
|
+
} from "./security/limits.ts";
|
|
15
|
+
import { getProjectConfigPath, getUserConfigPath } from "./paths.ts";
|
|
16
|
+
import { normalizeAllowedHost } from "./security/ssrf.ts";
|
|
17
|
+
|
|
18
|
+
export type FeatureToggle = boolean | { enabled?: boolean };
|
|
19
|
+
|
|
20
|
+
export type SearchProviderName = "auto" | "duckduckgo" | "brave" | "tavily" | "exa" | "jina" | "searxng";
|
|
21
|
+
|
|
22
|
+
export interface McpFeatureConfig {
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
requestTimeoutMs?: number;
|
|
25
|
+
idleTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WebSearchConfig {
|
|
29
|
+
provider?: SearchProviderName;
|
|
30
|
+
braveApiKey?: string;
|
|
31
|
+
tavilyApiKey?: string;
|
|
32
|
+
exaApiKey?: string;
|
|
33
|
+
jinaApiKey?: string;
|
|
34
|
+
searxngUrl?: string;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
maxResults?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WebFetchConfig {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
maxBytes?: number;
|
|
42
|
+
maxChars?: number;
|
|
43
|
+
jinaFallback?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WebFeatureConfig {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
search?: WebSearchConfig;
|
|
49
|
+
fetch?: WebFetchConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Private hosts the user deliberately trusts, as `host` or `host:port`.
|
|
52
|
+
* Needed for self-hosted services such as a local SearXNG instance, which the
|
|
53
|
+
* private-address guard would otherwise block.
|
|
54
|
+
*/
|
|
55
|
+
allowedHosts?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SubagentsFeatureConfig {
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
maxConcurrency?: number;
|
|
61
|
+
maxParallel?: number;
|
|
62
|
+
maxOutputBytes?: number;
|
|
63
|
+
spawnBudget?: number;
|
|
64
|
+
allowNested?: boolean;
|
|
65
|
+
/** Let the fleet/inspector open a running child in an external Herdr pane, if `herdr` is on PATH. */
|
|
66
|
+
herdr?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PiEssentialsFile {
|
|
70
|
+
mcp?: boolean | McpFeatureConfig;
|
|
71
|
+
web?: boolean | WebFeatureConfig;
|
|
72
|
+
subagents?: boolean | SubagentsFeatureConfig;
|
|
73
|
+
todos?: boolean | { enabled?: boolean };
|
|
74
|
+
questions?: boolean | { enabled?: boolean };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResolvedMcpConfig {
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
requestTimeoutMs: number;
|
|
80
|
+
idleTimeoutMs: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ResolvedWebConfig {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
allowedHosts: ReadonlySet<string>;
|
|
86
|
+
search: {
|
|
87
|
+
provider: SearchProviderName;
|
|
88
|
+
braveApiKey?: string;
|
|
89
|
+
tavilyApiKey?: string;
|
|
90
|
+
exaApiKey?: string;
|
|
91
|
+
jinaApiKey?: string;
|
|
92
|
+
searxngUrl?: string;
|
|
93
|
+
timeoutMs: number;
|
|
94
|
+
maxResults: number;
|
|
95
|
+
};
|
|
96
|
+
fetch: {
|
|
97
|
+
timeoutMs: number;
|
|
98
|
+
maxBytes: number;
|
|
99
|
+
maxChars: number;
|
|
100
|
+
jinaFallback: boolean;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ResolvedSubagentsConfig {
|
|
105
|
+
enabled: boolean;
|
|
106
|
+
maxConcurrency: number;
|
|
107
|
+
maxParallel: number;
|
|
108
|
+
maxOutputBytes: number;
|
|
109
|
+
spawnBudget: number;
|
|
110
|
+
allowNested: boolean;
|
|
111
|
+
herdr: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ResolvedConfig {
|
|
115
|
+
mcp: ResolvedMcpConfig;
|
|
116
|
+
web: ResolvedWebConfig;
|
|
117
|
+
subagents: ResolvedSubagentsConfig;
|
|
118
|
+
todos: { enabled: boolean };
|
|
119
|
+
questions: { enabled: boolean };
|
|
120
|
+
warnings: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const DEFAULTS: PiEssentialsFile = {
|
|
124
|
+
mcp: true,
|
|
125
|
+
web: true,
|
|
126
|
+
subagents: true,
|
|
127
|
+
todos: true,
|
|
128
|
+
questions: true,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
132
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function enabledFrom(flag: unknown, fallback: boolean): boolean {
|
|
136
|
+
if (typeof flag === "boolean") return flag;
|
|
137
|
+
if (isObject(flag) && typeof flag.enabled === "boolean") return flag.enabled;
|
|
138
|
+
return fallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function positiveInt(value: unknown, fallback: number, min = 1, max = Number.MAX_SAFE_INTEGER): number {
|
|
142
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
143
|
+
const n = Math.floor(value);
|
|
144
|
+
if (n < min) return min;
|
|
145
|
+
if (n > max) return max;
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function optionalString(value: unknown): string | undefined {
|
|
150
|
+
if (typeof value !== "string") return undefined;
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readJsonFile(filePath: string, warnings: string[]): unknown {
|
|
156
|
+
if (!fs.existsSync(filePath)) return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
159
|
+
return JSON.parse(raw) as unknown;
|
|
160
|
+
} catch {
|
|
161
|
+
warnings.push(`Ignoring malformed config at ${filePath}; using defaults for that file.`);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function asFile(value: unknown, filePath: string, warnings: string[]): PiEssentialsFile {
|
|
167
|
+
if (value === undefined) return {};
|
|
168
|
+
if (!isObject(value)) {
|
|
169
|
+
warnings.push(`Ignoring ${filePath}: expected a JSON object at the top level.`);
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
const known = new Set<string>(FEATURE_KEYS);
|
|
173
|
+
for (const key of Object.keys(value)) {
|
|
174
|
+
if (!known.has(key)) {
|
|
175
|
+
warnings.push(`Unknown option "${key}" in ${filePath}; expected one of ${[...known].join(", ")}.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return value as PiEssentialsFile;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const FEATURE_KEYS = ["mcp", "web", "subagents", "todos", "questions"] as const;
|
|
182
|
+
|
|
183
|
+
/** Layer one config file over another; later files win, objects merge key by key. */
|
|
184
|
+
export function mergeFiles(base: PiEssentialsFile, overlay: PiEssentialsFile): PiEssentialsFile {
|
|
185
|
+
const merged: PiEssentialsFile = { ...base };
|
|
186
|
+
for (const key of FEATURE_KEYS) {
|
|
187
|
+
const next = overlay[key];
|
|
188
|
+
if (next === undefined) continue;
|
|
189
|
+
(merged as Record<string, unknown>)[key] = mergeMaybeObject(base[key], next);
|
|
190
|
+
}
|
|
191
|
+
return merged;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function mergeMaybeObject<T>(base: boolean | T | undefined, overlay: boolean | T): boolean | T {
|
|
195
|
+
if (typeof overlay === "boolean" || typeof base === "boolean" || base === undefined) return overlay;
|
|
196
|
+
if (isObject(base) && isObject(overlay)) return deepMerge(base, overlay) as T;
|
|
197
|
+
return overlay;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
|
201
|
+
const out: Record<string, unknown> = { ...base };
|
|
202
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
203
|
+
const existing = out[key];
|
|
204
|
+
out[key] = isObject(existing) && isObject(value) ? deepMerge(existing, value) : value;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function envOr(configValue: string | undefined, envName: string): string | undefined {
|
|
210
|
+
return configValue || optionalString(process.env[envName]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the private-host allowlist. A configured SearXNG instance is trusted
|
|
215
|
+
* implicitly: pointing `searxngUrl` at localhost is the normal way to run one,
|
|
216
|
+
* and silently blocking it would make the setting useless.
|
|
217
|
+
*/
|
|
218
|
+
function resolveAllowedHosts(
|
|
219
|
+
configured: unknown,
|
|
220
|
+
searxngUrl: string | undefined,
|
|
221
|
+
warnings: string[],
|
|
222
|
+
): ReadonlySet<string> {
|
|
223
|
+
const hosts = new Set<string>();
|
|
224
|
+
const add = (entry: string, source: string) => {
|
|
225
|
+
const normalized = normalizeAllowedHost(entry);
|
|
226
|
+
if (normalized) hosts.add(normalized);
|
|
227
|
+
else warnings.push(`Ignoring unparseable ${source} entry "${entry}".`);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (configured !== undefined) {
|
|
231
|
+
if (Array.isArray(configured)) {
|
|
232
|
+
for (const entry of configured) {
|
|
233
|
+
if (typeof entry === "string") add(entry, "web.allowedHosts");
|
|
234
|
+
else warnings.push("web.allowedHosts entries must be strings.");
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
warnings.push("web.allowedHosts must be an array of host or host:port strings.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (searxngUrl) add(searxngUrl, "web.search.searxngUrl");
|
|
241
|
+
return hosts;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resolveConfig(file: PiEssentialsFile, warnings: string[] = []): ResolvedConfig {
|
|
245
|
+
const mcpIn = file.mcp ?? true;
|
|
246
|
+
const webIn = file.web ?? true;
|
|
247
|
+
const subIn = file.subagents ?? true;
|
|
248
|
+
const mcpObj = isObject(mcpIn) ? mcpIn : {};
|
|
249
|
+
const webObj = isObject(webIn) ? webIn : {};
|
|
250
|
+
const subObj = isObject(subIn) ? subIn : {};
|
|
251
|
+
const search = isObject(webObj.search) ? webObj.search : {};
|
|
252
|
+
const fetch = isObject(webObj.fetch) ? webObj.fetch : {};
|
|
253
|
+
|
|
254
|
+
const searxngUrl = optionalString(search.searxngUrl) ?? optionalString(process.env.SEARXNG_URL);
|
|
255
|
+
const providerRaw = optionalString(search.provider) ?? "auto";
|
|
256
|
+
const providers: SearchProviderName[] = ["auto", "duckduckgo", "brave", "tavily", "exa", "jina", "searxng"];
|
|
257
|
+
const provider = (providers.includes(providerRaw as SearchProviderName) ? providerRaw : "auto") as SearchProviderName;
|
|
258
|
+
if (providerRaw !== provider) {
|
|
259
|
+
warnings.push(`Unknown search provider "${providerRaw}"; using auto.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
mcp: {
|
|
264
|
+
enabled: enabledFrom(mcpIn, true),
|
|
265
|
+
requestTimeoutMs: positiveInt(mcpObj.requestTimeoutMs, DEFAULT_MCP_REQUEST_TIMEOUT_MS, 1000, 300_000),
|
|
266
|
+
idleTimeoutMs: positiveInt(mcpObj.idleTimeoutMs, DEFAULT_MCP_IDLE_TIMEOUT_MS, 1000, 24 * 60 * 60_000),
|
|
267
|
+
},
|
|
268
|
+
web: {
|
|
269
|
+
enabled: enabledFrom(webIn, true),
|
|
270
|
+
allowedHosts: resolveAllowedHosts(webObj.allowedHosts, searxngUrl, warnings),
|
|
271
|
+
search: {
|
|
272
|
+
provider,
|
|
273
|
+
braveApiKey: envOr(optionalString(search.braveApiKey), "BRAVE_API_KEY"),
|
|
274
|
+
tavilyApiKey: envOr(optionalString(search.tavilyApiKey), "TAVILY_API_KEY"),
|
|
275
|
+
exaApiKey: envOr(optionalString(search.exaApiKey), "EXA_API_KEY"),
|
|
276
|
+
jinaApiKey: envOr(optionalString(search.jinaApiKey), "JINA_API_KEY"),
|
|
277
|
+
searxngUrl,
|
|
278
|
+
timeoutMs: positiveInt(search.timeoutMs, DEFAULT_WEB_TIMEOUT_MS, 1000, 60_000),
|
|
279
|
+
maxResults: positiveInt(search.maxResults, DEFAULT_SEARCH_RESULTS, 1, 20),
|
|
280
|
+
},
|
|
281
|
+
fetch: {
|
|
282
|
+
timeoutMs: positiveInt(fetch.timeoutMs, DEFAULT_WEB_TIMEOUT_MS, 1000, 60_000),
|
|
283
|
+
maxBytes: positiveInt(fetch.maxBytes, DEFAULT_WEB_MAX_BYTES, 16_384, 8 * 1024 * 1024),
|
|
284
|
+
maxChars: positiveInt(fetch.maxChars, DEFAULT_WEB_MAX_CHARS, 1000, 200_000),
|
|
285
|
+
jinaFallback: fetch.jinaFallback !== false,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
subagents: {
|
|
289
|
+
enabled: enabledFrom(subIn, true),
|
|
290
|
+
maxConcurrency: positiveInt(subObj.maxConcurrency, DEFAULT_SUBAGENT_CONCURRENCY, 1, MAX_PARALLEL_SUBAGENTS),
|
|
291
|
+
maxParallel: positiveInt(subObj.maxParallel, MAX_PARALLEL_SUBAGENTS, 1, MAX_PARALLEL_SUBAGENTS),
|
|
292
|
+
maxOutputBytes: positiveInt(subObj.maxOutputBytes, DEFAULT_SUBAGENT_OUTPUT_BYTES, 1024, 1024 * 1024),
|
|
293
|
+
spawnBudget: positiveInt(subObj.spawnBudget, DEFAULT_SUBAGENT_SPAWN_BUDGET, 1, 64),
|
|
294
|
+
allowNested: subObj.allowNested === true,
|
|
295
|
+
herdr: subObj.herdr !== false,
|
|
296
|
+
},
|
|
297
|
+
todos: { enabled: enabledFrom(file.todos ?? true, true) },
|
|
298
|
+
questions: { enabled: enabledFrom(file.questions ?? true, true) },
|
|
299
|
+
warnings,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function loadConfig(cwd: string = process.cwd()): ResolvedConfig {
|
|
304
|
+
const warnings: string[] = [];
|
|
305
|
+
const userRaw = readJsonFile(getUserConfigPath(), warnings);
|
|
306
|
+
const projectRaw = readJsonFile(getProjectConfigPath(cwd), warnings);
|
|
307
|
+
const merged = mergeFiles(
|
|
308
|
+
mergeFiles(DEFAULTS, asFile(userRaw, getUserConfigPath(), warnings)),
|
|
309
|
+
asFile(projectRaw, getProjectConfigPath(cwd), warnings),
|
|
310
|
+
);
|
|
311
|
+
return resolveConfig(merged, warnings);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function warnConfig(warnings: string[], notify?: (message: string) => void): void {
|
|
315
|
+
for (const warning of warnings) {
|
|
316
|
+
if (notify) notify(warning);
|
|
317
|
+
else console.warn(`[pi-essentials] ${warning}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function ensureDir(dir: string, mode = 0o700): void {
|
|
322
|
+
fs.mkdirSync(dir, { recursive: true, mode });
|
|
323
|
+
try {
|
|
324
|
+
fs.chmodSync(dir, mode);
|
|
325
|
+
} catch {
|
|
326
|
+
// Best-effort on platforms that ignore chmod.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let tmpCounter = 0;
|
|
331
|
+
|
|
332
|
+
export function writePrivateFile(filePath: string, contents: string): void {
|
|
333
|
+
ensureDir(path.dirname(filePath));
|
|
334
|
+
const tmp = `${filePath}.${process.pid}.${tmpCounter++}.tmp`;
|
|
335
|
+
try {
|
|
336
|
+
fs.writeFileSync(tmp, contents, { encoding: "utf8", mode: 0o600 });
|
|
337
|
+
fs.renameSync(tmp, filePath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
try {
|
|
340
|
+
fs.rmSync(tmp, { force: true });
|
|
341
|
+
} catch {
|
|
342
|
+
// Best-effort cleanup of the partial temp file.
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
fs.chmodSync(filePath, 0o600);
|
|
348
|
+
} catch {
|
|
349
|
+
// Best-effort.
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export class PiEssentialsError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly retryable: boolean;
|
|
6
|
+
|
|
7
|
+
constructor(message: string, code = "PI_ESSENTIALS_ERROR", retryable = false) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "PiEssentialsError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.retryable = retryable;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function errorMessage(error: unknown): string {
|
|
16
|
+
if (error instanceof Error) return error.message;
|
|
17
|
+
if (typeof error === "string") return error;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(error);
|
|
20
|
+
} catch {
|
|
21
|
+
return String(error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Abort/timeout detection. Deliberately narrow: a remote "504 Gateway Timeout"
|
|
27
|
+
* body must not look like a local cancellation, or callers skip their fallbacks.
|
|
28
|
+
*/
|
|
29
|
+
export function isAbortError(error: unknown): boolean {
|
|
30
|
+
if (!error || typeof error !== "object") return false;
|
|
31
|
+
const name = "name" in error ? String((error as { name?: unknown }).name) : "";
|
|
32
|
+
if (name === "AbortError" || name === "TimeoutError") return true;
|
|
33
|
+
if ((error as { code?: unknown }).code === "ABORT_ERR") return true;
|
|
34
|
+
const message = errorMessage(error).toLowerCase();
|
|
35
|
+
if (/^http \d{3}\b/.test(message)) return false;
|
|
36
|
+
return (
|
|
37
|
+
message.includes("the operation was aborted") ||
|
|
38
|
+
message.includes("was cancelled or timed out") ||
|
|
39
|
+
message.includes("this operation was aborted") ||
|
|
40
|
+
message.includes("request aborted")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toolText(text: string, details: object = {}, usage?: Usage) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text" as const, text }],
|
|
47
|
+
details,
|
|
48
|
+
...(usage ? { usage } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fail a tool call.
|
|
54
|
+
*
|
|
55
|
+
* Pi only marks a tool result `isError: true` when `execute` throws, so every
|
|
56
|
+
* genuine failure — bad arguments included — must raise rather than return
|
|
57
|
+
* prose that merely starts with "Error:".
|
|
58
|
+
*/
|
|
59
|
+
export function toolFailure(message: string, code = "PI_ESSENTIALS_ERROR"): never {
|
|
60
|
+
throw new PiEssentialsError(message, code);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Truncate to a UTF-8 byte budget without splitting a multi-byte character. */
|
|
64
|
+
export function capBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
65
|
+
const limit = Math.max(16, Math.floor(maxBytes));
|
|
66
|
+
const buffer = Buffer.from(text, "utf8");
|
|
67
|
+
if (buffer.byteLength <= limit) return { text, truncated: false };
|
|
68
|
+
|
|
69
|
+
// Reserve room for the marker itself, then walk back off any continuation
|
|
70
|
+
// byte (0b10xxxxxx) so the cut lands on a character boundary.
|
|
71
|
+
let end = limit;
|
|
72
|
+
let marker = "";
|
|
73
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
74
|
+
marker = `\n\n[Truncated: ${buffer.byteLength - end} of ${buffer.byteLength} bytes omitted.]`;
|
|
75
|
+
let next = Math.max(0, limit - Buffer.byteLength(marker, "utf8"));
|
|
76
|
+
while (next > 0 && (buffer[next] & 0xc0) === 0x80) next -= 1;
|
|
77
|
+
if (next === end) break;
|
|
78
|
+
end = next;
|
|
79
|
+
}
|
|
80
|
+
marker = `\n\n[Truncated: ${buffer.byteLength - end} of ${buffer.byteLength} bytes omitted.]`;
|
|
81
|
+
if (Buffer.byteLength(marker, "utf8") > limit) {
|
|
82
|
+
marker = "\n\n[Truncated]".slice(0, limit);
|
|
83
|
+
end = 0;
|
|
84
|
+
}
|
|
85
|
+
return { text: `${buffer.subarray(0, end).toString("utf8")}${marker}`, truncated: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function capText(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
89
|
+
const limit = Math.max(1, Math.floor(maxChars));
|
|
90
|
+
if (text.length <= limit) return { text, truncated: false };
|
|
91
|
+
const omitted = text.length - limit;
|
|
92
|
+
return {
|
|
93
|
+
text: `${text.slice(0, limit)}\n\n[Truncated: ${omitted} characters omitted. Request another slice if you need more.]`,
|
|
94
|
+
truncated: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, warnConfig } from "./config.ts";
|
|
3
|
+
import { registerMcp } from "./mcp/index.ts";
|
|
4
|
+
import { registerQuestions } from "./questions/index.ts";
|
|
5
|
+
import { registerSubagents } from "./subagents/index.ts";
|
|
6
|
+
import { registerTodos } from "./todos/index.ts";
|
|
7
|
+
import { registerWeb } from "./web/index.ts";
|
|
8
|
+
|
|
9
|
+
export type { PiEssentialsFile, ResolvedConfig } from "./config.ts";
|
|
10
|
+
export { loadConfig, resolveConfig } from "./config.ts";
|
|
11
|
+
|
|
12
|
+
/** Depth beyond which a subagent may no longer spawn further subagents. */
|
|
13
|
+
export const MAX_NEST_DEPTH = 2;
|
|
14
|
+
|
|
15
|
+
export function readNestDepth(env: NodeJS.ProcessEnv = process.env): number {
|
|
16
|
+
const parsed = Number.parseInt(env.PI_ESSENTIALS_NEST_DEPTH ?? "0", 10);
|
|
17
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function piEssentials(pi: ExtensionAPI): void {
|
|
21
|
+
const config = loadConfig(process.cwd());
|
|
22
|
+
|
|
23
|
+
const nested = process.env.PI_ESSENTIALS_SUBAGENT === "1";
|
|
24
|
+
const nestDepth = readNestDepth();
|
|
25
|
+
|
|
26
|
+
// Surface config problems through the UI once a session exists; writing to the
|
|
27
|
+
// console during extension load corrupts TUI rendering.
|
|
28
|
+
let warned = false;
|
|
29
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
30
|
+
if (warned || config.warnings.length === 0) return;
|
|
31
|
+
warned = true;
|
|
32
|
+
if (ctx.hasUI) warnConfig(config.warnings, (message) => ctx.ui.notify(`pi-essentials: ${message}`, "warning"));
|
|
33
|
+
else warnConfig(config.warnings);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (config.mcp.enabled && !nested) registerMcp(pi, config);
|
|
37
|
+
if (config.web.enabled) registerWeb(pi, config);
|
|
38
|
+
if (config.subagents.enabled && (!nested || (config.subagents.allowNested && nestDepth < MAX_NEST_DEPTH))) {
|
|
39
|
+
registerSubagents(pi, config);
|
|
40
|
+
}
|
|
41
|
+
if (config.todos.enabled && !nested) registerTodos(pi);
|
|
42
|
+
if (config.questions.enabled && !nested) registerQuestions(pi);
|
|
43
|
+
}
|