@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/examples/mcp.json +30 -0
  4. package/examples/pi-essentials.json +32 -0
  5. package/examples/pi-settings.json +5 -0
  6. package/package.json +88 -0
  7. package/skills/pi-essentials/SKILL.md +50 -0
  8. package/src/config.ts +351 -0
  9. package/src/errors.ts +96 -0
  10. package/src/index.ts +43 -0
  11. package/src/mcp/commands.ts +390 -0
  12. package/src/mcp/config.ts +157 -0
  13. package/src/mcp/credential-store.ts +153 -0
  14. package/src/mcp/index.ts +67 -0
  15. package/src/mcp/manager.ts +941 -0
  16. package/src/mcp/oauth.ts +262 -0
  17. package/src/mcp/proxy-tool.ts +213 -0
  18. package/src/mcp/render.ts +164 -0
  19. package/src/mcp/types.ts +63 -0
  20. package/src/paths.ts +48 -0
  21. package/src/questions/ask.ts +134 -0
  22. package/src/questions/index.ts +72 -0
  23. package/src/questions/render.ts +69 -0
  24. package/src/questions/validate.ts +85 -0
  25. package/src/security/env.ts +132 -0
  26. package/src/security/limits.ts +20 -0
  27. package/src/security/ssrf.ts +237 -0
  28. package/src/subagents/activity.ts +132 -0
  29. package/src/subagents/builtins/oracle.md +11 -0
  30. package/src/subagents/builtins/reviewer.md +11 -0
  31. package/src/subagents/builtins/scout.md +12 -0
  32. package/src/subagents/builtins/worker.md +11 -0
  33. package/src/subagents/discover.ts +54 -0
  34. package/src/subagents/herdr.ts +150 -0
  35. package/src/subagents/index.ts +642 -0
  36. package/src/subagents/inspector-tail.d.mts +1 -0
  37. package/src/subagents/inspector-tail.mjs +140 -0
  38. package/src/subagents/render.ts +464 -0
  39. package/src/subagents/runner.ts +468 -0
  40. package/src/subagents/schema.ts +107 -0
  41. package/src/subagents/types.ts +131 -0
  42. package/src/subagents/worktree.ts +131 -0
  43. package/src/todos/index.ts +170 -0
  44. package/src/todos/render.ts +198 -0
  45. package/src/todos/state.ts +310 -0
  46. package/src/ui/render.ts +215 -0
  47. package/src/web/activity.ts +91 -0
  48. package/src/web/cache.ts +153 -0
  49. package/src/web/extract.ts +75 -0
  50. package/src/web/fetch.ts +167 -0
  51. package/src/web/html-to-markdown.ts +284 -0
  52. package/src/web/http.ts +238 -0
  53. package/src/web/index.ts +214 -0
  54. package/src/web/providers/brave.ts +27 -0
  55. package/src/web/providers/duckduckgo.ts +60 -0
  56. package/src/web/providers/exa.ts +29 -0
  57. package/src/web/providers/jina.ts +25 -0
  58. package/src/web/providers/searxng.ts +29 -0
  59. package/src/web/providers/tavily.ts +31 -0
  60. package/src/web/providers/types.ts +75 -0
  61. package/src/web/render.ts +130 -0
  62. package/src/web/search.ts +108 -0
