@fradser/pi-context 0.6.0 → 0.6.1
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 +6 -9
- package/extensions/context-command.ts +1 -1
- package/extensions/context-prompt.ts +75 -0
- package/extensions/context-tools.ts +171 -168
- package/package.json +4 -4
- package/prompts/context-research.md +10 -0
- package/references/workflow.md +2 -2
- package/agents/context-researcher.md +0 -3
package/README.md
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
30
|
+
pi --print --mode json --no-session -ne -ns -np -nc --no-themes --tools read,bash
|
|
35
31
|
```
|
|
36
32
|
|
|
37
|
-
The child
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 Pi process with a read/bash allowlist, disabled extension discovery, and prompt-level no-modification guidance; research stays out of the main session. The child may inspect public repositories with a depth-1 temporary clone under /tmp.
|
|
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,162 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
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 { getMarkdownTheme, keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, Markdown, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
3
|
import {
|
|
8
|
-
|
|
4
|
+
createLiveActivityWidget,
|
|
5
|
+
createStaticToolLifecycleResultRenderer,
|
|
9
6
|
eventToolLifecycle,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
runPiWorker,
|
|
8
|
+
type PiLiveWidgetContext,
|
|
9
|
+
type PiWorkerProgressUpdate,
|
|
13
10
|
} from "@fradser/pi-kit";
|
|
14
11
|
import { Type } from "typebox";
|
|
12
|
+
import { buildContextResearchPrompt } from "./context-prompt.ts";
|
|
15
13
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const RESEARCH_TOOLS = ["read", "bash"];
|
|
15
|
+
const FINAL_ANSWER_RETRY_INSTRUCTION = `
|
|
16
|
+
|
|
17
|
+
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
18
|
|
|
21
19
|
interface ToolTextResult {
|
|
22
20
|
content: [{ type: "text"; text: string }];
|
|
23
21
|
details: Record<string, unknown>;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
interface ToolResultForRendering {
|
|
27
|
-
content: unknown;
|
|
28
|
-
details?: unknown;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
interface ChildResult {
|
|
32
25
|
text: string;
|
|
33
26
|
stderr: string;
|
|
34
27
|
exitCode: number;
|
|
35
28
|
cancelled: boolean;
|
|
36
|
-
timedOut: boolean;
|
|
37
29
|
}
|
|
38
30
|
|
|
31
|
+
interface ResearchAttemptResult extends ChildResult {
|
|
32
|
+
retried: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ResearchRunner = (
|
|
36
|
+
query: string,
|
|
37
|
+
signal: AbortSignal | undefined,
|
|
38
|
+
onUpdate: ((update: PiWorkerProgressUpdate) => void) | undefined,
|
|
39
|
+
finalAnswerRetry: boolean,
|
|
40
|
+
) => Promise<ChildResult>;
|
|
41
|
+
|
|
39
42
|
function textResult(text: string, details: Record<string, unknown> = {}): ToolTextResult {
|
|
40
43
|
return { content: [{ type: "text", text }], details };
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function
|
|
44
|
-
|
|
46
|
+
export function formatResearchSubject(query: unknown): string {
|
|
47
|
+
if (typeof query !== "string") return "context request";
|
|
48
|
+
return query.replace(/\s+/g, " ").trim() || "context request";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderContextCall(
|
|
52
|
+
query: unknown,
|
|
53
|
+
theme: { fg(color: string, text: string): string; bold(text: string): string },
|
|
54
|
+
): Component {
|
|
55
|
+
return {
|
|
56
|
+
render: (width) => {
|
|
57
|
+
if (width <= 0) return [];
|
|
58
|
+
const prefix = theme.fg("customMessageLabel", theme.bold("[context]"));
|
|
59
|
+
const title = `research started · ${formatResearchSubject(query)}`;
|
|
60
|
+
return new Text(`${prefix} ${title}`, 0, 0).render(width)
|
|
61
|
+
.map((line) => truncateToWidth(line, width, "")).concat("");
|
|
62
|
+
},
|
|
63
|
+
invalidate: () => {},
|
|
64
|
+
};
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
return normalized.length <= 120 ? normalized : `${normalized.slice(0, 117)}...`;
|
|
67
|
+
function resultText(result: ToolTextResult): string {
|
|
68
|
+
return result.content.find((part) => part.type === "text")?.text ?? "";
|
|
50
69
|
}
|
|
51
70
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
export function buildResearchPrompt(query: string, finalAnswerRetry = false): string {
|
|
72
|
+
const prompt = buildContextResearchPrompt({
|
|
73
|
+
userResearchRequest: query,
|
|
74
|
+
workingDirectory: process.cwd(),
|
|
75
|
+
});
|
|
76
|
+
return finalAnswerRetry ? `${prompt}${FINAL_ANSWER_RETRY_INSTRUCTION}` : prompt;
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
79
|
+
/** Minimal structural view of the tool-execution context: only what the research widget needs. */
|
|
80
|
+
export type ResearchWidgetContext = PiLiveWidgetContext;
|
|
81
|
+
|
|
82
|
+
interface ActiveResearch {
|
|
83
|
+
token: number;
|
|
84
|
+
ctx: ResearchWidgetContext | undefined;
|
|
85
|
+
activity?: string;
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"",
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
let researchToken = 0;
|
|
89
|
+
let activeResearch: ActiveResearch | undefined;
|
|
90
|
+
const researchWidget = createLiveActivityWidget({
|
|
91
|
+
key: "context-research",
|
|
92
|
+
placement: "aboveEditor",
|
|
93
|
+
fit: truncateToWidth,
|
|
94
|
+
formatIdentity: (identity, theme) => theme.fg("success", theme.bold(identity)),
|
|
95
|
+
formatActivity: (activity, theme) => new Markdown(
|
|
96
|
+
activity, 0, 0, getMarkdownTheme(), { color: (text) => theme.fg("accent", text) },
|
|
97
|
+
).render(Math.max(1, visibleWidth(activity))).map((line) => line.trim()).filter(Boolean).join(" "),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Show the running-research status above the editor; returns a token for updates/clear. */
|
|
101
|
+
export function startResearchWidget(ctx: ResearchWidgetContext | undefined): number {
|
|
102
|
+
const token = ++researchToken;
|
|
103
|
+
activeResearch = { token, ctx };
|
|
104
|
+
researchWidget.update(ctx, [{ id: String(token), identity: "researcher" }]);
|
|
105
|
+
return token;
|
|
90
106
|
}
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
/** Refresh the live activity line; stale tokens are ignored. */
|
|
109
|
+
export function updateResearchWidget(token: number, activity: string | undefined): void {
|
|
110
|
+
if (activeResearch?.token !== token) return;
|
|
111
|
+
if (activity) activeResearch.activity = activity;
|
|
112
|
+
researchWidget.update(activeResearch.ctx, [{
|
|
113
|
+
id: String(token),
|
|
114
|
+
identity: "researcher",
|
|
115
|
+
activity: activeResearch.activity,
|
|
116
|
+
}]);
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
"(allow process*)",
|
|
116
|
-
"(allow sysctl-read)",
|
|
117
|
-
"(allow file-read*)",
|
|
118
|
-
`(allow file-write* (subpath \"${temporaryDirectory}\"))`,
|
|
119
|
-
"(allow network*)",
|
|
120
|
-
].join("\n");
|
|
119
|
+
/** Remove the status widget; stale tokens are ignored. */
|
|
120
|
+
export function clearResearchWidget(ctx: ResearchWidgetContext | undefined, token: number): void {
|
|
121
|
+
if (activeResearch?.token !== token) return;
|
|
122
|
+
activeResearch = undefined;
|
|
123
|
+
researchWidget.clear(ctx);
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
export function runResearchChild(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
});
|
|
126
|
+
export function runResearchChild(
|
|
127
|
+
query: string,
|
|
128
|
+
signal?: AbortSignal,
|
|
129
|
+
onUpdate?: (update: PiWorkerProgressUpdate) => void,
|
|
130
|
+
finalAnswerRetry = false,
|
|
131
|
+
): Promise<ChildResult> {
|
|
132
|
+
let cancelled = signal?.aborted ?? false;
|
|
133
|
+
const onAbort = () => { cancelled = true; };
|
|
134
|
+
if (!signal?.aborted) signal?.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
return runPiWorker({
|
|
136
|
+
prompt: buildResearchPrompt(query, finalAnswerRetry),
|
|
137
|
+
cwd: process.cwd(),
|
|
138
|
+
tools: RESEARCH_TOOLS,
|
|
139
|
+
minimal: true,
|
|
140
|
+
signal,
|
|
141
|
+
onUpdate,
|
|
142
|
+
}).then((result) => {
|
|
143
|
+
signal?.removeEventListener("abort", onAbort);
|
|
144
|
+
return { text: result.text.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode, cancelled };
|
|
189
145
|
});
|
|
190
146
|
}
|
|
191
147
|
|
|
148
|
+
export async function runResearchWithFinalAnswerRetry(
|
|
149
|
+
query: string,
|
|
150
|
+
signal?: AbortSignal,
|
|
151
|
+
onUpdate?: (update: PiWorkerProgressUpdate) => void,
|
|
152
|
+
runner: ResearchRunner = runResearchChild,
|
|
153
|
+
): Promise<ResearchAttemptResult> {
|
|
154
|
+
const first = await runner(query, signal, onUpdate, false);
|
|
155
|
+
if (first.cancelled || first.exitCode !== 0 || first.text) return { ...first, retried: false };
|
|
156
|
+
const retry = await runner(query, signal, onUpdate, true);
|
|
157
|
+
return { ...retry, retried: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
192
160
|
const ResearchParams = Type.Object({
|
|
193
161
|
query: Type.String({ description: "Research request: repository, library, codebase question, or current technical topic." }),
|
|
194
162
|
});
|
|
@@ -197,30 +165,65 @@ export function registerContextTools(pi: ExtensionAPI): void {
|
|
|
197
165
|
pi.registerTool({
|
|
198
166
|
name: "context_get",
|
|
199
167
|
label: "Isolated Pi research",
|
|
200
|
-
description: "Research a repository, library, codebase, or technical topic in an independent
|
|
201
|
-
promptSnippet: "Research independently in a
|
|
168
|
+
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.",
|
|
169
|
+
promptSnippet: "Research independently in a prompt-constrained Pi child process",
|
|
202
170
|
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-
|
|
171
|
+
"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
172
|
],
|
|
205
173
|
parameters: ResearchParams,
|
|
206
174
|
executionMode: "sequential",
|
|
207
175
|
renderShell: "self",
|
|
208
|
-
renderCall
|
|
209
|
-
|
|
210
|
-
const params = context.args as { query?: string };
|
|
211
|
-
return renderContextResult(result, options, theme, context, compactSubject(params.query));
|
|
176
|
+
renderCall(args, theme, _context) {
|
|
177
|
+
return renderContextCall(args.query, theme);
|
|
212
178
|
},
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
179
|
+
renderResult(result, options, theme, context) {
|
|
180
|
+
// While the async child is still running, live progress already shows
|
|
181
|
+
// in the `researcher` widget above the editor, so a partial result
|
|
182
|
+
// renders no transcript row: the `research started` call row stays the
|
|
183
|
+
// single visible row until the settled `[context] researched` result.
|
|
184
|
+
if (options.isPartial) {
|
|
185
|
+
return { render: () => [] as string[], invalidate: () => {} };
|
|
219
186
|
}
|
|
220
|
-
|
|
221
|
-
|
|
187
|
+
const query = (context.args as { query?: string })?.query;
|
|
188
|
+
const subject = formatResearchSubject(query);
|
|
189
|
+
const text = resultText(result as ToolTextResult);
|
|
190
|
+
const details = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
191
|
+
const spec = eventToolLifecycle("context", subject, {
|
|
192
|
+
label: "researched",
|
|
193
|
+
details,
|
|
194
|
+
detailLimit: "all",
|
|
195
|
+
});
|
|
196
|
+
return createStaticToolLifecycleResultRenderer({
|
|
197
|
+
createSpec: () => spec,
|
|
198
|
+
expandHint: keyHint("app.tools.expand", "to expand"),
|
|
199
|
+
fit: truncateToWidth,
|
|
200
|
+
visibleWidth,
|
|
201
|
+
wrapDetail: (line, width) => wrapTextWithAnsi(line, Math.max(1, width)),
|
|
202
|
+
})(result as ToolTextResult, options, theme, context);
|
|
203
|
+
},
|
|
204
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
205
|
+
const widget = startResearchWidget(ctx as ResearchWidgetContext | undefined);
|
|
206
|
+
try {
|
|
207
|
+
const child = await runResearchWithFinalAnswerRetry(params.query, signal, (progress) => {
|
|
208
|
+
updateResearchWidget(widget, progress.activity);
|
|
209
|
+
onUpdate?.(textResult(progress.activity ?? "Working...", {
|
|
210
|
+
operation: "context-review",
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
213
|
+
if (child.cancelled) throw new Error("Isolated Pi research was cancelled");
|
|
214
|
+
if (child.exitCode !== 0) {
|
|
215
|
+
const phase = child.retried ? " retry" : "";
|
|
216
|
+
throw new Error(`Isolated Pi research${phase} failed (exit ${child.exitCode}): ${child.stderr.slice(0, 400)}`);
|
|
217
|
+
}
|
|
218
|
+
if (!child.text) throw new Error("Isolated Pi research returned no answer after retry");
|
|
219
|
+
return textResult(child.text, {
|
|
220
|
+
exitCode: child.exitCode,
|
|
221
|
+
operation: "context-review",
|
|
222
|
+
...(child.retried ? { retried: true } : {}),
|
|
223
|
+
});
|
|
224
|
+
} finally {
|
|
225
|
+
clearResearchWidget(ctx as ResearchWidgetContext | undefined, widget);
|
|
222
226
|
}
|
|
223
|
-
return textResult(truncate(child.text), { exitCode: child.exitCode });
|
|
224
227
|
},
|
|
225
228
|
});
|
|
226
229
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fradser/pi-context",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Retrieve context for repositories, libraries, and technical questions with one native tool that launches an isolated
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
-
"
|
|
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
|
-
"
|
|
24
|
+
"prompts",
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"pi": {
|
|
@@ -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}}
|
package/references/workflow.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|