@hicaru/pi-rlm 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +22 -19
  2. package/package.json +2 -1
  3. package/src/bridge/library.ts +93 -15
  4. package/src/bridge/llm-query.ts +60 -36
  5. package/src/bridge/rlm-query.ts +63 -79
  6. package/src/commands/rlm-config.ts +8 -8
  7. package/src/commands/rlm.ts +48 -12
  8. package/src/config/settings.ts +33 -3
  9. package/src/context/library-context.ts +209 -22
  10. package/src/context/repomix-context.ts +7 -58
  11. package/src/core/answer.ts +5 -13
  12. package/src/core/artifacts.ts +4 -3
  13. package/src/core/critique.ts +92 -0
  14. package/src/core/engine.ts +94 -299
  15. package/src/core/gates.ts +33 -4
  16. package/src/core/limits.ts +19 -1
  17. package/src/core/pipeline-handlers.ts +319 -0
  18. package/src/core/pipeline.ts +40 -15
  19. package/src/core/types.ts +26 -30
  20. package/src/index.ts +36 -26
  21. package/src/mode/native-guards.ts +2 -2
  22. package/src/mode/rlm-mode.ts +8 -11
  23. package/src/prompts/phases.ts +18 -39
  24. package/src/prompts/system.ts +167 -64
  25. package/src/prompts/user.ts +1 -5
  26. package/src/sandbox/protocol.ts +5 -17
  27. package/src/sandbox/sandbox-manager.ts +5 -5
  28. package/src/sandbox/sandbox.ts +67 -27
  29. package/src/sandbox/worker.py +534 -48
  30. package/src/state/paths.ts +1 -1
  31. package/src/state/reads.ts +12 -4
  32. package/src/state/resume.ts +26 -25
  33. package/src/state/rows.ts +2 -2
  34. package/src/text/parsing.ts +0 -6
  35. package/src/text/tokens.ts +7 -1
  36. package/src/tool/repl-details.ts +2 -3
  37. package/src/tool/repl-tool.ts +132 -337
  38. package/src/tool/rlm-aggregator.ts +7 -7
  39. package/src/tool/rlm-details.ts +6 -13
  40. package/src/tool/rlm-events.ts +14 -11
  41. package/src/tool/rlm-tool.ts +20 -38
  42. package/src/tool/subcall-render.ts +61 -9
  43. package/src/tool/subcall-store.ts +4 -2
  44. package/src/ui/config-panel.ts +43 -23
  45. package/src/ui/intro.ts +2 -1
  46. package/src/ui/status.ts +8 -5
  47. package/src/ui/theme-adapter.ts +36 -0
  48. package/src/ui/theme.ts +0 -25
  49. package/src/mode/input-router.ts +0 -23
  50. package/src/registry/edit-registry.ts +0 -22
  51. package/src/text/edits.ts +0 -164
  52. package/src/tool/apply-edits-tool.ts +0 -295
