@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 +6 -9
- package/extensions/context-command.ts +1 -1
- package/extensions/context-prompt.ts +75 -0
- package/extensions/context-tools.ts +168 -168
- package/package.json +5 -5
- 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
|
-
|
|
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
|
|
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 { 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
|
-
|
|
4
|
+
bindLifecycleRenderers,
|
|
5
|
+
contentDetailLines,
|
|
6
|
+
createLiveActivityWidget,
|
|
9
7
|
eventToolLifecycle,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
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
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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");
|
|
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(
|
|
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
|
-
});
|
|
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
|
|
201
|
-
promptSnippet: "Research independently in a
|
|
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-
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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.
|
|
4
|
-
"description": "Retrieve context for repositories, libraries, and technical questions with one native tool that launches an isolated
|
|
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
|
-
"
|
|
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": {
|
|
@@ -36,6 +36,6 @@
|
|
|
36
36
|
"typebox": "*"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@fradser/pi-kit": "0.
|
|
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}}
|
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.
|