@fradser/pi-context 0.5.1 → 0.6.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Context Pi Package
2
2
 
3
- **Version:** 0.5.0
3
+ **Version:** 0.5.1
4
4
 
5
- Retrieve code context for any repo, library, or natural-language query via 5 methods: DeepWiki, Context7, Exa, git clone, and web search+fetch.
5
+ `@fradser/pi-context` exposes one tool: `context_get`.
6
6
 
7
- The package does **not** ship a skill. Native tool guidance is injected into the system prompt, and the `/context` command loads the full workflow into the current turn.
7
+ It starts an independent Pi child process for repository, library, codebase, and technical-topic research. This isolates exploratory tool calls from the main session while returning a concise, evidence-based result.
8
8
 
9
9
  ## Installation
10
10
 
@@ -16,76 +16,39 @@ pi install /path/to/pi-packages/packages/context
16
16
 
17
17
  ## Usage
18
18
 
19
- Run `/context` followed by a natural-language question, repository slug, library name, or several targets. For example:
19
+ Use natural language to ask Pi for research or external context:
20
20
 
21
21
  ```text
22
- /context React 19 server actions error handling
23
- /context facebook/react
24
- /context facebook/react zustand --method=deepwiki,context7
22
+ Research how facebook/react implements server actions.
23
+ Compare the current React Router and Next.js data-loading guidance.
24
+ Investigate https://github.com/owner/repo and explain its architecture.
25
25
  ```
26
26
 
27
- `--method=` accepts `deepwiki,context7,exa,clone,web,all` (comma-separated). With no target, the command reads dependency manifests in the current directory (`package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`) and uses detected dependencies as targets.
27
+ Pi recognizes these requests and invokes `context_get` automatically. Users do not need to type `context_get` or use a slash command directly; it is an internal native tool with one `query` parameter containing the context request.
28
28
 
29
- The full workflow (target classification, per-method process, fallbacks, selection guide) lives in `references/workflow.md` and is injected into the turn when `/context` runs. Lightweight tool-selection guidance is always present in the system prompt.
29
+ ## Child-process constraints
30
30
 
31
- ## Runtime requirements
31
+ The child runs as:
32
32
 
33
- | Method | Availability | Notes |
34
- |--------|--------------|-------|
35
- | DeepWiki | Always (native tool `context_deepwiki`) | No API key required |
36
- | Context7 | Always (native tool `context_context7`) | Optional `CONTEXT7_API_KEY` (Bearer) for per-key quota |
37
- | Exa | Native tool `context_exa` | Requires `EXA_API_KEY` env var |
38
- | Git clone | Always | Pi `bash` + `read` |
39
- | Web fetch | Always | `curl`/HTTP via `bash` |
40
-
41
- All three retrieval tools are registered by this package's root extension entry (`index.ts`) and call the public REST APIs directly — no MCP servers needed.
42
-
43
- ## Methods (summary)
44
-
45
- 1. **DeepWiki** — AI-generated repo docs via `context_deepwiki` (`mode: structure | contents | ask`).
46
- 2. **Context7** — up-to-date library docs via `context_context7` (`query` + optional `topic`).
47
- 3. **Exa** — web/code search via `context_exa` (`query` + optional `numResults`, requires `EXA_API_KEY`).
48
- 4. **Git clone** — `git clone --depth=1` to `/tmp`, `read` key files, clean up.
49
- 5. **Web search+fetch** — `bash` + `curl` against official docs, GitHub issues, changelogs.
50
-
51
- If a native tool is unavailable or errors, fall back to clone/web. The `/context` workflow reference contains the full selection matrix and per-method process.
33
+ ```text
34
+ pi --print --mode json --no-session --tools read,bash --exclude-tools edit,write
35
+ ```
52
36
 
53
- ## Optional manual brief
37
+ The child starts in a unique temporary working directory under `/tmp`. It is instructed to use only read-only investigation. If line-level evidence from a public repository is necessary, it may run `git clone --depth=1` in that directory, inspect the clone, and removes the entire temporary directory before completing. It must not modify the caller's working directory or run package-management, deployment, or interactive commands.
54
38
 
55
- `agents/context-researcher.md` is an optional manual prompt brief for an isolated research pass. Pi does not load it as an invocable agent; use the `/context` command for the executable workflow.
39
+ Results are bounded before entering the main session and render as compact, expandable context lifecycle rows.
56
40
 
57
41
  ## Structure
58
42
 
59
- ```
43
+ ```text
60
44
  context/
