@fradser/pi-context 0.6.0 → 0.6.2

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,6 +1,4 @@
1
- # Context Pi Package
2
-
3
- **Version:** 0.5.1
1
+ # @fradser/pi-context
4
2
 
5
3
  `@fradser/pi-context` exposes one tool: `context_get`.
6
4
 
@@ -10,8 +8,6 @@ It starts an independent Pi child process for repository, library, codebase, and
10
8
 
11
9
  ```bash
12
10
  pi install npm:@fradser/pi-context
13
- # or from this repository:
14
- pi install /path/to/pi-packages/packages/context
15
11
  ```
16
12
 
17
13
  ## Usage
@@ -31,12 +27,12 @@ Pi recognizes these requests and invokes `context_get` automatically. Users do n
31
27
  The child runs as:
32
28
 
33
29
  ```text
34
- pi --print --mode json --no-session --tools read,bash --exclude-tools edit,write
30
+ pi --print --mode json --no-session -ne -ns -np -nc --no-themes --tools read,bash
35
31
  ```
36
32
 
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.
33
+ The child runs in the caller's working directory through pi-kit's minimal `runPiWorker` mode: `-ne -ns -np -nc --no-themes` disables extension, skill, prompt-template, context-file, and theme discovery, while `--tools read,bash` exposes only the two required tools. It has no sandbox, timeout, or display truncation; `bash` remains technically able to write because there is no OS sandbox, so the bundled worker prompt requires no modifications. If line-level evidence from a public repository is necessary, it may use `git clone --depth=1` under `/tmp` with removal after inspection. It must not run package-management, deployment, or interactive commands. The shared worker fails closed if its bounded stdout, stderr, or JSONL line limits are exceeded.
38
34
 
39
- Results are bounded before entering the main session and render as compact, expandable context lifecycle rows.
35
+ A package-local typed builder reads the bundled Markdown as a reference protocol and builds a task-specific context-review prompt containing the caller working directory, complete research request, reference protocol, and completion contract. The Markdown file itself is never used as an execution identity. Startup renders `[context] research started · <research query>`, using the normalized concrete query that produced the prompt; the row remains width-bounded and does not expose the resource path. While the async child runs, the transcript keeps only that started row with no duplicate progress row; live progress shows in the researcher widget above the editor. The widget shows neutral live research activity. The child always runs with `--no-session`, so separate invocations retain no memory or agent-teams state. A successful empty answer is retried exactly once with a stronger completion requirement that does not rely on hidden reasoning or prior tool output. Results enter the main session untruncated and render as a compact, expandable `[context] researched` lifecycle row.
40
36
 
41
37
  ## Structure
42
38
 
@@ -46,7 +42,8 @@ context/
46
42
  ├── extensions/context-tools.ts
47
43
  ├── extensions/context-command.ts
48
44
  ├── references/workflow.md
49
- └── agents/context-researcher.md
45
+ ├── extensions/context-prompt.ts
46
+ └── prompts/context-research.md
50
47
  ```
51
48
 
52
49
  ## License
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  const CONTEXT_GUIDANCE = `
4
4
  ## Isolated research
5
5
 
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.
6
+ For questions about an external repository, library, codebase, or current technical topic, call \`context_get\` instead of researching inline. Research then stays out of the main session.
7
7
  `;
8
8
 