@@ -0,0 +1,75 @@
1
+ export interface SearchHit {
2
+ title: string;
3
+ url: string;
4
+ snippet: string;
5
+ source: string;
6
+ }
7
+
8
+ export interface SearchResult {
9
+ provider: string;
10
+ query: string;
11
+ hits: SearchHit[];
12
+ notes?: string;
13
+ }
14
+
15
+ export interface SearchOptions {
16
+ query: string;
17
+ /** Maximum hits to return. */
18
+ count: number;
19
+ /** Per-request timeout from `web.search.timeoutMs`. */
20
+ timeoutMs: number;
21
+ signal?: AbortSignal;
22
+ /** Private hosts the user explicitly trusts (for example a local SearXNG). */
23
+ allowedHosts?: ReadonlySet<string>;
24
+ }
25
+
26
+ export type SearchFn = (options: SearchOptions) => Promise<SearchResult>;
27
+
28
+ /** Response cap for search APIs; result payloads are small compared to pages. */
29
+ export const SEARCH_MAX_BYTES = 1024 * 1024;
30
+
31
+ /** Collapse whitespace and drop empty snippets so terminal output stays compact. */
32
+ export function cleanSnippet(value: string | undefined): string {
33
+ return (value ?? "").replace(/\s+/g, " ").trim();
34
+ }
35
+
36
+ /** Parse, canonicalize, and limit search results to fetchable web URLs. */
37
+ export function normalizeResultUrl(value: string | undefined): string | undefined {
38
+ if (!value) return undefined;
39
+ try {
40
+ const url = new URL(value.trim());
41
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
42
+ url.hash = "";
43
+ return url.href;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function toHits(
50
+ rows: Array<{ title?: string; url?: string; snippet?: string }>,
51
+ count: number,
52
+ source: string,
53
+ ): SearchHit[] {
54
+ const seen = new Set<string>();
55
+ const hits: SearchHit[] = [];
56
+ for (const row of rows) {
57
+ const url = normalizeResultUrl(row.url);
58
+ const title = cleanSnippet(row.title);
59
+ if (!url || !title || seen.has(url)) continue;
60
+ seen.add(url);
61
+ hits.push({ title, url, snippet: cleanSnippet(row.snippet), source });
62
+ if (hits.length >= count) break;
63
+ }
64
+ return hits;
65
+ }
66
+
67
+ /** Parse a JSON search response with a provider-specific error message on failure. */
68
+ export function parseJson<T>(text: string, provider: string): T {
69
+ try {
70
+ return JSON.parse(text) as T;
71
+ } catch {
72
+ const preview = text.replace(/\s+/g, " ").trim().slice(0, 200);
73
+ throw new Error(`${provider} returned a non-JSON response${preview ? `: ${preview}` : "."}`);
74
+ }
75
+ }
@@ -0,0 +1,130 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import {
4
+ body,
5
+ failLine,
6
+ firstText,
7
+ formatCount,
8
+ GLYPH,
9
+ meta,
10
+ okLine,
11
+ oneLine,
12
+ safeRender,
13
+ shortUrl,
14
+ titleLine,
15
+ type RenderableResult,
16
+ type RenderSlot,
17
+ } from "../ui/render.ts";
18
+ import type { SearchHit } from "./providers/types.ts";
19
+
20
+ interface SearchDetails {
21
+ provider?: string;
22
+ query?: string;
23
+ hits?: SearchHit[];
24
+ }
25
+
26
+ interface FetchDetails {
27
+ url?: string;
28
+ title?: string;
29
+ totalChars?: number;
30
+ truncated?: boolean;
31
+ cacheId?: string;
32
+ offset?: number;
33
+ }
34
+
35
+ export function renderSearchCall(
36
+ args: { query?: string; numResults?: number },
37
+ theme: Theme,
38
+ context: RenderSlot,
39
+ ): Text {
40
+ return safeRender(
41
+ () =>
42
+ titleLine(theme, "web_search", args?.query ? `"${oneLine(args.query, 56)}"` : undefined) +
43
+ meta(theme, [args?.numResults ? `${args.numResults} results` : undefined]),
44
+ "web_search",
45
+ context,
46
+ );
47
+ }
48
+
49
+ export function renderSearchResult(
50
+ result: RenderableResult<SearchDetails | undefined>,
51
+ options: { expanded: boolean; isPartial: boolean },
52
+ theme: Theme,
53
+ context: RenderSlot,
54
+ ): Text {
55
+ return safeRender(
56
+ () => {
57
+ if (options.isPartial) return theme.fg("muted", `${GLYPH.sep} searching…`);
58
+ if (context.isError) return failLine(theme, oneLine(firstText(result) || "search failed", 96));
59
+
60
+ const hits = result?.details?.hits ?? [];
61
+ if (hits.length === 0) {
62
+ return `${theme.fg("warning", GLYPH.pending)} ${theme.fg("muted", "no results")}`;
63
+ }
64
+
65
+ const header = okLine(theme, theme.fg("text", `${hits.length} results`));
66
+ const lines = hits.map(
67
+ (hit, index) =>
68
+ `${theme.fg("dim", `${index + 1}.`)} ${theme.fg("text", oneLine(hit.title, 60))}\n ${theme.fg("mdLinkUrl", shortUrl(hit.url, 64))}`,
69
+ );
70
+ return header + body(theme, lines, options.expanded, { limit: 3, indent: " ", noun: "result" });
71
+ },
72
+ oneLine(firstText(result), 120),
73
+ context,
74
+ );
75
+ }
76
+
77
+ export function renderFetchCall(
78
+ args: { url?: string; cacheId?: string; offset?: number; limit?: number },
79
+ theme: Theme,
80
+ context: RenderSlot,
81
+ ): Text {
82
+ return safeRender(
83
+ () => {
84
+ const subject = args?.url ? shortUrl(args.url, 58) : args?.cacheId ? `cache ${args.cacheId}` : undefined;
85
+ return (
86
+ titleLine(theme, "web_fetch", subject) +
87
+ meta(theme, [
88
+ args?.offset ? `from ${formatCount(args.offset)}` : undefined,
89
+ args?.limit ? `${formatCount(args.limit)} chars` : undefined,
90
+ ])
91
+ );
92
+ },
93
+ "web_fetch",
94
+ context,
95
+ );
96
+ }
97
+
98
+ export function renderFetchResult(
99
+ result: RenderableResult<FetchDetails | undefined>,
100
+ options: { expanded: boolean; isPartial: boolean },
101
+ theme: Theme,
102
+ context: RenderSlot,
103
+ ): Text {
104
+ return safeRender(
105
+ () => {
106
+ if (options.isPartial) return theme.fg("muted", `${GLYPH.sep} fetching…`);
107
+ if (context.isError) return failLine(theme, oneLine(firstText(result) || "fetch failed", 96));
108
+
109
+ const details = result?.details ?? {};
110
+ const title = details.title && details.title !== details.url ? details.title : shortUrl(details.url ?? "", 52);
111
+ const header =
112
+ okLine(theme, theme.fg("text", oneLine(title, 58))) +
113
+ meta(theme, [
114
+ details.totalChars ? `${formatCount(details.totalChars)} chars` : undefined,
115
+ details.truncated ? "truncated" : undefined,
116
+ details.cacheId ? `id ${details.cacheId}` : undefined,
117
+ ]);
118
+
119
+ // The result text carries the `# title / Source: / Cache-Id:` preamble;
120
+ // the header already says all of that, so preview the body instead.
121
+ const text = firstText(result);
122
+ const preview = text.split("\n").slice(4).filter((line) => line.trim().length > 0);
123
+ return header + body(theme, preview.map((line) => theme.fg("toolOutput", oneLine(line, 100))), options.expanded, {
124
+ limit: 2,
125
+ });
126
+ },
127
+ oneLine(firstText(result), 120),
128
+ context,
129
+ );
130
+ }
@@ -0,0 +1,108 @@
1
+ import type { ResolvedWebConfig, SearchProviderName } from "../config.ts";
2
+ import { isAbortError, PiEssentialsError } from "../errors.ts";
3
+ import { searchBrave } from "./providers/brave.ts";
4
+ import { searchDuckDuckGo } from "./providers/duckduckgo.ts";
5
+ import { searchExa } from "./providers/exa.ts";
6
+ import { searchJina } from "./providers/jina.ts";
7
+ import { searchSearxng } from "./providers/searxng.ts";
8
+ import { searchTavily } from "./providers/tavily.ts";
9
+ import type { SearchFn, SearchResult } from "./providers/types.ts";
10
+
11
+ export type ConcreteProvider = Exclude<SearchProviderName, "auto">;
12
+
13
+ /** Providers usable with the current configuration, best-first. */
14
+ export function availableProviders(config: ResolvedWebConfig["search"]): ConcreteProvider[] {
15
+ const names: ConcreteProvider[] = [];
16
+ if (config.searxngUrl) names.push("searxng");
17
+ if (config.braveApiKey) names.push("brave");
18
+ if (config.tavilyApiKey) names.push("tavily");
19
+ if (config.exaApiKey) names.push("exa");
20
+ if (config.jinaApiKey) names.push("jina");
21
+ names.push("duckduckgo");
22
+ return names;
23
+ }
24
+
25
+ export function providerFn(name: ConcreteProvider, config: ResolvedWebConfig["search"]): SearchFn {
26
+ switch (name) {
27
+ case "duckduckgo":
28
+ return searchDuckDuckGo;
29
+ case "brave":
30
+ if (!config.braveApiKey) {
31
+ throw new PiEssentialsError("Brave search requires web.search.braveApiKey or BRAVE_API_KEY.", "WEB_CONFIG");
32
+ }
33
+ return searchBrave(config.braveApiKey);
34
+ case "tavily":
35
+ if (!config.tavilyApiKey) {
36
+ throw new PiEssentialsError("Tavily search requires web.search.tavilyApiKey or TAVILY_API_KEY.", "WEB_CONFIG");
37
+ }
38
+ return searchTavily(config.tavilyApiKey);
39
+ case "exa":
40
+ if (!config.exaApiKey) {
41
+ throw new PiEssentialsError("Exa search requires web.search.exaApiKey or EXA_API_KEY.", "WEB_CONFIG");
42
+ }
43
+ return searchExa(config.exaApiKey);
44
+ case "jina":
45
+ return searchJina(config.jinaApiKey);
46
+ case "searxng":
47
+ if (!config.searxngUrl) {
48
+ throw new PiEssentialsError("SearXNG search requires web.search.searxngUrl or SEARXNG_URL.", "WEB_CONFIG");
49
+ }
50
+ return searchSearxng(config.searxngUrl);
51
+ default:
52
+ throw new PiEssentialsError(`Unknown search provider: ${String(name)}`, "WEB_CONFIG");
53
+ }
54
+ }
55
+
56
+ export async function runSearch(
57
+ query: string,
58
+ config: ResolvedWebConfig["search"],
59
+ requested: SearchProviderName | undefined,
60
+ count: number,
61
+ signal?: AbortSignal,
62
+ allowedHosts?: ReadonlySet<string>,
63
+ ): Promise<SearchResult> {
64
+ const options = { query, count, timeoutMs: config.timeoutMs, signal, allowedHosts };
65
+ const explicit = requested && requested !== "auto" ? requested : config.provider !== "auto" ? config.provider : undefined;
66
+ if (explicit) {
67
+ return providerFn(explicit, config)(options);
68
+ }
69
+
70
+ const chain = availableProviders(config);
71
+ let lastEmpty: SearchResult | undefined;
72
+ let failed = 0;
73
+ for (const name of chain) {
74
+ if (signal?.aborted) throw new PiEssentialsError("Search was cancelled.", "WEB_SEARCH_CANCELLED");
75
+ try {
76
+ const result = await providerFn(name, config)(options);
77
+ if (result.hits.length > 0) return result;
78
+ lastEmpty = result;
79
+ } catch (error) {
80
+ // Cancellation is a caller decision, not a provider failure. In
81
+ // particular, never continue the fallback chain after an aborted fetch.
82
+ if (signal?.aborted || isAbortError(error)) {
83
+ throw new PiEssentialsError("Search was cancelled.", "WEB_SEARCH_CANCELLED");
84
+ }
85
+ failed += 1;
86
+ }
87
+ }
88
+ if (lastEmpty) return lastEmpty;
89
+ throw new PiEssentialsError(failed > 0 ? "Search failed." : "No search results.", "WEB_SEARCH_FAILED", true);
90
+ }
91
+
92
+ export function formatSearch(result: SearchResult): string {
93
+ const lines = [`Search for "${result.query}":`, ""];
94
+ if (result.hits.length === 0) {
95
+ lines.push("No results.");
96
+ }
97
+ result.hits.forEach((hit, index) => {
98
+ lines.push(`${index + 1}. ${hit.title}`);
99
+ lines.push(` ${hit.url}`);
100
+ if (hit.snippet) {
101
+ const snippet = hit.snippet.length > 240 ? `${hit.snippet.slice(0, 239).trimEnd()}…` : hit.snippet;
102
+ lines.push(` ${snippet}`);
103
+ }
104
+ lines.push("");
105
+ });
106
+ if (result.notes) lines.push(result.notes);
107
+ return lines.join("\n").trim();
108
+ }