61
- ├── index.ts # Package-root extension entry point
62
- ├── extensions/
63
- │ ├── context-tools.ts # Native pi tools: context_deepwiki / context_context7 / context_exa
64
- │ └── context-command.ts # /context command + system-prompt guidance
65
- ├── references/
66
- │ └── workflow.md # Full workflow injected by /context
67
- ├── agents/
68
- │ └── context-researcher.md # Isolated research brief (manual)
69
- └── README.md
45
+ ├── index.ts
46
+ ├── extensions/context-tools.ts
47
+ ├── extensions/context-command.ts
48
+ ├── references/workflow.md
49
+ └── agents/context-researcher.md
70
50
  ```
71
51
 
72
- ## Prerequisites
73
-
74
- - Pi with extensions + custom tools
75
- - Network access for external lookups
76
- - `EXA_API_KEY` for the Exa method (optional for the other four)
77
-
78
- ## Best Practices
79
-
80
- - **Local first**: check local context (package.json, imports) before external lookups.
81
- - **Combine methods**: DeepWiki / clone for architecture, Context7 for API surface, Exa / web for community patterns.
82
- - **Verify sources**: cross-reference fetched content against actual code.
83
- - **Clean up**: remove `/tmp` clones when finished.
84
-
85
52
  ## License
86
53
 
87
54
  MIT
88
-
89
- ## Author
90
-
91
- Frad LEE (fradser@gmail.com)
@@ -1,36 +1,3 @@
1
- ---
2
- name: context-researcher
3
- description: Research a library, repository, or code pattern without polluting the main conversation. Uses the native context_deepwiki/context_context7/context_exa tools when applicable, otherwise git clone + HTTP fetch via Pi bash/read. Accepts natural-language queries, repo slugs, library names, or any combination.
4
- ---
1
+ # Context researcher
5
2
 
6
- # Context researcher (optional agent brief)
7
-
8
- Pi does not load Claude Code agent files automatically. Treat this file as a **prompt brief** when you want an isolated research pass. Prefer the `/context` command for the executable workflow.
9
-
10
- ## Runtime requirements
11
-
12
- | Method | Requirement |
13
- |--------|-------------|
14
- | DeepWiki | Native `context_deepwiki` tool |
15
- | Context7 | Native `context_context7` tool |
16
- | Exa | Native `context_exa` tool (+ optional `EXA_API_KEY`) |
17
- | Git clone | `git` + Pi `bash` / `read` (always) |
18
- | Web | `curl` or host HTTP via Pi `bash` (always) |
19
-
20
- If a native context tool is unavailable or fails, skip that method, use clone + web, and report which methods ran and why the fallback was needed.
21
-
22
- ## Process
23
-
24
- 1. **Parse the request** — targets and optional `--method=` list (see `/context`).
25
- 2. **Classify** each target (repo / library / natural-language).
26
- 3. **Select methods** per the `/context` workflow's selection guide.
27
- 4. **Execute with Pi tools** — use the applicable native context tools (`context_deepwiki`, `context_context7`, `context_exa`) plus `bash` and `read`. Summarize intermediates; do not dump raw payloads.
28
- 5. **Return** a concise architecture/API/examples summary only.
29
-
30
- ## Output format
31
-
32
- - Target classification
33
- - Methods used (and skipped, with reason)
34
- - Key findings (bullets)
35
- - Copyable snippets only when necessary
36
- - Open questions / confidence notes
3
+ `context_get` is the package's sole Pi tool. It delegates a research request to a fresh read-only Pi process with only `read` and `bash` available. The child may make a depth-1 clone of a public repository in a unique `/tmp` directory when source inspection is needed, then must remove that directory before completing.
@@ -1,121 +1,15 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
 
6
- const PKG_DIR =
7
- typeof __dirname === "string"
8
- ? __dirname
9
- : dirname(fileURLToPath(import.meta.url));
10
-
11
- const WORKFLOW_PATH = join(PKG_DIR, "..", "references", "workflow.md");
12
-
13
- const METHOD_FLAG = "--method=";
14
- const ALL_METHODS = ["deepwiki", "context7", "exa", "clone", "web", "all"] as const;
15
- const MANIFEST_FILES = ["package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
16
-
17
3
  const CONTEXT_GUIDANCE = `
18
- ## Context retrieval
4
+ ## Isolated research
19
5
 
20
- - Use \`context_deepwiki\` for public GitHub repo architecture and DeepWiki Q&A.
21
- - Use \`context_context7\` for up-to-date library/API docs (optionally focused by topic).
22
- - Use \`context_exa\` for web/code search when EXA_API_KEY is set; otherwise fall back to \`bash\` + \`curl\`.
23
- - Use \`git clone --depth=1\` to /tmp plus \`read\`/\`bash\` for private repos or deep inspection; always clean up.
24
- - Prefer small intermediate summaries over dumping raw API payloads into the conversation.
25
- - The \`/context <targets...> [--method=...]\` command loads the full workflow when the user asks to research or understand code.
6
+ When the user asks to research, investigate, compare, or understand an external repository, library, codebase, or current technical topic, call \`context_get\`. It starts a separate read-only Pi process and keeps research work out of the main session. The child may inspect public repositories with a depth-1 temporary clone under /tmp.
26
7
  `;
27
8
 