9
9
  export function registerContextGuidance(pi: ExtensionAPI): void {
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export interface ContextResearchPromptBindings {
4
+ userResearchRequest: string;
5
+ workingDirectory: string;
6
+ }
7
+
8
+ const CONTEXT_REVIEW_COMPLETION = [
9
+ "Return one self-contained review of the requested context.",
10
+ "State the conclusion, cite concrete evidence, and distinguish observed facts from uncertainty.",
11
+ "Finish with a textual final answer rather than ending after a tool call.",
12
+ ].join(" ");
13
+
14
+ const CONTEXT_RESEARCH_TEMPLATE_URL = new URL("../prompts/context-research.md", import.meta.url);
15
+ const PLACEHOLDER_PATTERN = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
16
+ const PLACEHOLDER_TOKEN_PATTERN = /\{\{[^{}]+\}\}/g;
17
+ const REQUIRED_PLACEHOLDERS = ["USER_RESEARCH_REQUEST"] as const;
18
+
19
+ function templatePlaceholders(template: string): string[] {
20
+ return [...template.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]);
21
+ }
22
+
23
+ function placeholderTokens(template: string): string[] {
24
+ return template.match(PLACEHOLDER_TOKEN_PATTERN) ?? [];
25
+ }
26
+
27
+ export function validateContextPromptTemplate(template: string): void {
28
+ const placeholders = templatePlaceholders(template);
29
+ const allowedTokens = new Set(REQUIRED_PLACEHOLDERS.map((placeholder) => `{{${placeholder}}}`));
30
+ const unknown = [...new Set(placeholderTokens(template).filter((token) => !allowedTokens.has(token)))];
31
+ if (unknown.length > 0) {
32
+ throw new Error(`Context prompt has unknown placeholder(s): ${unknown.join(", ")}`);
33
+ }
34
+ const missing = REQUIRED_PLACEHOLDERS.filter((placeholder) => !placeholders.includes(placeholder));
35
+ if (missing.length > 0) {
36
+ throw new Error(`Context prompt has missing placeholder(s): ${missing.join(", ")}`);
37
+ }
38
+ }
39
+
40
+ export function renderContextPromptTemplate(
41
+ template: string,
42
+ bindings: ContextResearchPromptBindings,
43
+ ): string {
44
+ validateContextPromptTemplate(template);
45
+ const rendered = template.replace(
46
+ "{{USER_RESEARCH_REQUEST}}",
47
+ () => bindings.userResearchRequest,
48
+ );
49
+ const bindingTokens = new Set(placeholderTokens(bindings.userResearchRequest));
50
+ const unexpected = placeholderTokens(rendered).filter((token) => !bindingTokens.has(token));
51
+ if (unexpected.length > 0) {
52
+ throw new Error(`Context prompt has unresolved placeholder(s): ${[...new Set(unexpected)].join(", ")}`);
53
+ }
54
+ return rendered;
55
+ }
56
+
57
+ const CONTEXT_RESEARCH_TEMPLATE = readFileSync(CONTEXT_RESEARCH_TEMPLATE_URL, "utf8").trim();
58
+
59
+ export function buildContextResearchPrompt(bindings: ContextResearchPromptBindings): string {
60
+ const protocol = renderContextPromptTemplate(CONTEXT_RESEARCH_TEMPLATE, bindings);
61
+ return [
62
+ "# Context Review",
63
+ "",
64
+ "## Current Research Task",
65
+ `Working directory: ${bindings.workingDirectory}`,
66
+ "",
67
+ bindings.userResearchRequest,
68
+ "",
69
+ "## Reference Protocol",
70
+ protocol,
71
+ "",
72
+ "## Completion Contract",
73
+ CONTEXT_REVIEW_COMPLETION,
74
+ ].join("\n");
75
+ }
@@ -1,194 +1,167 @@
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";
1
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
3
  import {
8
- createToolLifecycleResultRenderer,
4
+ bindLifecycleRenderers,
5
+ contentDetailLines,
6
+ createLiveActivityWidget,
9
7
  eventToolLifecycle,
10
- resolvePiCli,
11
- spawnPiChild,
12
- terminateChildProcess,
8
+ runPiWorker,
9
+ type PiLiveWidgetContext,
10
+ type PiWorkerProgressUpdate,
13
11
  } from "@fradser/pi-kit";
14
12
  import { Type } from "typebox";
13
+ import { buildContextResearchPrompt } from "./context-prompt.ts";
15
14
 
16
- const READ_ONLY_TOOLS = ["read", "bash"];
17
- const EXCLUDED_TOOLS = ["edit", "write"];
18
- const MAX_CHARS = 60_000;
19
- const CHILD_TIMEOUT_MS = 180_000;
15
+ const RESEARCH_TOOLS = ["read", "bash"];
16
+
17
+ /** Geometry bound once: the research row shares hint and wrapping. */
18
+ const contextRows = bindLifecycleRenderers({
19
+ fit: truncateToWidth,
20
+ visibleWidth,
21
+ wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
22
+ expandHint: () => keyHint("app.tools.expand", "to expand"),
23
+ });
24
+ const FINAL_ANSWER_RETRY_INSTRUCTION = `
25
+
26
+ Completion requirement: the previous attempt produced no textual final answer. Complete the original research request now without relying on hidden reasoning, transient tool output, or state from the prior attempt; repeat any inspection needed to support the answer. Your final assistant message must be a self-contained plain-text answer with the conclusion and concrete evidence. Do not finish immediately after a tool call.`;
20
27
 