@@ -1,23 +0,0 @@
1
- import type { InputSource } from "@earendil-works/pi-coding-agent";
2
-
3
- export interface InputRouteState {
4
- readonly enabled: boolean;
5
- readonly busy: boolean;
6
- }
7
-
8
- export interface InputRouteEvent {
9
- readonly source: InputSource;
10
- readonly text: string;
11
- }
12
-
13
- export type InputRouteDecision = "continue" | "route" | "busy";
14
-
15
- export function decideRlmInputRoute(event: InputRouteEvent, state: InputRouteState): InputRouteDecision {
16
- const eligible = state.enabled && event.source === "interactive" && !event.text.trimStart().startsWith("/");
17
- if (!eligible) return "continue";
18
- return state.busy ? "busy" : "route";
19
- }
20
-
21
- export function shouldRouteRlmInput(event: InputRouteEvent, state: InputRouteState): boolean {
22
- return decideRlmInputRoute(event, state) === "route";
23
- }
@@ -1,22 +0,0 @@
1
- import type { ProposedEdit } from "../sandbox/protocol.ts";
2
-
3
- export class EditRegistry {
4
- private readonly edits = new Map<string, ProposedEdit>();
5
-
6
- registerAll(edits: readonly ProposedEdit[] | undefined): void {
7
- if (edits === undefined) return;
8
- for (const edit of edits) this.edits.set(edit.id, edit);
9
- }
10
-
11
- get(id: string): ProposedEdit | undefined {
12
- return this.edits.get(id);
13
- }
14
-
15
- delete(id: string): boolean {
16
- return this.edits.delete(id);
17
- }
18
-
19
- clear(): void {
20
- this.edits.clear();
21
- }
22
- }
package/src/text/edits.ts DELETED
@@ -1,164 +0,0 @@
1
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { constants } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import type { ProposedEdit } from "../sandbox/protocol.ts";
5
- import { errorMessage, formatError } from "../util/errors.ts";
6
-
7
- export interface AnchorEdit {
8
- readonly oldText: string;
9
- readonly newText: string;
10
- }
11
-
12
- export function countOccurrences(haystack: string, needle: string): number {
13
- if (needle.length === 0) return 0;
14
- let count = 0;
15
- let offset = 0;
16
- for (;;) {
17
- const match = haystack.indexOf(needle, offset);
18
- if (match < 0) return count;
19
- count++;
20
- offset = match + needle.length;
21
- }
22
- }
23
-
24
- /**
25
- * Literal string replace of the first occurrence of `oldText` with `newText`.
26
- * Splice-based so `$&` / `$$` / `$'` in newText are NOT treated as
27
- * special replacement patterns (String.prototype.replace string-form hazard).
28
- */
29
- export function replaceOnceLiteral(content: string, oldText: string, newText: string): string {
30
- const idx = content.indexOf(oldText);
31
- if (idx < 0) return content;
32
- return content.slice(0, idx) + newText + content.slice(idx + oldText.length);
33
- }
34
-
35
- export type PlanEditResult =
36
- | {
37
- readonly ok: true;
38
- /** create = new file; replace = one-shot anchor swap; already-applied = idempotent skip */
39
- readonly kind: "create" | "replace" | "already-applied";
40
- readonly before: string;
41
- readonly after: string;
42
- }
43
- | { readonly ok: false; readonly error: string };
44
-
45
- async function pathExists(abs: string): Promise<boolean> {
46
- try {
47
- await access(abs, constants.F_OK);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
- /**
55
- * Validate and compute an edit without writing.
56
- * Shared by headless apply and the native apply_edits tool (DRY).
57
- *
58
- * Idempotent retry: if a prior unit already landed the change
59
- * (oldText absent + newText already present for replace; create with identical content),
60
- * returns kind "already-applied" so a failed fanout can be retried without wedging.
61
- *
62
- * Create-file: refuses to clobber when the target exists with different content.
63
- */
64
- export async function planEdit(
65
- cwd: string,
66
- path: string,
67
- oldText: string,
68
- newText: string,
69
- ): Promise<PlanEditResult> {
70
- try {
71
- const fullPath = resolve(cwd, path);
72
- if (oldText.length === 0) {
73
- if (await pathExists(fullPath)) {
74
- const existing = await readFile(fullPath, "utf8");
75
- if (existing === newText) {
76
- return { ok: true, kind: "already-applied", before: existing, after: existing };
77
- }
78
- return {
79
- ok: false,
80
- error: formatError(
81
- `${path}: file already exists with different content — refuse to clobber (create requires empty target or identical content)`,
82
- ),
83
- };
84
- }
85
- return { ok: true, kind: "create", before: "", after: newText };
86
- }
87
-
88
- const content = await readFile(fullPath, "utf8");
89
- const occurrences = countOccurrences(content, oldText);
90
- if (occurrences === 0) {
91
- // Idempotent skip: prior apply already removed the anchor and left newText.
92
- // Deletions (newText === "") always "include" empty string — treat them as
93
- // retry-unsafe so a typo'd anchor fails instead of silently skipping.
94
- if (newText.length > 0 && content.includes(newText)) {
95
- return { ok: true, kind: "already-applied", before: content, after: content };
96
- }
97
- return { ok: false, error: formatError(`anchor occurs 0 times in ${path}`) };
98
- }
99
- if (occurrences !== 1) {
100
- return { ok: false, error: formatError(`anchor occurs ${occurrences} times in ${path}`) };
101
- }
102
- const after = replaceOnceLiteral(content, oldText, newText);
103
- return { ok: true, kind: "replace", before: content, after };
104
- } catch (err: unknown) {
105
- return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
106
- }
107
- }
108
-
109
- export type ApplyOneEditResult =
110
- | { readonly ok: true; readonly before: string; readonly after: string; readonly kind: "create" | "replace" | "already-applied" }
111
- | { readonly ok: false; readonly error: string };
112
-
113
- /**
114
- * Apply a single anchor edit to the working tree (direct disk write).
115
- * Used by the implement fanout and any headless apply path.
116
- */
117
- export async function applyOneEdit(
118
- cwd: string,
119
- path: string,
120
- oldText: string,
121
- newText: string,
122
- ): Promise<ApplyOneEditResult> {
123
- const planned = await planEdit(cwd, path, oldText, newText);
124
- if (!planned.ok) return planned;
125
- if (planned.kind === "already-applied") {
126
- return { ok: true, before: planned.before, after: planned.after, kind: "already-applied" };
127
- }
128
- try {
129
- const fullPath = resolve(cwd, path);
130
- if (planned.kind === "create") {
131
- await mkdir(dirname(fullPath), { recursive: true });
132
- }
133
- await writeFile(fullPath, planned.after, "utf8");
134
- return { ok: true, before: planned.before, after: planned.after, kind: planned.kind };
135
- } catch (err: unknown) {
136
- return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
137
- }
138
- }
139
-
140
- export type ApplyProposedEditsResult =
141
- | { readonly ok: true; readonly applied: number }
142
- | { readonly ok: false; readonly error: string; readonly applied: number };
143
-
144
- /**
145
- * Apply a series of proposed edits to the working tree (patch series, not a race).
146
- * Shared by the implement fanout and any headless apply path.
147
- * Safe to re-run: already-applied units succeed without re-writing.
148
- */
149
- export async function applyProposedEdits(
150
- edits: readonly ProposedEdit[],
151
- cwd: string,
152
- ): Promise<ApplyProposedEditsResult> {
153
- let applied = 0;
154
- for (let i = 0; i < edits.length; i++) {
155
- const edit = edits[i];
156
- if (edit === undefined) continue;
157
- const one = await applyOneEdit(cwd, edit.path, edit.oldText, edit.newText);
158
- if (!one.ok) {
159
- return { ok: false, error: one.error, applied };
160
- }
161
- applied++;
162
- }
163
- return { ok: true, applied };
164
- }
@@ -1,295 +0,0 @@
1
- import { createEditToolDefinition, type AgentToolResult, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
- import { Container, Text, type Component } from "@earendil-works/pi-tui";
3
- import { Type } from "typebox";
4
- import { mkdir, writeFile } from "node:fs/promises";
5
- import { dirname, resolve } from "node:path";
6
- import type { EditRegistry } from "../registry/edit-registry.ts";
7
- import { planEdit } from "../text/edits.ts";
8
- import { errorMessage, formatError } from "../util/errors.ts";
9
-
10
- export const ApplyEditsToolParams = Object.freeze(Type.Object({
11
- ids: Type.Array(Type.String({ description: "A staged edit ID returned by stage_edit()." }), {
12
- description: "Staged edit IDs to apply.",
13
- }),
14
- }));
15
-
16
- export interface ApplyEditsFailure {
17
- readonly id: string;
18
- readonly path: string;
19
- readonly error: string;
20
- }
21
-
22
- export interface ApplyEditsPatch {
23
- readonly oldText: string;
24
- readonly newText: string;
25
- }
26
-
27
- export interface ApplyEditsFileStat {
28
- readonly path: string;
29
- readonly status: "applied" | "failed";
30
- readonly added: number;
31
- readonly removed: number;
32
- readonly edits: readonly ApplyEditsPatch[];
33
- }
34
-
35
- export interface ApplyEditsDetails {
36
- readonly status: "done" | "partial" | "error";
37
- readonly appliedCount: number;
38
- readonly failedCount: number;
39
- readonly errors: readonly ApplyEditsFailure[];
40
- readonly fileStats: readonly ApplyEditsFileStat[];
41
- }
42
-
43
- export interface LineStats {
44
- readonly added: number;
45
- readonly removed: number;
46
- }
47
-
48
- export function countLines(text: string): number {
49
- return text.length === 0 ? 0 : text.split("\n").length;
50
- }
51
-
52
- export function diffStats(before: string, after: string): LineStats {
53
- const beforeLineCount = countLines(before);
54
- const afterLineCount = countLines(after);
55
- return Object.freeze({
56
- added: Math.max(0, afterLineCount - beforeLineCount),
57
- removed: Math.max(0, beforeLineCount - afterLineCount),
58
- });
59
- }
60
-
61
- function statusFor(appliedCount: number, failedCount: number): ApplyEditsDetails["status"] {
62
- if (failedCount === 0 && appliedCount > 0) return "done";
63
- return appliedCount > 0 ? "partial" : "error";
64
- }
65
-
66
- function aggregateLineStats(fileStats: readonly ApplyEditsFileStat[]): LineStats {
67
- let added = 0;
68
- let removed = 0;
69
- for (let i = 0; i < fileStats.length; i++) {
70
- const stat = fileStats[i];
71
- added += stat.added;
72
- removed += stat.removed;
73
- }
74
- return Object.freeze({ added, removed });
75
- }
76
-
77
- function appendPatch(existing: readonly ApplyEditsPatch[], patch: ApplyEditsPatch | undefined): readonly ApplyEditsPatch[] {
78
- if (patch === undefined) return existing;
79
- const patches = new Array<ApplyEditsPatch>(existing.length + 1);
80
- for (let i = 0; i < existing.length; i++) {
81
- patches[i] = existing[i];
82
- }
83
- patches[existing.length] = patch;
84
- return Object.freeze(patches);
85
- }
86
-
87
- function mergeFileStat(
88
- fileStatsByPath: Map<string, ApplyEditsFileStat>,
89
- path: string,
90
- status: ApplyEditsFileStat["status"],
91
- stats: LineStats,
92
- patch?: ApplyEditsPatch,
93
- ): void {
94
- const existing = fileStatsByPath.get(path);
95
- const nextStatus: ApplyEditsFileStat["status"] = existing?.status === "failed" || status === "failed" ? "failed" : "applied";
96
- fileStatsByPath.set(path, {
97
- path,
98
- status: nextStatus,
99
- added: (existing?.added ?? 0) + stats.added,
100
- removed: (existing?.removed ?? 0) + stats.removed,
101
- edits: appendPatch(existing?.edits ?? Object.freeze([]), patch),
102
- });
103
- }
104
-
105
- function renderLineStats(stats: LineStats, theme: Theme): string {
106
- return `${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)} lines`;
107
- }
108
-
109
- function formatEditCounts(details: ApplyEditsDetails): string {
110
- return details.failedCount > 0
111
- ? `${details.appliedCount} applied, ${details.failedCount} failed`
112
- : `${details.appliedCount} applied`;
113
- }
114
-
115
- function formatFileCount(fileCount: number): string {
116
- return `${fileCount} file${fileCount === 1 ? "" : "s"}`;
117
- }
118
-
119
- function summarizeFiles(details: ApplyEditsDetails): string {
120
- return `apply_edits: ${formatFileCount(details.fileStats.length)}, ${formatEditCounts(details)}`;
121
- }
122
-
123
- function summarize(details: ApplyEditsDetails): string {
124
- const stats = aggregateLineStats(details.fileStats);
125
- const head = `${summarizeFiles(details)} (+${stats.added} -${stats.removed} lines)`;
126
- if (details.errors.length === 0) return `${head}.`;
127
- const rows = new Array<string>(details.errors.length);
128
- for (let i = 0; i < details.errors.length; i++) {
129
- const error = details.errors[i];
130
- rows[i] = `${error.id}: ${error.error}`;
131
- }
132
- return `${head}.\n${rows.join("\n")}`;
133
- }
134
-
135
- function renderCollapsed(details: ApplyEditsDetails, theme: Theme): Text {
136
- const stats = aggregateLineStats(details.fileStats);
137
- const statusColor = details.status === "error" ? "error" : "success";
138
- return new Text(`${theme.fg(statusColor, summarizeFiles(details))} ${renderLineStats(stats, theme)}`, 0, 0);
139
- }
140
-
141
- function renderFileLine(fileStat: ApplyEditsFileStat, theme: Theme): Text {
142
- const glyph = fileStat.status === "applied" ? theme.fg("success", "✓") : theme.fg("error", "✗");
143
- const stats = renderLineStats(fileStat, theme);
144
- return new Text(`${glyph} ${theme.fg("dim", fileStat.path)} ${stats}`, 0, 0);
145
- }
146
-
147
- function limitPatchText(text: string): string {
148
- const maxChars = 2_000;
149
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
150
- }
151
-
152
- function renderPatchLines(text: string, prefix: string, color: "error" | "success", theme: Theme): string {
153
- const limitedText = limitPatchText(text);
154
- const lines = limitedText.split("\n");
155
- const rendered = new Array<string>(lines.length);
156
- for (let i = 0; i < lines.length; i++) {
157
- rendered[i] = theme.fg(color, `${prefix}${lines[i]}`);
158
- }
159
- return rendered.join("\n");
160
- }
161
-
162
- function renderPatch(patch: ApplyEditsPatch, theme: Theme): Text {
163
- const oldText = patch.oldText.length === 0 ? "(new file)" : patch.oldText;
164
- const text = [
165
- theme.fg("error", "--- old"),
166
- renderPatchLines(oldText, "- ", "error", theme),
167
- theme.fg("success", "+++ new"),
168
- renderPatchLines(patch.newText, "+ ", "success", theme),
169
- ].join("\n");
170
- return new Text(text, 2, 0);
171
- }
172
-
173
- function renderExpanded(details: ApplyEditsDetails, theme: Theme): Container {
174
- const container = new Container();
175
- const header = summarizeFiles(details);
176
- const headerColor = details.status === "error" ? "error" : "success";
177
- container.addChild(new Text(theme.fg(headerColor, header), 0, 0));
178
- for (let i = 0; i < details.fileStats.length; i++) {
179
- const fileStat = details.fileStats[i];
180
- container.addChild(renderFileLine(fileStat, theme));
181
- for (let editIndex = 0; editIndex < fileStat.edits.length; editIndex++) {
182
- container.addChild(renderPatch(fileStat.edits[editIndex], theme));
183
- }
184
- }
185
- if (details.errors.length > 0) {
186
- const rows = new Array<string>(details.errors.length);
187
- for (let i = 0; i < details.errors.length; i++) {
188
- const error = details.errors[i];
189
- rows[i] = `${error.id}: ${error.error}`;
190
- }
191
- container.addChild(new Text(theme.fg("error", rows.join("\n")), 0, 0));
192
- }
193
- return container;
194
- }
195
-
196
- export function createApplyEditsTool(editRegistry: EditRegistry): ToolDefinition<typeof ApplyEditsToolParams, ApplyEditsDetails> {
197
- return {
198
- name: "apply_edits",
199
- label: "Apply Edits",
200
- description: "Apply staged REPL edits by ID without re-typing file paths or edit bodies.",
201
- parameters: ApplyEditsToolParams,
202
-
203
- async execute(toolCallId, params, signal, _onUpdate, ctx): Promise<AgentToolResult<ApplyEditsDetails>> {
204
- const errors = new Array<ApplyEditsFailure>(params.ids.length);
205
- const fileStatsByPath = new Map<string, ApplyEditsFileStat>();
206
- let appliedCount = 0;
207
- let failedCount = 0;
208
- // Native mode: plan/validate with shared planEdit, then write through the host
209
- // edit tool so file-watcher / undo hooks still fire (direct write for create only).
210
- const editTool = createEditToolDefinition(ctx.cwd);
211
-
212
- for (let i = 0; i < params.ids.length; i++) {
213
- const id = params.ids[i];
214
- const edit = editRegistry.get(id);
215
- if (edit === undefined) {
216
- errors[failedCount] = { id, path: id, error: formatError("unknown edit id") };
217
- mergeFileStat(fileStatsByPath, id, "failed", { added: 0, removed: 0 });
218
- failedCount++;
219
- continue;
220
- }
221
-
222
- const planned = await planEdit(ctx.cwd, edit.path, edit.oldText, edit.newText);
223
- if (!planned.ok) {
224
- errors[failedCount] = { id, path: edit.path, error: planned.error };
225
- mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
226
- failedCount++;
227
- continue;
228
- }
229
-
230
- try {
231
- if (planned.kind === "already-applied") {
232
- mergeFileStat(fileStatsByPath, edit.path, "applied", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
233
- editRegistry.delete(id);
234
- appliedCount++;
235
- continue;
236
- }
237
- if (planned.kind === "create") {
238
- const fullPath = resolve(ctx.cwd, edit.path);
239
- await mkdir(dirname(fullPath), { recursive: true });
240
- await writeFile(fullPath, planned.after, "utf8");
241
- } else {
242
- await editTool.execute(
243
- toolCallId,
244
- { path: edit.path, edits: [{ oldText: edit.oldText, newText: edit.newText }] },
245
- signal,
246
- undefined,
247
- ctx,
248
- );
249
- }
250
- mergeFileStat(
251
- fileStatsByPath,
252
- edit.path,
253
- "applied",
254
- diffStats(planned.before, planned.after),
255
- { oldText: edit.oldText, newText: edit.newText },
256
- );
257
- editRegistry.delete(id);
258
- appliedCount++;
259
- } catch (error: unknown) {
260
- errors[failedCount] = { id, path: edit.path, error: formatError(errorMessage(error)) };
261
- mergeFileStat(fileStatsByPath, edit.path, "failed", { added: 0, removed: 0 }, { oldText: edit.oldText, newText: edit.newText });
262
- failedCount++;
263
- }
264
- }
265
-
266
- const fileStats = new Array<ApplyEditsFileStat>(fileStatsByPath.size);
267
- let fileStatIndex = 0;
268
- for (const stat of fileStatsByPath.values()) {
269
- fileStats[fileStatIndex] = stat;
270
- fileStatIndex++;
271
- }
272
-
273
- const details = Object.freeze({
274
- status: statusFor(appliedCount, failedCount),
275
- appliedCount,
276
- failedCount,
277
- errors: Object.freeze(errors.slice(0, failedCount)),
278
- fileStats: Object.freeze(fileStats),
279
- });
280
- return { content: [{ type: "text", text: summarize(details) }], details };
281
- },
282
-
283
- renderCall(args, theme) {
284
- const editCount = args.ids.length;
285
- const summary = `apply_edits: ${editCount} edit${editCount === 1 ? "" : "s"}`;
286
- return new Text(theme.fg("toolTitle", theme.bold(summary)), 0, 0);
287
- },
288
-
289
- renderResult(result, options, theme): Component {
290
- const details = result.details;
291
- if (details === undefined) return new Text("(no apply_edits details)", 0, 0);
292
- return options.expanded ? renderExpanded(details, theme) : renderCollapsed(details, theme);
293
- },
294
- };
295
- }