28
- interface ParsedArgs {
29
- targets: string[];
30
- methods: string[];
31
- }
32
-
33
- function parseArgs(raw: string): ParsedArgs {
34
- const targets: string[] = [];
35
- let methods: string[] = ["all"];
36
- const tokens = raw.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
37
- for (const token of tokens) {
38
- if (token.startsWith(METHOD_FLAG)) {
39
- methods = token
40
- .slice(METHOD_FLAG.length)
41
- .split(",")
42
- .map((m) => m.trim().toLowerCase())
43
- .filter((m) => (ALL_METHODS as readonly string[]).includes(m));
44
- if (methods.length === 0) methods = ["all"];
45
- continue;
46
- }
47
- targets.push(token.replace(/^"|"$/g, ""));
48
- }
49
- return { targets, methods };
50
- }
51
-
52
- function detectDependencies(cwd: string): string[] {
53
- const deps = new Set<string>();
54
- for (const file of MANIFEST_FILES) {
55
- try {
56
- const text = readFileSync(join(cwd, file), "utf8");
57
- if (file === "package.json") {
58
- const json = JSON.parse(text) as {
59
- dependencies?: Record<string, string>;
60
- devDependencies?: Record<string, string>;
61
- };
62
- for (const name of Object.keys(json.dependencies ?? {})) deps.add(name);
63
- for (const name of Object.keys(json.devDependencies ?? {})) deps.add(name);
64
- } else if (file === "pyproject.toml") {
65
- for (const match of text.matchAll(/^\s*"([A-Za-z0-9_.-]+)"\s*[>=<~!]/gm)) {
66
- deps.add(match[1]);
67
- }
68
- } else if (file === "go.mod") {
69
- for (const match of text.matchAll(/^\s*([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\s+v/gm)) {
70
- deps.add(match[1]);
71
- }
72
- } else if (file === "Cargo.toml") {
73
- for (const match of text.matchAll(/^([A-Za-z0-9_-]+)\s*=\s*"[^"]+"/gm)) {
74
- deps.add(match[1]);
75
- }
76
- }
77
- } catch {
78
- // Missing or unreadable manifest — skip.
79
- }
80
- }
81
- return [...deps];
82
- }
83
-
84
- function loadWorkflow(): string {
85
- return readFileSync(WORKFLOW_PATH, "utf8");
86
- }
87
-
88
- function buildUserPrompt(args: ParsedArgs, cwd: string): string {
89
- const workflow = loadWorkflow();
90
- const targets = args.targets.length > 0 ? args.targets : detectDependencies(cwd);
91
- const header = [
92
- "Run the /context workflow.",
93
- "",
94
- `Targets: ${targets.length > 0 ? targets.join(", ") : "(none provided and no dependencies detected in cwd)"}`,
95
- `Allowed methods: ${args.methods.join(",")}`,
96
- "",
97
- "---",
98
- "",
99
- ].join("\n");
100
- return header + workflow;
101
- }
102
-
103
- export function registerContextCommand(pi: ExtensionAPI): void {
9
+ export function registerContextGuidance(pi: ExtensionAPI): void {
104
10
  pi.on("before_agent_start", async (event) => ({
105
11
  systemPrompt: event.systemPrompt + CONTEXT_GUIDANCE,
106
12
  }));
107
-
108
- pi.registerCommand("context", {
109
- description:
110
- "Retrieve code context for repos, libraries, or a natural-language question via DeepWiki, Context7, Exa, clone, or web.",
111
- handler: async (rawArgs, ctx) => {
112
- const args = parseArgs(rawArgs.trim());
113
- const prompt = buildUserPrompt(args, ctx.cwd);
114
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
115
- },
116
- });
117
13
  }
118
14
 
119
- export default function register(pi: ExtensionAPI): void {
120
- registerContextCommand(pi);
121
- }
15
+ export default registerContextGuidance;
@@ -1,277 +1,228 @@
1
- /**
2
- * Native pi tools for DeepWiki / Context7 / Exa retrieval.
3
- *
4
- * Replaces the package's `.mcp.json` (MCP servers). pi has no built-in MCP
5
- * support (docs/usage.md), so these tools call the public REST APIs directly:
6
- * - context_deepwiki → https://mcp.deepwiki.com/mcp (DeepWiki's public JSON-RPC/SSE API)
7
- * - context_context7 → https://context7.com/api/v1/{search,docs}
8
- * - context_exa → https://api.exa.ai/search (needs EXA_API_KEY)
9
- *
10
- * Pure HTTP tools — available in interactive and non-interactive runs alike.
11
- */
12
-
13
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
- import { truncateHead, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
15
- import { StringEnum } from "@earendil-works/pi-ai";
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_MAX_LINES, truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import {
8
+ createToolLifecycleResultRenderer,
9
+ eventToolLifecycle,
10
+ resolvePiCli,
11
+ spawnPiChild,
12
+ terminateChildProcess,
13
+ } from "@fradser/pi-kit";
16
14
  import { Type } from "typebox";
17
15
 
16
+ const READ_ONLY_TOOLS = ["read", "bash"];
17
+ const EXCLUDED_TOOLS = ["edit", "write"];
18
18
  const MAX_CHARS = 60_000;
19
- const TIMEOUT_MS = 30_000;
20
- const DEEPWIKI_MCP = "https://mcp.deepwiki.com/mcp";
19
+ const CHILD_TIMEOUT_MS = 180_000;
21
20
 
22
21
  interface ToolTextResult {
23
- content: [{ type: "text"; text: string }];
24
- details: Record<string, unknown>;
22
+ content: [{ type: "text"; text: string }];
23
+ details: Record<string, unknown>;
25
24
  }
26
25
 
27
- function textResult(text: string, details: Record<string, unknown> = {}): ToolTextResult {
28
- return { content: [{ type: "text", text }], details };
26
+ interface ToolResultForRendering {
27
+ content: unknown;
28
+ details?: unknown;
29
29
  }
30
30
 
31
- function truncate(text: string): string {
32
- if (text.length <= MAX_CHARS) return text;
33
- const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: MAX_CHARS });
34
- return `${result.content}\n\n…[truncated ${text.length - MAX_CHARS} chars]`;
31
+ interface ChildResult {
32
+ text: string;
33
+ stderr: string;
34
+ exitCode: number;
35
+ cancelled: boolean;
36
+ timedOut: boolean;
35
37
  }
36
38
 
37
- function requestSignal(signal: AbortSignal | undefined): AbortSignal {
38
- const timeout = AbortSignal.timeout(TIMEOUT_MS);
39
- return signal ? AbortSignal.any([signal, timeout]) : timeout;
39
+ function textResult(text: string, details: Record<string, unknown> = {}): ToolTextResult {
40
+ return { content: [{ type: "text", text }], details };
40
41
  }
41
42
 
42
- async function httpJson(
43
- url: string,
44
- init: RequestInit = {},
45
- signal: AbortSignal | undefined,
46
- ): Promise<{ ok: boolean; status: number; body: string }> {
47
- const res = await fetch(url, { ...init, signal: requestSignal(signal) });
48
- const body = await res.text();
49
- return { ok: res.ok, status: res.status, body };
43
+ function emptyToolCall(): Text {
44
+ return new Text("", 0, 0);
50
45
  }
51
46
 
52
- /**
53
- * DeepWiki's public API is a JSON-RPC-over-HTTP/SSE endpoint (mcp.deepwiki.com).
54
- * Call a tool directly — no MCP client or sidecar process needed.
55
- */
56
- async function deepwikiCall(
57
- tool: string,
58
- args: Record<string, unknown>,
59
- signal: AbortSignal | undefined,
60
- ): Promise<string> {
61
- const res = await fetch(DEEPWIKI_MCP, {
62
- method: "POST",
63
- headers: {
64
- "Content-Type": "application/json",
65
- Accept: "application/json, text/event-stream",
66
- },
67
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args } }),
68
- signal: requestSignal(signal),
69
- });
70
- const body = await res.text();
71
- if (!res.ok) throw new Error(`DeepWiki HTTP ${res.status}: ${body.slice(0, 200)}`);
72
-
73
- // SSE frames: `event: message` + one or more `data:` lines, blank-line terminated.
74
- // Join multiline payloads, keep the last frame with data.
75
- let payload: string | null = null;
76
- let currentData: string[] = [];
77
- for (const raw of body.split("\n")) {
78
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
79
- if (line === "") {
80
- if (currentData.length > 0) payload = currentData.join("\n");
81
- currentData = [];
82
- continue;
83
- }
84
- if (line.startsWith(":")) continue; // SSE comment
85
- if (line.startsWith("data:")) {
86
- currentData.push(line.slice(5).replace(/^ /, ""));
87
- }
88
- }
89
- if (currentData.length > 0) payload = currentData.join("\n");
90
- if (payload === null) throw new Error("DeepWiki: no data frame in response");
91
-
92
- const data = JSON.parse(payload);
93
- if (data.error) throw new Error(`DeepWiki: ${data.error.message ?? JSON.stringify(data.error)}`);
94
- const content = data.result?.content;
95
- if (Array.isArray(content)) {
96
- return content.map((c) => (typeof c?.text === "string" ? c.text : JSON.stringify(c))).join("\n");
97
- }
98
- return JSON.stringify(data.result ?? data);
47
+ function compactSubject(value: unknown): string {
48
+ const normalized = String(value ?? "research").replace(/\s+/g, " ").trim();
49
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 117)}...`;
99
50
  }
100
51
 
101
- const DeepWikiParams = Type.Object({
102
- owner: Type.String({ description: "GitHub owner, e.g. 'facebook'" }),
103
- repo: Type.String({ description: "GitHub repository name, e.g. 'react'" }),
104
- mode: StringEnum(["structure", "contents", "ask"], {
105
- description: "structure = table of contents; contents = full wiki text; ask = targeted Q&A",
106
- }),
107
- question: Type.Optional(
108
- Type.String({ description: "Required when mode is 'ask'; the question about the repository" }),
109
- ),
110
- });
111
- const Context7Params = Type.Object({
112
- query: Type.String({ description: "Library name to find docs for, e.g. 'fastapi' or 'react'" }),
113
- topic: Type.Optional(
114
- Type.String({ description: "Optional topic focus, e.g. 'auth' or 'ssr' (best results with a topic)" }),
115
- ),
116
- });
117
-
118
- const ExaParams = Type.Object({
119
- query: Type.String({ description: "Precise natural-language or code query" }),
120
- numResults: Type.Optional(
121
- Type.Number({ description: "Number of results (default 5)", default: 5 }),
122
- ),
123
- });
124
-
125
- export function registerContextTools(pi: ExtensionAPI): void {
126
- pi.registerTool({
127
- name: "context_deepwiki",
128
- label: "DeepWiki repo docs",
129
- description:
130
- "Fetch AI-generated documentation for a public GitHub repository from DeepWiki. " +
131
- "mode=structure returns the table of contents; mode=contents returns the full wiki text; " +
132
- "mode=ask answers a specific question with sources. No API key required.",
133
- promptSnippet: "Look up AI-generated documentation for a public GitHub repo from DeepWiki",
134
- promptGuidelines: [
135
- "Use context_deepwiki to fetch structured repo documentation via DeepWiki instead of cloning when you only need a high-level understanding.",
136
- ],
137
- parameters: DeepWikiParams,
138
- executionMode: "sequential",
139
-
140
- async execute(_toolCallId, params, signal) {
141
- const repo = `${params.owner}/${params.repo}`;
142
- if (params.mode === "ask") {
143
- if (!params.question) {
144
- return textResult("context_deepwiki: mode 'ask' requires a question");
145
- }
146
- const answer = await deepwikiCall("ask_question", { repoName: repo, question: params.question }, signal);
147
- return textResult(truncate(answer));
148
- }
149
- const tool = params.mode === "structure" ? "read_wiki_structure" : "read_wiki_contents";
150
- const out = await deepwikiCall(tool, { repoName: repo }, signal);
151
- return textResult(truncate(out));
152
- },
153
- });
154
-
155
- pi.registerTool({
156
- name: "context_context7",
157
- label: "Context7 library docs",
158
- description:
159
- "Fetch up-to-date, version-aware documentation snippets for a library from Context7. " +
160
- "Searches the library, then retrieves markdown docs (optionally focused on a topic). " +
161
- "Anonymous usage is rate-limited; set CONTEXT7_API_KEY (Authorization: Bearer) for per-key quota.",
162
- promptSnippet: "Look up version-aware library documentation from Context7",
163
- promptGuidelines: [
164
- "Use context_context7 to look up library docs instead of guessing APIs from memory. Anonymous usage is rate-limited; set CONTEXT7_API_KEY for higher quota.",
165
- ],
166
- parameters: Context7Params,
167
- executionMode: "sequential",
168
-
169
- async execute(_toolCallId, params, signal) {
170
- const headers: Record<string, string> = {};
171
- if (process.env.CONTEXT7_API_KEY) {
172
- headers.Authorization = `Bearer ${process.env.CONTEXT7_API_KEY}`;
173
- }
52
+ function truncate(text: string): string {
53
+ if (text.length <= MAX_CHARS) return text;
54
+ const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: MAX_CHARS });
55
+ return `${result.content}\n\n…[truncated ${text.length - MAX_CHARS} chars]`;
56
+ }
174
57
 
175
- const { ok, status, body } = await httpJson(
176
- `https://context7.com/api/v1/search?query=${encodeURIComponent(params.query)}`,
177
- { headers },
178
- signal,
179
- );
180
- if (!ok) {
181
- if (status === 401 || status === 403) {
182
- return textResult(
183
- `Context7 search needs an API key (HTTP ${status}). Set CONTEXT7_API_KEY ` +
184
- `(Authorization: Bearer) or fall back to web/clone methods.`,
185
- );
186
- }
187
- throw new Error(`Context7 search failed (HTTP ${status}): ${body.slice(0, 400)}`);
188
- }
58
+ function renderContextResult(
59
+ result: ToolResultForRendering,
60
+ options: { expanded?: boolean },
61
+ theme: { fg(color: string, text: string): string; bg(color: string, text: string): string; bold(text: string): string },
62
+ context: { isError?: boolean },
63
+ subject: string,
64
+ ) {
65
+ return createToolLifecycleResultRenderer<ToolResultForRendering, Text>({
66
+ createSpec: (_result, _text, details) => eventToolLifecycle("context", subject, {
67
+ label: "researched",
68
+ details,
69
+ detailLimit: 50,
70
+ }),
71
+ expandHint: "ctrl+o to expand",
72
+ fit: truncateToWidth,
73
+ visibleWidth,
74
+ renderError: (line, errorTheme) => new Text(errorTheme.fg("error", line), 0, 0),
75
+ })(result, options, theme, context);
76
+ }
189
77
 
190
- let data: { results?: Array<{ id?: unknown }> };
191
- try {
192
- data = JSON.parse(body);
193
- } catch {
194
- throw new Error(`Context7 search: unexpected response: ${body.slice(0, 400)}`);
195
- }
196
- const results = Array.isArray(data.results) ? data.results : [];
197
- if (results.length === 0) {
198
- return textResult(`Context7: no library found for "${params.query}"`);
199
- }
200
- const libraryId = typeof results[0]?.id === "string" ? results[0].id : "";
201
- if (!libraryId) {
202
- return textResult(`Context7: search returned no usable library id for "${params.query}"`);
203
- }
78
+ export function buildResearchPrompt(query: string): string {
79
+ return [
80
+ "Research the user's request independently and return a concise, evidence-based answer.",
81
+ "You are in a child Pi process. Use only read and bash; never edit or write files.",
82
+ "For public repository line-level evidence, you may run git clone --depth=1 into a unique /tmp directory, inspect it, and remove it before answering.",
83
+ "Never modify the caller's working directory. Do not use package managers, deployment commands, or interactive commands.",
84
+ "Cite concrete source URLs, repository paths, or documentation names when available.",
85
+ "Keep raw findings compact and synthesize the answer rather than dumping large payloads.",
86
+ "",
87
+ "User research request:",
88
+ query,
89
+ ].join("\n");
90
+ }
204
91
 
205
- // v1 doc ids look like "/owner/repo" — strip the leading slash for the path.
206
- const path = libraryId.startsWith("/") ? libraryId.slice(1) : libraryId;
207
- const topic = params.topic ? `&topic=${encodeURIComponent(params.topic)}` : "";
208
- const docUrl = `https://context7.com/api/v1/${path}?type=txt${topic}`;
209
- const doc = await httpJson(docUrl, { headers }, signal);
210
- if (!doc.ok) {
211
- throw new Error(`Context7 docs failed (HTTP ${doc.status}): ${doc.body.slice(0, 400)}`);
212
- }
213
- return textResult(
214
- `Context7 docs for "${params.query}" (${libraryId})${params.topic ? `, topic ${params.topic}` : ""}:\n\n${truncate(doc.body)}`,
215
- );
216
- },
217
- });
92
+ function parseChildOutput(stdout: string): string {
93
+ let text = "";
94
+ for (const line of stdout.split("\n")) {
95
+ try {
96
+ const event = JSON.parse(line) as { type?: string; message?: { role?: string; content?: Array<{ type?: string; text?: string }> } };
97
+ if (event.type !== "message_end" || event.message?.role !== "assistant") continue;
98
+ const candidate = (event.message.content ?? [])
99
+ .filter((part) => part.type === "text" && typeof part.text === "string")
100
+ .map((part) => part.text)
101
+ .join("");
102
+ if (candidate.trim()) text = candidate;
103
+ } catch {
104
+ // JSON mode may include non-event output; it is not part of the result.
105
+ }
106
+ }
107
+ return text.trim();
108
+ }
218
109
 