21
28
  interface ToolTextResult {
22
29
  content: [{ type: "text"; text: string }];
23
30
  details: Record<string, unknown>;
24
31
  }
25
32
 
26
- interface ToolResultForRendering {
27
- content: unknown;
28
- details?: unknown;
29
- }
30
-
31
33
  interface ChildResult {
32
34
  text: string;
33
35
  stderr: string;
34
36
  exitCode: number;
35
37
  cancelled: boolean;
36
- timedOut: boolean;
37
38
  }
38
39
 
40
+ interface ResearchAttemptResult extends ChildResult {
41
+ retried: boolean;
42
+ }
43
+
44
+ type ResearchRunner = (
45
+ query: string,
46
+ signal: AbortSignal | undefined,
47
+ onUpdate: ((update: PiWorkerProgressUpdate) => void) | undefined,
48
+ finalAnswerRetry: boolean,
49
+ ) => Promise<ChildResult>;
50
+
39
51
  function textResult(text: string, details: Record<string, unknown> = {}): ToolTextResult {
40
52
  return { content: [{ type: "text", text }], details };
41
53
  }
42
54
 
43
- function emptyToolCall(): Text {
44
- return new Text("", 0, 0);
55
+ export function formatResearchSubject(query: unknown): string {
56
+ if (typeof query !== "string") return "context request";
57
+ return query.replace(/\s+/g, " ").trim() || "context request";
45
58
  }
46
59
 
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)}...`;
60
+ function renderContextCall(
61
+ query: unknown,
62
+ theme: { fg(color: string, text: string): string; bold(text: string): string },
63
+ ): Component {
64
+ return {
65
+ render: (width) => {
66
+ if (width <= 0) return [];
67
+ const prefix = theme.fg("customMessageLabel", theme.bold("[context]"));
68
+ const title = `research started · ${formatResearchSubject(query)}`;
69
+ return new Text(`${prefix} ${title}`, 0, 0).render(width)
70
+ .map((line) => truncateToWidth(line, width, "")).concat("");
71
+ },
72
+ invalidate: () => {},
73
+ };
50
74
  }
51
75
 
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]`;
76
+ export function buildResearchPrompt(query: string, finalAnswerRetry = false): string {
77
+ const prompt = buildContextResearchPrompt({
78
+ userResearchRequest: query,
79
+ workingDirectory: process.cwd(),
80
+ });
81
+ return finalAnswerRetry ? `${prompt}${FINAL_ANSWER_RETRY_INSTRUCTION}` : prompt;
56
82
  }
57
83
 
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);
84
+ /** Minimal structural view of the tool-execution context: only what the research widget needs. */
85
+ export type ResearchWidgetContext = PiLiveWidgetContext;
86
+
87
+ interface ActiveResearch {
88
+ token: number;
89
+ ctx: ResearchWidgetContext | undefined;
90
+ activity?: string;
76
91
  }
77
92
 
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");
93
+ let researchToken = 0;
94
+ let activeResearch: ActiveResearch | undefined;
95
+ // Identity, marker, and activity rendering all come from pi-kit, so this
96
+ // widget uses the same row language as every other package's status row; the
97
+ // activity format is the only per-package choice.
98
+ const researchWidget = createLiveActivityWidget({
99
+ key: "context-research",
100
+ placement: "aboveEditor",
101
+ fit: truncateToWidth,
102
+ activityFormat: "markdown",
103
+ });
104
+
105
+ /** Show the running-research status above the editor; returns a token for updates/clear. */
106
+ export function startResearchWidget(ctx: ResearchWidgetContext | undefined): number {
107
+ const token = ++researchToken;
108
+ activeResearch = { token, ctx };
109
+ researchWidget.update(ctx, [{ id: String(token), identity: "researcher" }]);
110
+ return token;
90
111
  }
