@hicaru/pi-rlm 0.1.8 → 0.1.9
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 +22 -19
- package/package.json +2 -1
- package/src/bridge/library.ts +93 -15
- package/src/bridge/llm-query.ts +1 -0
- package/src/bridge/rlm-query.ts +2 -4
- package/src/context/library-context.ts +209 -22
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +130 -134
- package/src/core/gates.ts +30 -1
- package/src/core/pipeline.ts +38 -13
- package/src/core/types.ts +1 -3
- package/src/index.ts +1 -9
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +18 -39
- package/src/prompts/system.ts +31 -19
- package/src/sandbox/protocol.ts +5 -10
- package/src/sandbox/sandbox.ts +43 -9
- package/src/sandbox/worker.py +180 -48
- package/src/state/resume.ts +21 -14
- package/src/state/rows.ts +2 -2
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +29 -55
- package/src/tool/rlm-aggregator.ts +7 -7
- package/src/tool/rlm-details.ts +6 -3
- package/src/tool/rlm-events.ts +14 -11
- package/src/tool/rlm-tool.ts +2 -7
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +2 -2
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -164
- package/src/tool/apply-edits-tool.ts +0 -295
package/src/core/gates.ts
CHANGED
|
@@ -78,6 +78,35 @@ export function countHeadingsOutsideFences(content: string, re: RegExp): number
|
|
|
78
78
|
return count;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Count newlines without materialising a line array. */
|
|
82
|
+
export function lineCountOf(text: string): number {
|
|
83
|
+
let count = 1;
|
|
84
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++;
|
|
85
|
+
return count;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Names of `## Phase N:` sections that contain no `### Success Criteria` heading. */
|
|
89
|
+
export function phasesMissingSuccessCriteria(content: string): readonly string[] {
|
|
90
|
+
const missing: string[] = [];
|
|
91
|
+
let currentPhase: string | undefined;
|
|
92
|
+
let sawCriteria = false;
|
|
93
|
+
const flush = (): void => {
|
|
94
|
+
if (currentPhase !== undefined && !sawCriteria) missing.push(currentPhase);
|
|
95
|
+
};
|
|
96
|
+
forEachLineOutsideFences(content, (line) => {
|
|
97
|
+
const phase = /^## Phase (\d+):/.exec(line);
|
|
98
|
+
if (phase) {
|
|
99
|
+
flush();
|
|
100
|
+
currentPhase = `Phase ${phase[1] ?? "?"}`;
|
|
101
|
+
sawCriteria = false;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (currentPhase !== undefined && /^### Success Criteria/.test(line)) sawCriteria = true;
|
|
105
|
+
});
|
|
106
|
+
flush();
|
|
107
|
+
return Object.freeze(missing);
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
/**
|
|
82
111
|
* Fence-aware count of top-level (column-0) `- ` bullets under a `## <heading>` section.
|
|
83
112
|
* Nested/indented sub-bullets are ignored. The next `## ` heading ends the section.
|
|
@@ -207,7 +236,7 @@ export function verifyCitations(body: string, cwd: string): GateResult<undefined
|
|
|
207
236
|
}
|
|
208
237
|
let lineCount: number;
|
|
209
238
|
try {
|
|
210
|
-
lineCount = readFileSync(abs, "utf-8")
|
|
239
|
+
lineCount = lineCountOf(readFileSync(abs, "utf-8"));
|
|
211
240
|
} catch {
|
|
212
241
|
errors.push(`unbacked citation ${key} — file could not be read`);
|
|
213
242
|
continue;
|
package/src/core/pipeline.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RLM pipeline stage graph — data-driven transitions with deterministic gates.
|
|
3
3
|
*
|
|
4
|
-
* Stages: clarify → research → blueprint →
|
|
4
|
+
* Stages: clarify → research → blueprint → validate
|
|
5
5
|
* (clarify skipped when askUserQuestion is off).
|
|
6
6
|
* The root RLM writes artifacts via save_artifact(); advance_phase() is gated by
|
|
7
7
|
* TypeScript floors (never LLM judgment). Validate routes on measured
|
|
8
8
|
* blockers_count with a bounded corrective loop back to blueprint.
|
|
9
|
+
* The pipeline is read-only by design: it produces a validated plan and does not
|
|
10
|
+
* write source. Accidental sandbox writes are blocked (open/pathlib/os.open);
|
|
11
|
+
* this is steering, not a hard security boundary.
|
|
9
12
|
*/
|
|
10
13
|
import {
|
|
11
14
|
checkStatusReady,
|
|
@@ -19,16 +22,31 @@ import {
|
|
|
19
22
|
verifyCitations,
|
|
20
23
|
} from "./gates.ts";
|
|
21
24
|
|
|
22
|
-
export type Phase = "clarify" | "research" | "blueprint" | "
|
|
25
|
+
export type Phase = "clarify" | "research" | "blueprint" | "validate";
|
|
23
26
|
|
|
24
27
|
export const PHASES = Object.freeze([
|
|
25
28
|
"clarify",
|
|
26
29
|
"research",
|
|
27
30
|
"blueprint",
|
|
28
|
-
"implement",
|
|
29
31
|
"validate",
|
|
30
32
|
] as const satisfies readonly Phase[]);
|
|
31
33
|
|
|
34
|
+
const PHASE_SET: ReadonlySet<string> = Object.freeze(new Set<string>(PHASES));
|
|
35
|
+
|
|
36
|
+
export function isPhase(value: unknown): value is Phase {
|
|
37
|
+
return typeof value === "string" && PHASE_SET.has(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map a persisted phase name onto the current graph. Trails written before the
|
|
42
|
+
* implement phase was retired resume at `blueprint` — the last phase whose
|
|
43
|
+
* artifact is still meaningful.
|
|
44
|
+
*/
|
|
45
|
+
export function reconcilePhase(persisted: unknown): Phase {
|
|
46
|
+
if (isPhase(persisted)) return persisted;
|
|
47
|
+
return "blueprint";
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
/** Kind string the model passes to save_artifact(kind, content). */
|
|
33
51
|
export type ArtifactKind = "clarification" | "research" | "plan" | "validation";
|
|
34
52
|
|
|
@@ -37,8 +55,7 @@ export type StageGateData =
|
|
|
37
55
|
| { readonly kind: "clarification"; readonly clarification: ClarificationGateData }
|
|
38
56
|
| { readonly kind: "research" }
|
|
39
57
|
| { readonly kind: "plan"; readonly plan: PlanGateData }
|
|
40
|
-
| { readonly kind: "validation"; readonly validation: ValidationGateData }
|
|
41
|
-
| { readonly kind: "side-effect" };
|
|
58
|
+
| { readonly kind: "validation"; readonly validation: ValidationGateData };
|
|
42
59
|
|
|
43
60
|
export interface StageDef {
|
|
44
61
|
readonly phase: Phase;
|
|
@@ -90,13 +107,6 @@ export const STAGES: Readonly<Record<Phase, StageDef>> = Object.freeze({
|
|
|
90
107
|
clarify: { phase: "clarify", artifactDir: "clarifications", artifactKind: "clarification", gate: clarifyGate },
|
|
91
108
|
research: { phase: "research", artifactDir: "research", artifactKind: "research", gate: researchGate },
|
|
92
109
|
blueprint: { phase: "blueprint", artifactDir: "plans", artifactKind: "plan", gate: blueprintGate },
|
|
93
|
-
// implement is a side-effect stage; exit is engine-driven (serial fanout).
|
|
94
|
-
implement: {
|
|
95
|
-
phase: "implement",
|
|
96
|
-
artifactDir: "",
|
|
97
|
-
artifactKind: "",
|
|
98
|
-
gate: (): GateResult<StageGateData> => ({ ok: true, value: { kind: "side-effect" } }),
|
|
99
|
-
},
|
|
100
110
|
validate: { phase: "validate", artifactDir: "validations", artifactKind: "validation", gate: validateGate },
|
|
101
111
|
});
|
|
102
112
|
|
|
@@ -115,12 +125,27 @@ export function stageForArtifactKind(kind: string): StageDef | undefined {
|
|
|
115
125
|
return undefined;
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
/** An artifact path plus its lifecycle status — append-only, never deleted. */
|
|
129
|
+
export interface ArtifactRef {
|
|
130
|
+
readonly path: string;
|
|
131
|
+
readonly status: "active" | "superseded";
|
|
132
|
+
/** Path of the artifact that superseded this one (the rejecting validation). */
|
|
133
|
+
readonly supersededBy?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The latest artifact saved for a stage, plus its gate result when it passed. */
|
|
137
|
+
export interface SavedArtifact {
|
|
138
|
+
readonly path: string;
|
|
139
|
+
/** Gate payload when the artifact passed; undefined ⇒ advance_phase must re-gate. */
|
|
140
|
+
readonly gateData?: StageGateData;
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
export interface PhaseState {
|
|
119
144
|
readonly current: Phase;
|
|
120
145
|
readonly advancedAt: number;
|
|
121
146
|
readonly summary?: string;
|
|
122
147
|
/** Artifact each completed stage produced — the named channels. */
|
|
123
|
-
readonly artifacts: Readonly<Partial<Record<Phase,
|
|
148
|
+
readonly artifacts: Readonly<Partial<Record<Phase, ArtifactRef>>>;
|
|
124
149
|
/** Corrective validate→blueprint re-entries taken so far. */
|
|
125
150
|
readonly backwardJumps: number;
|
|
126
151
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared configuration + runtime types for the RLM engine. */
|
|
2
2
|
|
|
3
3
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
4
|
-
import type { AskAnswer, AskQuestion
|
|
4
|
+
import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
|
|
5
5
|
import type { ReconstructResult } from "../state/resume.ts";
|
|
6
6
|
|
|
7
7
|
export interface Sampling {
|
|
@@ -105,8 +105,6 @@ export interface RlmInput {
|
|
|
105
105
|
/** Result of a completed RLM run. */
|
|
106
106
|
export interface RlmResult {
|
|
107
107
|
readonly answer: string;
|
|
108
|
-
/** Legacy anchor edits retained for compatibility while older run-state rows exist. */
|
|
109
|
-
readonly edits?: readonly ProposedEdit[];
|
|
110
108
|
readonly iterations: number;
|
|
111
109
|
readonly costUsd: number;
|
|
112
110
|
readonly inputTokens: number;
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,6 @@ import { registerRlmCommand } from "./commands/rlm.ts";
|
|
|
7
7
|
import { registerRlmConfigCommand } from "./commands/rlm-config.ts";
|
|
8
8
|
import { createRlmTool } from "./tool/rlm-tool.ts";
|
|
9
9
|
import { createReplTool } from "./tool/repl-tool.ts";
|
|
10
|
-
import { createApplyEditsTool } from "./tool/apply-edits-tool.ts";
|
|
11
|
-
import { EditRegistry } from "./registry/edit-registry.ts";
|
|
12
10
|
import { loadSettings, mergeConfig, resolveModelId } from "./config/settings.ts";
|
|
13
11
|
import { RlmController, cheapestModel } from "./mode/rlm-mode.ts";
|
|
14
12
|
import { postRlmGuide } from "./ui/intro.ts";
|
|
@@ -26,7 +24,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
26
24
|
// Init synchronously with defaults — ensures commands/tools/handlers register before session_start
|
|
27
25
|
const config = mergeConfig({});
|
|
28
26
|
const controller = new RlmController(config);
|
|
29
|
-
const editRegistry = new EditRegistry();
|
|
30
27
|
let onSandboxDiscardExtra: (() => void) | undefined;
|
|
31
28
|
const sandboxManager = new SandboxManager({
|
|
32
29
|
execTimeoutS: config.execTimeoutS,
|
|
@@ -34,10 +31,7 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
34
31
|
python: config.python,
|
|
35
32
|
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
36
33
|
maxPromptChars: config.maxPromptChars,
|
|
37
|
-
onSandboxDiscarded: () => {
|
|
38
|
-
editRegistry.clear();
|
|
39
|
-
onSandboxDiscardExtra?.();
|
|
40
|
-
},
|
|
34
|
+
onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
|
|
41
35
|
});
|
|
42
36
|
let packedContextText: string | undefined;
|
|
43
37
|
let contextPackPromise: Promise<string | undefined> | undefined;
|
|
@@ -85,7 +79,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
85
79
|
|
|
86
80
|
// ── Tool registration ──
|
|
87
81
|
pi.registerTool(createRlmTool(controller));
|
|
88
|
-
pi.registerTool(createApplyEditsTool(editRegistry));
|
|
89
82
|
let guidePosted = false;
|
|
90
83
|
|
|
91
84
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -109,7 +102,6 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
109
102
|
getModel: () => controller.resolveModels(ctx)?.model,
|
|
110
103
|
getWorkerModel: () => controller.resolveModels(ctx)?.worker,
|
|
111
104
|
registry: ctx.modelRegistry,
|
|
112
|
-
editRegistry,
|
|
113
105
|
config: controller.config,
|
|
114
106
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
115
107
|
ensureContext: async () => {
|
|
@@ -86,7 +86,7 @@ export function replDelegationNudge(stdoutChars: number, delegated: boolean): st
|
|
|
86
86
|
if (delegated || stdoutChars <= NUDGE_STDOUT_CHARS) return undefined;
|
|
87
87
|
return (
|
|
88
88
|
`\n[RLM: this repl() printed ${stdoutChars.toLocaleString()} chars with 0 sub-LLM calls — ` +
|
|
89
|
-
"
|
|
90
|
-
"
|
|
89
|
+
"if you were READING, delegate via llm_query / llm_query_batched / llm_query_chunked. " +
|
|
90
|
+
"Authoring an edit body yourself is correct and needs no delegation.]"
|
|
91
91
|
);
|
|
92
92
|
}
|
package/src/prompts/phases.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Injected at each phase boundary; the only channel between phases is durable
|
|
4
4
|
* artifacts under .rlm/artifacts/.
|
|
5
5
|
*/
|
|
6
|
-
import type { PhaseRecord } from "../core/gates.ts";
|
|
7
6
|
import type { Phase } from "../core/pipeline.ts";
|
|
8
7
|
|
|
9
8
|
const CLARIFY_GUIDANCE = Object.freeze([
|
|
@@ -48,7 +47,7 @@ const RESEARCH_GUIDANCE = Object.freeze([
|
|
|
48
47
|
"Probe the repository, delegate long reads via `llm_query_batched` / `llm_query_chunked`.",
|
|
49
48
|
"Every factual claim about the code MUST be cited as `path/file.ext:LINE` (or `LINE-LINE`).",
|
|
50
49
|
"The engine VERIFIES citations against the working tree — unbacked citations reject advance.",
|
|
51
|
-
"When research is complete, write ONE research document and call:",
|
|
50
|
+
"When research is complete, write ONE research document with a `## Findings` section and call:",
|
|
52
51
|
' `save_artifact("research", content)` # frontmatter must include `status: ready`',
|
|
53
52
|
' `advance_phase("blueprint", summary)`',
|
|
54
53
|
"Do not implement code in this phase.",
|
|
@@ -59,39 +58,37 @@ const BLUEPRINT_GUIDANCE = Object.freeze([
|
|
|
59
58
|
"Read the clarifications artifact (if present) and plan only within recorded Decisions;",
|
|
60
59
|
"Open Questions that would block the design must be re-asked via ask_user_question, not assumed.",
|
|
61
60
|
'Produce ONE plan document and save it with `save_artifact("plan", content)`.',
|
|
61
|
+
"This pipeline is read-only: it produces a plan a human or a native-mode agent executes;",
|
|
62
|
+
"do not write source files here (sandbox write modes are blocked).",
|
|
63
|
+
"Write changes as exact, copy-pasteable before/after code — never as prose instructions.",
|
|
62
64
|
"The ENGINE derive-checks the document — these are hard gates, not suggestions:",
|
|
63
65
|
"- frontmatter: `status: ready`, `phase_count: N`, `phases: [{n: 1, title: ...}, ...]`",
|
|
64
66
|
"- `phases:` / `phase_count` MUST match the `## Phase N:` body headings exactly (fenced examples ignored)",
|
|
65
67
|
"- every `file:line` citation MUST resolve against the working tree at this revision",
|
|
66
68
|
"Each `## Phase N: <title>` section contains:",
|
|
67
|
-
"- `### Changes Required` — per file: path + exact intended change
|
|
69
|
+
"- `### Changes Required` — per file: path + the exact intended change",
|
|
68
70
|
"- `### Success Criteria` — `#### Automated Verification:` (runnable commands) and",
|
|
69
71
|
" `#### Manual Verification:` checklists",
|
|
70
|
-
"Phases
|
|
71
|
-
|
|
72
|
-
"later phase unless that phase only EDITS what an earlier phase CREATED.",
|
|
73
|
-
'When ready: `advance_phase("implement", summary)` — the engine runs implement fanout itself.',
|
|
74
|
-
].join("\n"));
|
|
75
|
-
|
|
76
|
-
const IMPLEMENT_GUIDANCE = Object.freeze([
|
|
77
|
-
"## Implement phase",
|
|
78
|
-
"The engine drives serial child-RLM workers over each plan phase — you do not implement",
|
|
79
|
-
"here. If you were re-entered into implement unexpectedly, call",
|
|
80
|
-
'`advance_phase("validate")` only after the fanout has already completed.',
|
|
72
|
+
"Phases must be independently implementable in order, each leaving the tree working.",
|
|
73
|
+
'When ready: `advance_phase("validate", summary)`.',
|
|
81
74
|
].join("\n"));
|
|
82
75
|
|
|
83
76
|
const VALIDATE_GUIDANCE = Object.freeze([
|
|
84
|
-
"## Validate phase",
|
|
77
|
+
"## Validate phase (adversarial plan review)",
|
|
78
|
+
"Nothing has been implemented — you are reviewing the PLAN, not a diff.",
|
|
85
79
|
"Read the goal artifact (verbatim brief), the clarifications artifact (recorded Decisions),",
|
|
86
|
-
"and the plan.
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
80
|
+
"and the plan. For each plan phase check against the working tree:",
|
|
81
|
+
"- every cited `file:line` still resolves and says what the plan claims",
|
|
82
|
+
"- every file the phase edits exists (or is created by an earlier phase)",
|
|
83
|
+
"- no phase contradicts a recorded Decision or silently resolves an Open Question",
|
|
84
|
+
"- the Success Criteria are actually runnable and actually prove the change",
|
|
85
|
+
"- phases are independently implementable in the stated order",
|
|
86
|
+
"Write ONE review via `save_artifact(\"validation\", content)` with frontmatter:",
|
|
90
87
|
"- `status: ready`",
|
|
91
|
-
"- `blockers_count: <int
|
|
88
|
+
"- `blockers_count: <int >= 0>` # MEASURED — routing key; not prose",
|
|
92
89
|
"- `verdict: pass | fail` # pass requires blockers_count === 0",
|
|
93
90
|
"Each blocker needs a resolvable `file:line` citation.",
|
|
94
|
-
"Then finalize: `answer[\"content\"] = <
|
|
91
|
+
"Then finalize: `answer[\"content\"] = <the reviewed plan + review summary>; answer[\"ready\"] = True`.",
|
|
95
92
|
"If `blockers_count > 0`, the engine re-enters blueprint (bounded by maxBackwardJumps).",
|
|
96
93
|
].join("\n"));
|
|
97
94
|
|
|
@@ -99,27 +96,9 @@ const GUIDANCE: Readonly<Record<Phase, string>> = Object.freeze({
|
|
|
99
96
|
clarify: CLARIFY_GUIDANCE,
|
|
100
97
|
research: RESEARCH_GUIDANCE,
|
|
101
98
|
blueprint: BLUEPRINT_GUIDANCE,
|
|
102
|
-
implement: IMPLEMENT_GUIDANCE,
|
|
103
99
|
validate: VALIDATE_GUIDANCE,
|
|
104
100
|
});
|
|
105
101
|
|
|
106
102
|
export function phaseGuidance(phase: Phase): string {
|
|
107
103
|
return GUIDANCE[phase];
|
|
108
104
|
}
|
|
109
|
-
|
|
110
|
-
/** Single-phase implement prompt for an isolated child RLM (serial fanout unit). */
|
|
111
|
-
export function buildImplementPhasePrompt(planPath: string, r: PhaseRecord): string {
|
|
112
|
-
return [
|
|
113
|
-
`Implement ONLY Phase ${r.n} (${r.title}) of the plan at ${planPath} (${r.index + 1}/${r.total}).`,
|
|
114
|
-
`Read the plan from the REPL (open("${planPath}").read()).`,
|
|
115
|
-
"Hard rules (you are one unit of a sequenced run):",
|
|
116
|
-
"- CRITICAL: `context` is a STALE snapshot from run start. Always `open(path).read()` any file",
|
|
117
|
-
" you will edit BEFORE computing stage_edit anchors — earlier phases may have already changed them.",
|
|
118
|
-
"- Touch ONLY the files this phase's ### Changes Required lists.",
|
|
119
|
-
"- Earlier phases have already landed; a missing prerequisite file is a HARD ERROR —",
|
|
120
|
-
" finalize with 'Error: prerequisite missing: <path>' instead of creating it.",
|
|
121
|
-
"- Stage every change via stage_edit(path, old_text, new_text); never defer your own edits.",
|
|
122
|
-
"- Run only THIS phase's '#### Automated Verification:' commands; whole-plan checks are validate's job.",
|
|
123
|
-
`Finalize with a short summary of what landed for Phase ${r.n}.`,
|
|
124
|
-
].join("\n");
|
|
125
|
-
}
|
package/src/prompts/system.ts
CHANGED
|
@@ -141,13 +141,21 @@ function replGlossary(
|
|
|
141
141
|
}
|
|
142
142
|
if (libraryLoader) {
|
|
143
143
|
lines.push(
|
|
144
|
-
"- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
" (shallow-cloned, then packed).
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
144
|
+
"- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document and",
|
|
145
|
+
" **APPEND its files into the existing `context` list** (same shape: path/content/tokens).",
|
|
146
|
+
" `source` may be a local directory (repomix-packed), a single file path, or an https/git@ URL",
|
|
147
|
+
" (shallow-cloned, then packed). Paths are namespaced under `lib/<source_id>/…` so you can filter",
|
|
148
|
+
" by prefix. Returns metadata only:",
|
|
149
|
+
" {\"source\", \"source_id\", \"path_prefix\", \"files\", \"chars\", \"context_len\", \"already_loaded\"}",
|
|
150
|
+
" or an \"Error: ...\" string. **Never treat the return value as the file list** — always search",
|
|
151
|
+
" and chunk the single variable `context`. Do not invent `context_1` / aliases; do not call",
|
|
152
|
+
" globals()/locals(). Idempotent: re-loading the same source is a no-op.",
|
|
153
|
+
"",
|
|
154
|
+
" ```python",
|
|
155
|
+
" info = load_library(\"/path/to/other-project\")",
|
|
156
|
+
" # info is metadata; files are already in context under info[\"path_prefix\"]",
|
|
157
|
+
" lib_files = [f for f in context if f[\"path\"].startswith(info[\"path_prefix\"])]",
|
|
158
|
+
" ```",
|
|
151
159
|
);
|
|
152
160
|
}
|
|
153
161
|
if (recursion) {
|
|
@@ -172,8 +180,8 @@ function replGlossary(
|
|
|
172
180
|
" Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
|
|
173
181
|
" Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
|
|
174
182
|
"- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
|
|
175
|
-
" Order: 'clarify' → 'research' → 'blueprint' → '
|
|
176
|
-
" clarify is skipped when ask_user_question is disabled).",
|
|
183
|
+
" Order: 'clarify' → 'research' → 'blueprint' → 'validate' (one step at a time;",
|
|
184
|
+
" clarify is skipped when ask_user_question is disabled). The pipeline is READ-ONLY.",
|
|
177
185
|
" **advance_phase is validated by the engine** — it measures the latest saved artifact",
|
|
178
186
|
" (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
|
|
179
187
|
" A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
|
|
@@ -262,9 +270,8 @@ function nativeReplGlossary(): string {
|
|
|
262
270
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
263
271
|
"",
|
|
264
272
|
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
265
|
-
"- `load_library(source)`
|
|
273
|
+
"- `load_library(source) -> dict`: append external dir/file/git tree into `context` under `lib/<id>/…`. Return is metadata only — always use `context`.",
|
|
266
274
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
267
|
-
"- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
|
|
268
275
|
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
|
|
269
276
|
"",
|
|
270
277
|
"### Orchestrator Pattern",
|
|
@@ -293,18 +300,19 @@ function nativeReplGlossary(): string {
|
|
|
293
300
|
"|------|------|",
|
|
294
301
|
"| `repl({code})` | Need to chunk/delegate `context` to sub-LLMs; need Python scripting; need REPL state across calls |",
|
|
295
302
|
"| `zebra-mcp` | Semantic search over the codebase |",
|
|
296
|
-
"| `edit` |
|
|
297
|
-
"| `write` | Create a new file
|
|
303
|
+
"| `edit` | Change an existing file. Compose oldText/newText yourself; exact match required |",
|
|
304
|
+
"| `write` | Create a new file |",
|
|
298
305
|
"| `llm_query` (inside repl) | Extract, summarize, or classify a chunk of text |",
|
|
299
306
|
"| `rlm_query` (inside repl) | Complex sub-task needing iterative reasoning with its own REPL |",
|
|
300
307
|
"| `todo` (inside repl) | Track multi-step progress visibly to the user |",
|
|
301
|
-
"| `stage_edit(path, old, new)` (inside repl) | Stage exact edit params; apply returned IDs with `apply_edits` |",
|
|
302
308
|
"",
|
|
303
309
|
"### Workflow",
|
|
304
310
|
"1. **Plan**: Create todos for the multi-step analysis. Probe `context` — print length, inspect a few entries.",
|
|
305
311
|
"2. **Chunk & Delegate**: Slice `context` into batches, delegate each batch to sub-LLMs via `llm_query_batched`.",
|
|
306
312
|
"3. **Aggregate**: Collect results in Python, pass aggregated results to a final `llm_query` or produce the answer directly.",
|
|
307
|
-
"4. **Finalize**: For file changes,
|
|
313
|
+
"4. **Finalize**: For file changes, call the native `edit` / `write` tools directly — you",
|
|
314
|
+
" author the change, and Pi validates the anchor and renders the diff. For analysis tasks,",
|
|
315
|
+
" write a normal message.",
|
|
308
316
|
"",
|
|
309
317
|
"### Task-Specific Patterns",
|
|
310
318
|
LARGE_FILE_RULE_NATIVE,
|
|
@@ -336,9 +344,11 @@ export function buildNativeSystemPrompt(): string {
|
|
|
336
344
|
"All file content is pre-loaded in the REPL `context` variable. Use ONLY `repl({code})`.",
|
|
337
345
|
"If sub-LLM credits are exhausted → report the error to the user and stop.",
|
|
338
346
|
"",
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
"
|
|
347
|
+
"AUTHORING RULE: sub-LLMs (`llm_query` family) READ — they extract, locate, and summarize.",
|
|
348
|
+
"They never author code you will ship. Once you know WHAT to change, compose the exact",
|
|
349
|
+
"oldText / newText yourself and apply it with `edit`. Delegated code is written by a small",
|
|
350
|
+
"model with no view of the codebase; it is a research aid, never a patch.",
|
|
351
|
+
"Never re-type file bodies or `answer[\"content\"]` in your own output.",
|
|
342
352
|
"",
|
|
343
353
|
nativeReplGlossary(),
|
|
344
354
|
].join("\n");
|
|
@@ -357,7 +367,9 @@ export const NATIVE_TURN_REMINDER = [
|
|
|
357
367
|
"repl() stdout to you is hard-capped at 4K chars; read/grep and bash readers are blocked.",
|
|
358
368
|
"Any SEMANTIC reading of file/text content MUST go through llm_query / llm_query_batched /",
|
|
359
369
|
"llm_query_chunked (rlm_query for iterative sub-tasks). Deterministic Python (search, count,",
|
|
360
|
-
"slice, json) is free.
|
|
370
|
+
"slice, json) is free. AUTHORING IS NOT READING: you write every edit body yourself and apply",
|
|
371
|
+
"it with the native `edit` / `write` tools — never delegate code you will ship to a sub-LLM.",
|
|
372
|
+
"Keep your own output to decisions, authored edits, and aggregation.]",
|
|
361
373
|
].join("\n");
|
|
362
374
|
|
|
363
375
|
/** The one-line context metadata, also reused by the per-turn prompt in headless mode. */
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -21,24 +21,21 @@ export interface LlmReply {
|
|
|
21
21
|
readonly response?: string;
|
|
22
22
|
readonly responses?: readonly string[];
|
|
23
23
|
readonly answers?: readonly AskAnswer[];
|
|
24
|
-
/** load_library reply: temp file with the packed payload +
|
|
24
|
+
/** load_library reply: temp file with the packed payload (+ resume index / namespace). */
|
|
25
25
|
readonly path?: string;
|
|
26
26
|
readonly json?: boolean;
|
|
27
27
|
readonly index?: number;
|
|
28
28
|
readonly files?: number;
|
|
29
29
|
readonly chars?: number;
|
|
30
|
+
readonly source_id?: string;
|
|
31
|
+
readonly path_prefix?: string;
|
|
32
|
+
/** Host-side idempotency: library already loaded — no path payload. */
|
|
33
|
+
readonly already_loaded?: boolean;
|
|
30
34
|
readonly error?: string;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export type ParentMessage = WorkerRequest | LlmReply;
|
|
34
38
|
|
|
35
|
-
export interface ProposedEdit {
|
|
36
|
-
readonly id: string;
|
|
37
|
-
readonly path: string;
|
|
38
|
-
readonly oldText: string;
|
|
39
|
-
readonly newText: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
39
|
/** A normal response to a request (keyed by the request `id`). */
|
|
43
40
|
export interface WorkerResponse {
|
|
44
41
|
readonly id: string;
|
|
@@ -49,7 +46,6 @@ export interface WorkerResponse {
|
|
|
49
46
|
readonly stderr?: string;
|
|
50
47
|
readonly final_answer?: string | null;
|
|
51
48
|
readonly answer_content?: string;
|
|
52
|
-
readonly edits?: readonly ProposedEdit[];
|
|
53
49
|
readonly raised?: boolean;
|
|
54
50
|
readonly execution_time?: number;
|
|
55
51
|
// user-created variable names after this exec (filters builtins/context) — Metadata(stdout) for history orientation
|
|
@@ -201,7 +197,6 @@ export interface ReplResult {
|
|
|
201
197
|
readonly stderr: string;
|
|
202
198
|
readonly finalAnswer: string | null;
|
|
203
199
|
readonly answerContent: string;
|
|
204
|
-
readonly edits: readonly ProposedEdit[];
|
|
205
200
|
readonly raised: boolean;
|
|
206
201
|
readonly executionTimeMs: number;
|
|
207
202
|
/** User-created variable names after this exec (builtins/context filtered out). */
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -28,10 +28,14 @@ import { formatError } from "../util/errors.ts";
|
|
|
28
28
|
|
|
29
29
|
/** Result of a host-side library pack requested by `load_library`. */
|
|
30
30
|
export interface LibraryLoadResult {
|
|
31
|
-
readonly payload: unknown; //
|
|
32
|
-
readonly index: number; //
|
|
31
|
+
readonly payload: unknown; // always ContextFile[] under lib/<id>/
|
|
32
|
+
readonly index: number; // resume-sidecar index (not a REPL var name); -1 if alreadyLoaded
|
|
33
33
|
readonly files?: number;
|
|
34
34
|
readonly chars: number;
|
|
35
|
+
readonly sourceId: string;
|
|
36
|
+
readonly pathPrefix: string;
|
|
37
|
+
/** Host already has this library — no pack, no sidecar, empty payload. */
|
|
38
|
+
readonly alreadyLoaded?: boolean;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
|
|
@@ -64,6 +68,11 @@ export interface SandboxOptions {
|
|
|
64
68
|
readonly initTimeoutMs?: number;
|
|
65
69
|
/** Sub-LLM prompt cap (chars) — sizes llm_query_chunked chunks inside the worker. */
|
|
66
70
|
readonly maxPromptChars?: number;
|
|
71
|
+
/**
|
|
72
|
+
* When true, the worker rejects open() write modes (pipeline read-only runs).
|
|
73
|
+
* Native repl() data work leaves this false so scratch-file writes still work.
|
|
74
|
+
*/
|
|
75
|
+
readonly readOnly?: boolean;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "worker.py");
|
|
@@ -132,6 +141,9 @@ export class PythonSandbox {
|
|
|
132
141
|
if (opts.maxPromptChars !== undefined) {
|
|
133
142
|
workerArgs.push("--max-prompt-chars", String(opts.maxPromptChars));
|
|
134
143
|
}
|
|
144
|
+
if (opts.readOnly) {
|
|
145
|
+
workerArgs.push("--read-only");
|
|
146
|
+
}
|
|
135
147
|
this.proc = spawn(
|
|
136
148
|
python,
|
|
137
149
|
workerArgs,
|
|
@@ -212,7 +224,6 @@ export class PythonSandbox {
|
|
|
212
224
|
stderr: res.stderr ?? "",
|
|
213
225
|
finalAnswer: res.final_answer ?? null,
|
|
214
226
|
answerContent: res.answer_content ?? "",
|
|
215
|
-
edits: res.edits ?? [],
|
|
216
227
|
raised: res.raised ?? false,
|
|
217
228
|
executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
|
|
218
229
|
varNames: res.var_names ?? [],
|
|
@@ -299,7 +310,7 @@ export class PythonSandbox {
|
|
|
299
310
|
|
|
300
311
|
/**
|
|
301
312
|
* Refresh the parent-side request watchdog for every pending request.
|
|
302
|
-
* Used during long mid-exec work
|
|
313
|
+
* Used during long mid-exec work that does not
|
|
303
314
|
* produce additional worker interrupts on this sandbox.
|
|
304
315
|
*/
|
|
305
316
|
refreshWatchdog(): void {
|
|
@@ -381,11 +392,31 @@ export class PythonSandbox {
|
|
|
381
392
|
this.reply(msg.rid, { response });
|
|
382
393
|
} else if (msg.type === "load_library") {
|
|
383
394
|
const lib = await h.loadLibrary(msg.source ?? "", d);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
395
|
+
if (lib.alreadyLoaded) {
|
|
396
|
+
// No temp file — worker short-circuits on already_loaded.
|
|
397
|
+
this.reply(msg.rid, {
|
|
398
|
+
already_loaded: true,
|
|
399
|
+
index: lib.index,
|
|
400
|
+
files: 0,
|
|
401
|
+
chars: lib.chars,
|
|
402
|
+
source_id: lib.sourceId,
|
|
403
|
+
path_prefix: lib.pathPrefix,
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
const isJson = typeof lib.payload !== "string";
|
|
407
|
+
const path = await this.writeContextFile(lib.payload, isJson);
|
|
408
|
+
// Worker reads then unlinks (worker._load_library). Host must not unlink here —
|
|
409
|
+
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
410
|
+
this.reply(msg.rid, {
|
|
411
|
+
path,
|
|
412
|
+
json: isJson,
|
|
413
|
+
index: lib.index,
|
|
414
|
+
files: lib.files,
|
|
415
|
+
chars: lib.chars,
|
|
416
|
+
source_id: lib.sourceId,
|
|
417
|
+
path_prefix: lib.pathPrefix,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
389
420
|
}
|
|
390
421
|
} catch (err) {
|
|
391
422
|
this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -401,6 +432,9 @@ export class PythonSandbox {
|
|
|
401
432
|
index?: number;
|
|
402
433
|
files?: number;
|
|
403
434
|
chars?: number;
|
|
435
|
+
source_id?: string;
|
|
436
|
+
path_prefix?: string;
|
|
437
|
+
already_loaded?: boolean;
|
|
404
438
|
error?: string;
|
|
405
439
|
}): void {
|
|
406
440
|
if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
|