219
- pi.registerTool({
220
- name: "context_exa",
221
- label: "Exa web/code search",
222
- description:
223
- "Search the web for real-world code patterns, comparisons, and up-to-date information via Exa. " +
224
- "Requires the EXA_API_KEY environment variable. Returns titles, URLs, and text snippets.",
225
- promptSnippet: "Search the web for real-world code patterns, comparisons, and up-to-date information via Exa",
226
- promptGuidelines: [
227
- "Use context_exa to search the web when you need current information, code patterns, or comparisons. Requires EXA_API_KEY to be set.",
228
- ],
229
- parameters: ExaParams,
230
- executionMode: "sequential",
110
+ function sandboxProfile(researchDirectory: string): string {
111
+ const temporaryDirectory = researchDirectory.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
112
+ return [
113
+ "(version 1)",
114
+ "(deny default)",
115
+ "(allow process*)",
116
+ "(allow sysctl-read)",
117
+ "(allow file-read*)",
118
+ `(allow file-write* (subpath \"${temporaryDirectory}\"))`,
119
+ "(allow network*)",
120
+ ].join("\n");
121
+ }
231
122
 
232
- async execute(_toolCallId, params, signal) {
233
- const apiKey = process.env.EXA_API_KEY;
234
- if (!apiKey) {
235
- return textResult(
236
- "context_exa: EXA_API_KEY is not set. Set EXA_API_KEY to enable Exa search, " +
237
- "or fall back to web search via curl / GitHub search.",
238
- );
239
- }
123
+ export function runResearchChild(query: string, signal?: AbortSignal): Promise<ChildResult> {
124
+ const cli = resolvePiCli();
125
+ const researchDirectory = realpathSync(mkdtempSync(join(tmpdir(), "pi-context-")));
126
+ const piArgs = [
127
+ ...cli.args,
128
+ "--print",
129
+ "--mode",
130
+ "json",
131
+ "--no-session",
132
+ "--tools",
133
+ READ_ONLY_TOOLS.join(","),
134
+ "--exclude-tools",
135
+ EXCLUDED_TOOLS.join(","),
136
+ buildResearchPrompt(query),
137
+ ];
138
+
139
+ const sandboxed = process.platform === "darwin";
140
+ const command = sandboxed ? "sandbox-exec" : cli.command;
141
+ const args = sandboxed ? ["-p", sandboxProfile(researchDirectory), cli.command, ...piArgs] : piArgs;
142
+
143
+ return new Promise((resolve, reject) => {
144
+ let child: ChildProcess;
145
+ let settled = false;
146
+ try {
147
+ child = spawnPiChild(command, args, { cwd: researchDirectory, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
148
+ } catch (error) {
149
+ rmSync(researchDirectory, { recursive: true, force: true });
150
+ reject(error);
151
+ return;
152
+ }
153
+
154
+ let stdout = "";
155
+ let stderr = "";
156
+ let termination: Promise<boolean> | undefined;
157
+ let timedOut = false;
158
+ let cancelled = false;
159
+ const timeout = setTimeout(() => {
160
+ timedOut = true;
161
+ termination ??= terminateChildProcess(child);
162
+ }, CHILD_TIMEOUT_MS);
163
+ timeout.unref?.();
164
+ const abort = () => {
165
+ cancelled = true;
166
+ termination ??= terminateChildProcess(child);
167
+ };
168
+ if (signal?.aborted) abort();
169
+ else signal?.addEventListener("abort", abort, { once: true });
170
+
171
+ child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
172
+ child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
173
+ child.on("error", (error) => {
174
+ if (settled) return;
175
+ settled = true;
176
+ signal?.removeEventListener("abort", abort);
177
+ clearTimeout(timeout);
178
+ rmSync(researchDirectory, { recursive: true, force: true });
179
+ reject(error);
180
+ });
181
+ child.on("close", (code) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ signal?.removeEventListener("abort", abort);
185
+ clearTimeout(timeout);
186
+ rmSync(researchDirectory, { recursive: true, force: true });
187
+ resolve({ text: parseChildOutput(stdout), stderr: stderr.trim(), exitCode: code ?? 1, cancelled, timedOut });
188
+ });
189
+ });
190
+ }
240
191
 
241
- const numResults = Math.max(1, Math.min(10, Math.floor(params.numResults ?? 5)));
242
- const { ok, status, body } = await httpJson(
243
- "https://api.exa.ai/search",
244
- {
245
- method: "POST",
246
- headers: {
247
- "Content-Type": "application/json",
248
- "x-api-key": apiKey,
249
- },
250
- body: JSON.stringify({ query: params.query, numResults, contents: { text: true } }),
251
- },
252
- signal,
253
- );
254
- if (!ok) throw new Error(`Exa search failed (HTTP ${status}): ${body.slice(0, 400)}`);
192
+ const ResearchParams = Type.Object({
193
+ query: Type.String({ description: "Research request: repository, library, codebase question, or current technical topic." }),
194
+ });
255
195
 
256
- let data: { results?: Array<{ title?: string; url?: string; text?: string }> };
257
- try {
258
- data = JSON.parse(body);
259
- } catch {
260
- throw new Error(`Exa search: unexpected response: ${body.slice(0, 400)}`);
261
- }
262
- const results = Array.isArray(data.results) ? data.results : [];
263
- if (results.length === 0) {
264
- return textResult(`Exa: no results for "${params.query}"`);
265
- }
266
- const lines = results.map((r, i) => {
267
- const title = r.title || r.url || "(untitled)";
268
- const url = r.url || "";
269
- const text = typeof r.text === "string" ? r.text.slice(0, 1200) : "";
270
- return `${i + 1}. ${title}\n ${url}\n${text ? ` ${text}` : ""}`;
271
- });
272
- return textResult(truncate(lines.join("\n\n")));
273
- },
274
- });
196
+ export function registerContextTools(pi: ExtensionAPI): void {
197
+ pi.registerTool({
198
+ name: "context_get",
199
+ label: "Isolated Pi research",
200
+ description: "Research a repository, library, codebase, or technical topic in an independent read-only Pi child process. The child can inspect and temporarily clone public repositories under /tmp.",
201
+ promptSnippet: "Research independently in a read-only child Pi process",
202
+ promptGuidelines: [
203
+ "Use context_get when the user needs external context for a repository, library, codebase, or current technical topic. It retrieves that context in an isolated read-only Pi process.",
204
+ ],
205
+ parameters: ResearchParams,
206
+ executionMode: "sequential",
207
+ renderShell: "self",
208
+ renderCall: emptyToolCall,
209
+ renderResult(result, options, theme, context) {
210
+ const params = context.args as { query?: string };
211
+ return renderContextResult(result, options, theme, context, compactSubject(params.query));
212
+ },
213
+ async execute(_toolCallId, params, signal) {
214
+ const child = await runResearchChild(params.query, signal);
215
+ if (child.cancelled) throw new Error("Isolated Pi research was cancelled");
216
+ if (child.timedOut) throw new Error("Isolated Pi research timed out");
217
+ if (child.exitCode !== 0) {
218
+ throw new Error(`Isolated Pi research failed (exit ${child.exitCode}): ${child.stderr.slice(0, 400)}`);
219
+ }
220
+ if (!child.text) {
221
+ throw new Error("Isolated Pi research returned no answer");
222
+ }
223
+ return textResult(truncate(child.text), { exitCode: child.exitCode });
224
+ },
225
+ });
275
226
  }
276
227
 
277
228
  export default registerContextTools;
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import registerContextCommand from "./extensions/context-command.ts";
2
+ import registerContextGuidance from "./extensions/context-command.ts";
3
3
  import registerContextTools from "./extensions/context-tools.ts";
4
4
 
5
5
  export default function contextExtension(pi: ExtensionAPI): void {
6
6
  registerContextTools(pi);
7
- registerContextCommand(pi);
7
+ registerContextGuidance(pi);
8
8
  }
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@fradser/pi-context",
3
- "version": "0.5.1",
4
- "description": "Retrieve code context for any repo, library, or natural-language query via DeepWiki, Context7, Exa, git clone, and web search+fetch. Native pi tools (context_deepwiki/context_context7/context_exa) call the public REST APIs directly — no MCP servers.",
3
+ "version": "0.6.0",
4
+ "description": "Retrieve context for repositories, libraries, and technical questions with one native tool that launches an isolated read-only Pi research child.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "code-context",
8
- "deepwiki",
9
- "context7",
10
- "exa"
8
+ "isolated-research",
9
+ "read-only"
11
10
  ],