91
112
 
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();
113
+ /** Refresh the live activity line; stale tokens are ignored. */
114
+ export function updateResearchWidget(token: number, activity: string | undefined): void {
115
+ if (activeResearch?.token !== token) return;
116
+ if (activity) activeResearch.activity = activity;
117
+ researchWidget.update(activeResearch.ctx, [{
118
+ id: String(token),
119
+ identity: "researcher",
120
+ activity: activeResearch.activity,
121
+ }]);
108
122
  }
109
123
 
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");
124
+ /** Remove the status widget; stale tokens are ignored. */
125
+ export function clearResearchWidget(ctx: ResearchWidgetContext | undefined, token: number): void {
126
+ if (activeResearch?.token !== token) return;
127
+ activeResearch = undefined;
128
+ researchWidget.clear(ctx);
121
129
  }
122
130
 
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
- });
131
+ export function runResearchChild(
132
+ query: string,
133
+ signal?: AbortSignal,
134
+ onUpdate?: (update: PiWorkerProgressUpdate) => void,
135
+ finalAnswerRetry = false,
136
+ ): Promise<ChildResult> {
137
+ let cancelled = signal?.aborted ?? false;
138
+ const onAbort = () => { cancelled = true; };
139
+ if (!signal?.aborted) signal?.addEventListener("abort", onAbort, { once: true });
140
+ return runPiWorker({
141
+ prompt: buildResearchPrompt(query, finalAnswerRetry),
142
+ cwd: process.cwd(),
143
+ tools: RESEARCH_TOOLS,
144
+ minimal: true,
145
+ signal,
146
+ onUpdate,
147
+ }).then((result) => {
148
+ signal?.removeEventListener("abort", onAbort);
149
+ return { text: result.text.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode, cancelled };
189
150
  });
190
151
  }
191
152
 
153
+ export async function runResearchWithFinalAnswerRetry(
154
+ query: string,
155
+ signal?: AbortSignal,
156
+ onUpdate?: (update: PiWorkerProgressUpdate) => void,
157
+ runner: ResearchRunner = runResearchChild,
158
+ ): Promise<ResearchAttemptResult> {
159
+ const first = await runner(query, signal, onUpdate, false);
160
+ if (first.cancelled || first.exitCode !== 0 || first.text) return { ...first, retried: false };
161
+ const retry = await runner(query, signal, onUpdate, true);
162
+ return { ...retry, retried: true };
163
+ }
164
+
192
165
  const ResearchParams = Type.Object({
193
166
  query: Type.String({ description: "Research request: repository, library, codebase question, or current technical topic." }),
194
167
  });
@@ -197,30 +170,57 @@ export function registerContextTools(pi: ExtensionAPI): void {
197
170
  pi.registerTool({
198
171
  name: "context_get",
199
172
  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",
173
+ description: "Research a repository, library, codebase, or technical topic in an independent prompt-constrained Pi child process. The child has read and bash available and can inspect or temporarily clone public repositories under /tmp.",
174
+ promptSnippet: "Research independently in a prompt-constrained Pi child process",
202
175
  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.",
176
+ "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 Pi process with a read/bash allowlist and prompt-level no-modification guidance.",
204
177
  ],
205
178
  parameters: ResearchParams,
206
179
  executionMode: "sequential",
207
180
  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));
181
+ renderCall(args, theme, _context) {
182
+ return renderContextCall(args.query, theme);
212
183
  },
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)}`);
184
+ renderResult(result, options, theme, context) {
185
+ // While the async child is still running, live progress already shows
186
+ // in the `researcher` widget above the editor, so a partial result
187
+ // renders no transcript row: the `research started` call row stays the
188
+ // single visible row until the settled `[context] researched` result.
189
+ if (options.isPartial) {
190
+ return { render: () => [] as string[], invalidate: () => {} };
219
191
  }
220
- if (!child.text) {
221
- throw new Error("Isolated Pi research returned no answer");
192
+ const query = (context.args as { query?: string })?.query;
193
+ const subject = formatResearchSubject(query);
194
+ const spec = eventToolLifecycle("context", subject, {
195
+ label: "researched",
196
+ details: contentDetailLines(result),
197
+ detailLimit: "all",
198
+ });
199
+ return contextRows.result(() => spec)(result as ToolTextResult, options, theme, context);
200
+ },
201
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
202
+ const widget = startResearchWidget(ctx as ResearchWidgetContext | undefined);
203
+ try {
204
+ const child = await runResearchWithFinalAnswerRetry(params.query, signal, (progress) => {
205
+ updateResearchWidget(widget, progress.activity);
206
+ onUpdate?.(textResult(progress.activity ?? "Working...", {
207
+ operation: "context-review",
208
+ }));
209
+ });
210
+ if (child.cancelled) throw new Error("Isolated Pi research was cancelled");
211
+ if (child.exitCode !== 0) {
212
+ const phase = child.retried ? " retry" : "";
213
+ throw new Error(`Isolated Pi research${phase} failed (exit ${child.exitCode}): ${child.stderr.slice(0, 400)}`);
214
+ }
215
+ if (!child.text) throw new Error("Isolated Pi research returned no answer after retry");
216
+ return textResult(child.text, {
217
+ exitCode: child.exitCode,
218
+ operation: "context-review",
219
+ ...(child.retried ? { retried: true } : {}),
220
+ });
221
+ } finally {
222
+ clearResearchWidget(ctx as ResearchWidgetContext | undefined, widget);
222
223
  }
223
- return textResult(truncate(child.text), { exitCode: child.exitCode });
224
224
  },
225
225
  });
226
226
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@fradser/pi-context",
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.",
3
+ "version": "0.6.2",
4
+ "description": "Retrieve context for repositories, libraries, and technical questions with one native tool that launches an isolated prompt-constrained Pi research child.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "code-context",
8
8
  "isolated-research",
9
- "read-only"
9
+ "prompt-constrained"
10
10
  ],
11
11
  "author": "Frad LEE <fradser@gmail.com>",
12
12
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "index.ts",
22
22
  "extensions",
23
23
  "references",
24
- "agents",
24
+ "prompts",
25
25
  "README.md"
26
26
  ],
27
27
  "pi": {
@@ -36,6 +36,6 @@
36
36
  "typebox": "*"
37
37
  },
38
38
  "dependencies": {
39
- "@fradser/pi-kit": "0.4.2"
39
+ "@fradser/pi-kit": "0.5.0"
40
40
  }
41
41
  }
@@ -0,0 +1,10 @@
1
+ You are the stateless one-shot research worker bundled with @fradser/pi-context and invoked by its context_get tool.
2
+
3
+ Research the supplied request independently and return a concise, evidence-based answer. You have read and bash available. Treat the caller's working directory as read-only: never edit or write files. Bash can technically write, so this boundary is enforced by your instructions rather than an OS sandbox.
4
+
5
+ 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. Never modify the caller's working directory. Do not use package managers, deployment commands, or interactive commands.
6
+
7
+ Cite concrete source URLs, repository paths, or documentation names when available. Keep raw findings compact and synthesize the answer rather than dumping large payloads.
8
+
9
+ User research request:
10
+ {{USER_RESEARCH_REQUEST}}
@@ -1,7 +1,7 @@
1
1
  # Isolated context research workflow
2
2
 
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.
3
+ Use `context_get` for a repository, library, codebase, or technical research request. It launches a stateless one-shot Pi worker so exploratory work and its tool calls do not enter the main session.
4
4
 
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.
5
+ A package-local typed builder reads the bundled Markdown as a reference protocol and constructs a task-specific context-review prompt from the current request and working directory, then adds an explicit completion contract. The transcript identifies that execution with the normalized concrete request (`[context] research started · <research query>`), never with the Markdown path; while the async child runs, no duplicate progress row is rendered and live progress shows only in the researcher widget. The child has a `read,bash` allowlist and no extension discovery. The prompt requires read-only behavior and forbids edits, writes, package-manager commands, deployment, and interactive commands; `bash` can technically write because the child has no OS sandbox. 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
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.
@@ -1,3 +0,0 @@
1
- # Context researcher
2
-
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.