12
11
  "author": "Frad LEE <fradser@gmail.com>",
13
12
  "license": "MIT",
@@ -31,8 +30,12 @@
31
30
  ]
32
31
  },
33
32
  "peerDependencies": {
34
- "@earendil-works/pi-coding-agent": "*",
35
33
  "@earendil-works/pi-ai": "*",
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*",
36
36
  "typebox": "*"
37
+ },
38
+ "dependencies": {
39
+ "@fradser/pi-kit": "0.4.2"
37
40
  }
38
41
  }
@@ -1,108 +1,7 @@
1
- # context workflow
1
+ # Isolated context research workflow
2
2
 
3
- Retrieve code context for any repo, library, or natural-language code/technology question. Five methods are available; pick them per target. Prefer small intermediate summaries over dumping raw API payloads into the long-lived conversation.
3
+ Use `context_get` for a repository, library, codebase, or technical research request. It launches a separate Pi process so exploratory work and its tool calls do not enter the main session.
4
4
 
5
- ## Native tools
5
+ The child is read-only: it can use `read` and `bash`, but never `edit` or `write`. For public repository evidence it may run `git clone --depth=1` into a unique directory under `/tmp`, inspect only the relevant files, cite the evidence in its answer, and remove the clone before it exits.
6
6
 
7
- | Method | Tool | Availability |
8
- |--------|------|--------------|
9
- | DeepWiki | `context_deepwiki` | always, no key |
10
- | Context7 | `context_context7` | always, optional `CONTEXT7_API_KEY` for higher quota |
11
- | Exa | `context_exa` | requires `EXA_API_KEY` |
12
- | Git clone | `bash` + `read` | always |
13
- | Web search+fetch | `bash` + `curl` | always |
14
-
15
- If a native tool is unavailable or errors (e.g. `EXA_API_KEY` unset), skip it and use the documented clone/web fallback. Report which methods ran. There are no Pi built-ins named `WebSearch`/`WebFetch`/`Task` — do not invent them.
16
-
17
- ## Invocation
18
-
19
- `/context [targets...] [--method=a,b,...]`
20
-
21
- - `--method=` accepts a comma-separated list from `deepwiki,context7,exa,clone,web,all`. Default: `all`.
22
- - Quoted strings are one target. Multiple positional tokens are multiple targets.
23
- - No targets: read dependency manifests in cwd (`package.json`, `go.mod`, `pyproject.toml`, `Cargo.toml`) and use detected dependencies as targets.
24
-
25
- Classify each target:
26
-
27
- - GitHub slug (`owner/repo`) or git URL → repo — DeepWiki (`context_deepwiki`), else clone.
28
- - Bare package name or `name@version` → library — Context7 (`context_context7`), else web + clone of docs repo when known.
29
- - Anything else → natural-language — Exa (`context_exa`) when `EXA_API_KEY` is set, else web search via `curl`.
30
-
31
- When `--method=` is passed, use the intersection of allowed and applicable methods. Empty intersection → skip and report.
32
-
33
- ## Method 1: DeepWiki
34
-
35
- Best for: public GitHub repos needing an architecture overview quickly.
36
-
37
- 1. `context_deepwiki` with `owner`/`repo` and `mode: "structure"` (e.g. `facebook` / `react`).
38
- 2. `mode: "contents"` for the full wiki text, or `mode: "ask"` with `question` for targeted Q&A.
39
- 3. Extract architecture summary and key relationships.
40
-
41
- Token isolation: structure → relevant pages → short architecture summary.
42
- Fallback on error: Method 4 (clone) + Method 5 (web) on GitHub docs/README.
43
-
44
- ## Method 2: Context7
45
-
46
- Best for: up-to-date API docs and version-specific examples for packages/frameworks.
47
-
48
- 1. `context_context7` with the library name as `query` (e.g. `fastapi`, `react`).
49
- 2. Pass a `topic` (e.g. `auth`, `ssr`) for focused snippets; include the version in the query when given (`react@18`).
50
- 3. Extract the minimum viable API surface + examples.
51
-
52
- Token isolation: resolve id → query → minimum viable API surface + examples.
53
- Fallback on error: official docs URLs via `curl`, or clone of the library repo docs.
54
-
55
- ## Method 3: Exa
56
-
57
- Best for: real-world usage patterns and cross-web code examples.
58
-
59
- 1. Call with a precise query (language/framework/version/identifiers).
60
- 2. Prefer recent sources; dedupe near-identical snippets and mirrors.
61
- 3. Return a short explanation with sources.
62
-
63
- Fallback when key missing / error: GitHub code search URLs / known docs via `curl`, or clone representative examples.
64
-
65
- ## Method 4: Git clone
66
-
67
- Best for: private repos, deep implementation review, when other methods lack depth.
68
-
69
- 1. `git clone <repo-url> /tmp/<repo-name> --depth=1`
70
- 2. `read` key files: entry points, config, core modules.
71
- 3. Map structure with `bash` find/ls; search with `bash` grep.
72
- 4. Cleanup: `rm -rf /tmp/<repo-name>`.
73
-
74
- ## Method 5: Web search + fetch
75
-
76
- Best for: rationale, changelogs, issues, migration guides.
77
-
78
- 1. Derive version-anchored queries from the question or clone findings.
79
- 2. Fetch high-signal URLs (official docs, GitHub issues/PRs, changelogs).
80
- 3. Extract only relevant sections; cross-check against cloned code when available.
81
- 4. Discard results older than ~2 years unless foundational.
82
-
83
- Query patterns:
84
- - Changelogs: `"<repo> CHANGELOG v<version>"`
85
- - Rationale: `"<repo> <concept> why OR rationale site:github.com"`
86
- - Known issues: `"<repo> <symbol> issue OR bug site:github.com"`
87
- - Migration: `"<repo> migrate from <old> to <new>"`
88
-
89
- ## Method selection guide
90
-
91
- | Scenario | Primary | Fallback |
92
- |----------|---------|----------|
93
- | "How does X library work?" | Context7 | Web / clone |
94
- | "Architecture of Y repo" | DeepWiki | Git clone |
95
- | "Examples of Z pattern" | Exa | Context7 / web |
96
- | "Private/internal repo" | Git clone | — |
97
- | "What changed in v3?" | Context7 | Web |
98
- | "Why this design?" | Git clone → web | DeepWiki |
99
- | "Compare X vs Y" | Exa + Context7 | Web |
100
- | "Best practice for Z" | Web | Exa |
101
-
102
- ## Combining methods
103
-
104
- 1. DeepWiki / clone for architecture.
105
- 2. Context7 for API surface.
106
- 3. Exa / web for community patterns.
107
- 4. Always prefer read-only operations; clean up `/tmp` clones.
108
- 5. Return a synthesized summary only — drop raw bulk payloads.
7
+ The returned answer should synthesize findings, distinguish facts from uncertainty, and cite useful source URLs or repository paths. It must not modify the caller's working directory.