@hicaru/pi-rlm 0.2.1 → 0.3.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.
- package/README.md +28 -47
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +8 -18
- package/src/config/settings.ts +13 -34
- package/src/context/anydoc.ts +67 -0
- package/src/context/listing.ts +70 -0
- package/src/context/md-cache.ts +112 -0
- package/src/context/merge.ts +97 -0
- package/src/context/namespace.ts +180 -0
- package/src/context/resolve.ts +122 -0
- package/src/context/source-dir.ts +166 -0
- package/src/context/source-doc.ts +71 -0
- package/src/context/source-git.ts +51 -0
- package/src/context/source-text.ts +45 -0
- package/src/context/types.ts +88 -0
- package/src/context/walk.ts +250 -0
- package/src/core/engine.ts +61 -345
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +10 -38
- package/src/index.ts +92 -54
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +28 -58
- package/src/prompts/glossary.ts +290 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +15 -408
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +160 -0
- package/src/sandbox/protocol.ts +20 -75
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +129 -0
- package/src/sandbox/py/worker.py +856 -0
- package/src/sandbox/sandbox-manager.ts +24 -9
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +31 -5
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +60 -170
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +2 -13
- package/src/ui/config-panel.ts +12 -20
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/library.ts +0 -155
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/context/library-context.ts +0 -266
- package/src/context/repomix-context.ts +0 -204
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/sandbox/worker.py +0 -1456
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
package/src/commands/rlm.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
/** `/rlm` — toggle persistent Recursive Language Model mode. */
|
|
2
2
|
|
|
3
|
-
import type { ExtensionAPI
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { RlmController, RunHandle } from "../mode/rlm-mode.ts";
|
|
7
|
-
import { postRlmGuide } from "../ui/intro.ts";
|
|
8
|
-
import { clearRlmStatus, setRlmModeStatus } from "../ui/status.ts";
|
|
9
|
-
import { listRunIds, readContextSidecar, readHeader, resolveRunId } from "../state/index.ts";
|
|
10
|
-
import { DEFAULT_RUN_DIR } from "../config/defaults.ts";
|
|
11
|
-
import { reconstructRlmState } from "../state/resume.ts";
|
|
12
|
-
import type { ReconstructResult } from "../state/resume.ts";
|
|
13
|
-
import type { RunHeader } from "../state/rows.ts";
|
|
14
|
-
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
15
|
-
import { RlmEmitter } from "../tool/rlm-events.ts";
|
|
16
|
-
import { RlmEventAggregator } from "../tool/rlm-aggregator.ts";
|
|
17
|
-
import type { RlmDetails } from "../tool/rlm-details.ts";
|
|
18
|
-
import { cardHeader, cardStatsLine, renderCollapsedSubcallTree } from "../tool/subcall-render.ts";
|
|
19
|
-
import { errorMessage } from "../util/errors.ts";
|
|
20
|
-
|
|
21
|
-
/** Run ids offered for `/rlm-resume <TAB>`. */
|
|
22
|
-
const MAX_COMPLETIONS = 20;
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { RlmController } from "../mode/rlm-mode.ts";
|
|
5
|
+
import { setRlmModeStatus } from "../ui/status.ts";
|
|
23
6
|
|
|
24
7
|
export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController): void {
|
|
25
8
|
pi.registerCommand("rlm", {
|
|
@@ -43,69 +26,6 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
43
26
|
},
|
|
44
27
|
});
|
|
45
28
|
|
|
46
|
-
pi.registerCommand("rlm-help", {
|
|
47
|
-
description: "Show the RLM startup guide and command cheatsheet.",
|
|
48
|
-
handler: async () => {
|
|
49
|
-
postRlmGuide(pi, controller);
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
pi.registerCommand("rlm-resume", {
|
|
54
|
-
description: "Resume an interrupted RLM run (default @latest).",
|
|
55
|
-
getArgumentCompletions: async (prefix) => {
|
|
56
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
57
|
-
const ids = await listRunIds(process.cwd(), dir);
|
|
58
|
-
const candidates = ["@latest", ...ids];
|
|
59
|
-
return candidates
|
|
60
|
-
.filter((value) => value.startsWith(prefix))
|
|
61
|
-
.slice(0, MAX_COMPLETIONS)
|
|
62
|
-
.map((value) => ({ value, label: value }));
|
|
63
|
-
},
|
|
64
|
-
handler: async (args, ctx) => {
|
|
65
|
-
if (controller.isBusy()) {
|
|
66
|
-
ctx.ui.notify("RLM is busy (use /rlm-stop to cancel).", "warning");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const ref = args.trim() || "@latest";
|
|
70
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
71
|
-
const cwd = ctx.cwd ?? process.cwd();
|
|
72
|
-
const runId = await resolveRunId(cwd, dir, ref);
|
|
73
|
-
if (!runId) { ctx.ui.notify(`No resumable RLM run for '${ref}'.`, "error"); return; }
|
|
74
|
-
const header = await readHeader(cwd, dir, runId);
|
|
75
|
-
if (!header) { ctx.ui.notify(`Run ${runId} has no header.`, "error"); return; }
|
|
76
|
-
const systemPrompt = buildRlmSystemPrompt(
|
|
77
|
-
{ contextType: header.context.type, contextChars: header.context.chars, rootPrompt: header.rootPrompt },
|
|
78
|
-
{
|
|
79
|
-
orchestrator: header.meta.orchestrator,
|
|
80
|
-
recursion: 1 < header.meta.maxDepth,
|
|
81
|
-
askUserQuestion: controller.config.askUserQuestion,
|
|
82
|
-
todo: controller.config.todo,
|
|
83
|
-
},
|
|
84
|
-
);
|
|
85
|
-
let recon: ReconstructResult;
|
|
86
|
-
try { recon = await reconstructRlmState(cwd, dir, runId, systemPrompt); }
|
|
87
|
-
catch (e) {
|
|
88
|
-
ctx.ui.notify(`RLM resume failed: corrupt run state — ${errorMessage(e)}`, "error");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (!recon.ok) { ctx.ui.notify(`Cannot resume ${runId}: ${recon.reason}.`, "error"); return; }
|
|
92
|
-
if (recon.terminated) { ctx.ui.notify(`Run ${runId} already finished.`, "info"); return; }
|
|
93
|
-
const context = await readContextSidecar(cwd, dir, runId, header.context.json);
|
|
94
|
-
if (context === undefined) // R-C2: warn instead of silently resuming on empty context
|
|
95
|
-
ctx.ui.notify(`Warning: context sidecar missing for ${runId} — resuming without original context.`, "warning");
|
|
96
|
-
await executeRlmRunWithResume(pi, controller, ctx, recon, header, context ?? "");
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
pi.registerCommand("rlm-runs", {
|
|
101
|
-
description: "List recent RLM runs.",
|
|
102
|
-
handler: async (_args, ctx) => {
|
|
103
|
-
const dir = controller.config.runLog?.dir ?? DEFAULT_RUN_DIR;
|
|
104
|
-
const ids = (await listRunIds(ctx.cwd ?? process.cwd(), dir)).slice(0, 20);
|
|
105
|
-
ctx.ui.notify(ids.length ? ids.join("\n") : "No RLM runs recorded.", "info");
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
|
|
109
29
|
pi.registerShortcut?.("ctrl+shift+r", {
|
|
110
30
|
description: "Toggle RLM mode (off also stops a running query)",
|
|
111
31
|
handler: async (ctx) => {
|
|
@@ -115,72 +35,3 @@ export function registerRlmCommand(pi: ExtensionAPI, controller: RlmController):
|
|
|
115
35
|
},
|
|
116
36
|
});
|
|
117
37
|
}
|
|
118
|
-
|
|
119
|
-
/** Above-editor progress card for a `/rlm-resume` run: header + the live sub-call tree. */
|
|
120
|
-
function renderResumeWidget(details: RlmDetails | undefined, theme: Theme): Component {
|
|
121
|
-
const container = new Container();
|
|
122
|
-
if (!details) return container;
|
|
123
|
-
const turns = details.turns;
|
|
124
|
-
const stats = cardStatsLine(
|
|
125
|
-
details.totals,
|
|
126
|
-
theme,
|
|
127
|
-
turns.max > 0 ? `turn ${turns.current}/${turns.max}` : undefined,
|
|
128
|
-
);
|
|
129
|
-
container.addChild(new Text(cardHeader("RLM resume", details.status, stats, theme), 0, 0));
|
|
130
|
-
if (details.subcalls.length > 0) {
|
|
131
|
-
container.addChild(new Text(renderCollapsedSubcallTree(details.subcalls, theme), 0, 0));
|
|
132
|
-
}
|
|
133
|
-
return container;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function executeRlmRunWithResume(
|
|
137
|
-
pi: ExtensionAPI,
|
|
138
|
-
controller: RlmController,
|
|
139
|
-
ctx: ExtensionContext,
|
|
140
|
-
recon: ReconstructResult & { ok: true },
|
|
141
|
-
header: RunHeader,
|
|
142
|
-
context: unknown,
|
|
143
|
-
): Promise<void> {
|
|
144
|
-
let handle: RunHandle | undefined;
|
|
145
|
-
let emitter: RlmEmitter | undefined;
|
|
146
|
-
let aggregator: RlmEventAggregator | undefined;
|
|
147
|
-
try {
|
|
148
|
-
emitter = new RlmEmitter();
|
|
149
|
-
// Component factory rather than the string[] form: the array form is hard-capped at 10
|
|
150
|
-
// lines by pi, which the live sub-call tree exceeds as soon as a run fans out. The factory
|
|
151
|
-
// also receives the live theme, so the widget follows /theme switches.
|
|
152
|
-
let latest: RlmDetails | undefined;
|
|
153
|
-
aggregator = new RlmEventAggregator(emitter, (partial) => {
|
|
154
|
-
latest = partial.details;
|
|
155
|
-
if (!latest) return;
|
|
156
|
-
ctx.ui.setWidget?.("rlm-status", (_tui, theme) => renderResumeWidget(latest, theme), {
|
|
157
|
-
placement: "aboveEditor",
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
emitter.emitRootPrompt(header.rootPrompt);
|
|
161
|
-
const interactive = createPiInteractiveDeps(ctx);
|
|
162
|
-
if (controller.config.todo) {
|
|
163
|
-
for (const row of recon.todoRows) await interactive.onTodo?.(row.action, row.params);
|
|
164
|
-
}
|
|
165
|
-
handle = controller.start(ctx, { kind: "resume", resume: recon, context }, emitter, {
|
|
166
|
-
onAskUserQuestion: controller.config.askUserQuestion ? interactive.onAskUserQuestion : undefined,
|
|
167
|
-
onTodo: controller.config.todo ? interactive.onTodo : undefined,
|
|
168
|
-
});
|
|
169
|
-
} catch (e) {
|
|
170
|
-
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
pi.sendMessage({ customType: "rlm-question", content: `[resume] ${header.rootPrompt}`, display: true });
|
|
174
|
-
const { done } = handle;
|
|
175
|
-
try {
|
|
176
|
-
const result = await done;
|
|
177
|
-
pi.sendMessage({ customType: "rlm-answer", content: result.answer, display: true });
|
|
178
|
-
} catch (e) {
|
|
179
|
-
ctx.ui.notify(`RLM resume failed: ${errorMessage(e)}`, "error");
|
|
180
|
-
} finally {
|
|
181
|
-
clearRlmStatus(ctx.ui);
|
|
182
|
-
ctx.ui.setWidget?.("rlm-status", undefined);
|
|
183
|
-
aggregator?.dispose();
|
|
184
|
-
emitter?.shutdown();
|
|
185
|
-
}
|
|
186
|
-
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { RlmConfig } from "../core/types.ts";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_RUN_DIR = join(tmpdir(), "rlm-runs");
|
|
6
2
|
|
|
7
3
|
/** Frozen default sub-LLM system prompt — avoids re-allocation on every llm_query call. */
|
|
8
4
|
const DEFAULT_SUB_SYSTEM_PROMPT =
|
|
@@ -17,28 +13,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
17
13
|
execTimeoutS: 120,
|
|
18
14
|
requestTimeoutMs: 10 * 60_000,
|
|
19
15
|
// Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
|
|
20
|
-
// the only thing bounding fan-out.
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
// the only thing bounding leaf fan-out.
|
|
17
|
+
maxConcurrentSubcalls: 16,
|
|
18
|
+
// Children are bounded separately and lower: each is a Python subprocess holding its own copy
|
|
19
|
+
// of the context it inherited, where a leaf is one HTTP request. Worst case is
|
|
20
|
+
// (maxDepth - 1) × this many concurrent child engines.
|
|
21
|
+
maxConcurrentChildren: 6,
|
|
23
22
|
maxPromptChars: 400_000,
|
|
24
23
|
maxErrors: 5,
|
|
25
24
|
orchestrator: true,
|
|
26
|
-
pipeline: false,
|
|
27
|
-
maxBackwardJumps: 2,
|
|
28
25
|
compaction: true,
|
|
29
26
|
compactionThresholdPct: 0.65,
|
|
30
27
|
python: "python3",
|
|
31
28
|
sandboxInitTimeoutMs: 30_000,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
libraryLoader: true,
|
|
29
|
+
contextLoader: true,
|
|
30
|
+
autoSeedCwd: true,
|
|
35
31
|
rootSampling: Object.freeze({ maxTokens: 16_384 }),
|
|
36
32
|
subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
|
|
37
33
|
subSampling: Object.freeze({ maxTokens: 8192 }),
|
|
38
|
-
runLog: Object.freeze({
|
|
39
|
-
enabled: true,
|
|
40
|
-
dir: DEFAULT_RUN_DIR,
|
|
41
|
-
snapshot: true,
|
|
42
|
-
maxRuns: 50,
|
|
43
|
-
}),
|
|
44
34
|
});
|
package/src/config/settings.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
/** Persist RLM settings (tunable config +
|
|
1
|
+
/** Persist RLM settings (tunable config + pinned sub-LLM model id). */
|
|
2
2
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
|
-
import type { RlmConfig
|
|
7
|
+
import type { RlmConfig } from "../core/types.ts";
|
|
8
8
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
9
9
|
|
|
10
10
|
export interface PersistedSettings {
|
|
11
11
|
readonly config: Partial<RlmConfig>;
|
|
12
|
-
|
|
12
|
+
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)". */
|
|
13
|
+
readonly llm?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
|
|
@@ -43,21 +44,6 @@ function validateThinkingLevel(v: unknown): ThinkingLevel | undefined {
|
|
|
43
44
|
return typeof v === "string" && Object.hasOwn(THINKING_LEVELS, v) ? (v as ThinkingLevel) : undefined;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function validateRunLog(raw: unknown): Partial<RunLogConfig> | undefined {
|
|
47
|
-
if (typeof raw !== "object" || raw === null) return undefined;
|
|
48
|
-
const r = raw as Record<string, unknown>;
|
|
49
|
-
const out: { enabled?: boolean; dir?: string; snapshot?: boolean; maxRuns?: number } = {};
|
|
50
|
-
const enabled = validateBoolean(r.enabled);
|
|
51
|
-
if (enabled !== undefined) out.enabled = enabled;
|
|
52
|
-
const dir = validateString(r.dir);
|
|
53
|
-
if (dir !== undefined) out.dir = dir;
|
|
54
|
-
const snapshot = validateBoolean(r.snapshot);
|
|
55
|
-
if (snapshot !== undefined) out.snapshot = snapshot;
|
|
56
|
-
const maxRuns = validateNumber(r.maxRuns, 1);
|
|
57
|
-
if (maxRuns !== undefined) out.maxRuns = maxRuns;
|
|
58
|
-
return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
47
|
function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
62
48
|
if (typeof raw !== "object" || raw === null) return {};
|
|
63
49
|
const r = raw as Record<string, unknown>;
|
|
@@ -74,10 +60,10 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
74
60
|
if (requestTimeoutMs !== undefined) out.requestTimeoutMs = requestTimeoutMs;
|
|
75
61
|
const maxConcurrentSubcalls = validateNumber(r.maxConcurrentSubcalls, 1);
|
|
76
62
|
if (maxConcurrentSubcalls !== undefined) out.maxConcurrentSubcalls = maxConcurrentSubcalls;
|
|
63
|
+
const maxConcurrentChildren = validateNumber(r.maxConcurrentChildren, 1);
|
|
64
|
+
if (maxConcurrentChildren !== undefined) out.maxConcurrentChildren = maxConcurrentChildren;
|
|
77
65
|
const maxPromptChars = validateNumber(r.maxPromptChars, 1000);
|
|
78
66
|
if (maxPromptChars !== undefined) out.maxPromptChars = maxPromptChars;
|
|
79
|
-
const maxBudgetUsd = validateNumber(r.maxBudgetUsd, 0.01);
|
|
80
|
-
if (maxBudgetUsd !== undefined) out.maxBudgetUsd = maxBudgetUsd;
|
|
81
67
|
const maxTimeoutMs = validateNumber(r.maxTimeoutMs, 1000);
|
|
82
68
|
if (maxTimeoutMs !== undefined) out.maxTimeoutMs = maxTimeoutMs;
|
|
83
69
|
const maxTokens = validateNumber(r.maxTokens, 1);
|
|
@@ -86,10 +72,6 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
86
72
|
if (maxErrors !== undefined) out.maxErrors = maxErrors;
|
|
87
73
|
const orchestrator = validateBoolean(r.orchestrator);
|
|
88
74
|
if (orchestrator !== undefined) out.orchestrator = orchestrator;
|
|
89
|
-
const pipeline = validateBoolean(r.pipeline);
|
|
90
|
-
if (pipeline !== undefined) out.pipeline = pipeline;
|
|
91
|
-
const maxBackwardJumps = validateNumber(r.maxBackwardJumps, 0);
|
|
92
|
-
if (maxBackwardJumps !== undefined) out.maxBackwardJumps = maxBackwardJumps;
|
|
93
75
|
const compaction = validateBoolean(r.compaction);
|
|
94
76
|
if (compaction !== undefined) out.compaction = compaction;
|
|
95
77
|
const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
|
|
@@ -100,16 +82,13 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
100
82
|
if (smartReasoning !== undefined) out.smartReasoning = smartReasoning;
|
|
101
83
|
const subSystemPrompt = validateString(r.subSystemPrompt);
|
|
102
84
|
if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
|
|
103
|
-
const runLog = validateRunLog(r.runLog);
|
|
104
|
-
if (runLog) out.runLog = runLog;
|
|
105
85
|
const sandboxInitTimeoutMs = validateNumber(r.sandboxInitTimeoutMs, 100);
|
|
106
86
|
if (sandboxInitTimeoutMs !== undefined) out.sandboxInitTimeoutMs = sandboxInitTimeoutMs;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
|
|
87
|
+
// `libraryLoader` is the pre-rename key — still read so an existing rlm.json survives the upgrade.
|
|
88
|
+
const contextLoader = validateBoolean(r.contextLoader) ?? validateBoolean(r.libraryLoader);
|
|
89
|
+
if (contextLoader !== undefined) out.contextLoader = contextLoader;
|
|
90
|
+
const autoSeedCwd = validateBoolean(r.autoSeedCwd);
|
|
91
|
+
if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
|
|
113
92
|
if (typeof r.subSampling === "object" && r.subSampling !== null) {
|
|
114
93
|
const ss = r.subSampling as Record<string, unknown>;
|
|
115
94
|
const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
|
|
@@ -142,7 +121,8 @@ export async function loadSettings(): Promise<PersistedSettings> {
|
|
|
142
121
|
const r = raw as Record<string, unknown>;
|
|
143
122
|
return {
|
|
144
123
|
config: validateConfig(r.config),
|
|
145
|
-
worker
|
|
124
|
+
// `worker` is the pre-rename key — still read so an existing pin survives the upgrade.
|
|
125
|
+
llm: validateString(r.llm) ?? validateString(r.worker),
|
|
146
126
|
};
|
|
147
127
|
} catch {
|
|
148
128
|
return { config: {} };
|
|
@@ -167,7 +147,6 @@ export function mergeConfig(partial: Partial<RlmConfig>): RlmConfig {
|
|
|
167
147
|
...partial,
|
|
168
148
|
subSampling: { ...DEFAULT_CONFIG.subSampling, ...partial.subSampling },
|
|
169
149
|
rootSampling: Object.freeze({ ...DEFAULT_CONFIG.rootSampling, ...partial.rootSampling }),
|
|
170
|
-
...(partial.runLog ? { runLog: Object.freeze({ ...DEFAULT_CONFIG.runLog, ...partial.runLog }) } : {}),
|
|
171
150
|
};
|
|
172
151
|
}
|
|
173
152
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy NAPI handle for @firecrawl/anydoc.
|
|
3
|
+
*
|
|
4
|
+
* anydoc is a native addon with per-platform optionalDependencies (darwin x64/arm64,
|
|
5
|
+
* linux x64/arm64 gnu+musl, win32 x64 — NO win32-arm64). A static `import` on an uncovered
|
|
6
|
+
* platform is a plugin-load crash. So: import() once, memoised, catch → null. Absence is a
|
|
7
|
+
* value; documents degrade to skipped: "no-converter".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Minimal surface we consume — never re-export the full anydoc package. */
|
|
11
|
+
export interface AnydocHandle {
|
|
12
|
+
/** Detect document format from a path's extension; null for plain text / unknown. */
|
|
13
|
+
readonly formatFromPath: (path: string) => string | null;
|
|
14
|
+
/** Convert a document file to Markdown. Rejects with Error.code = ConvertErrorCode. */
|
|
15
|
+
readonly toMarkdown: (path: string) => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Document container extensions anydoc knows about, including Office macro/variants.
|
|
20
|
+
* Used when the native addon is absent so we still route these to "no-converter"
|
|
21
|
+
* instead of reading them as broken binary text.
|
|
22
|
+
*/
|
|
23
|
+
const DOCUMENT_EXTENSIONS: ReadonlySet<string> = Object.freeze(new Set([
|
|
24
|
+
"doc", "docx", "docm", "odt", "rtf", "epub", "pdf",
|
|
25
|
+
"ppt", "pptx", "pptm", "ppsx", "odp",
|
|
26
|
+
"xls", "xlsx", "xlsm", "xlsb", "ods", "csv",
|
|
27
|
+
]));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect a document container from a path when the anydoc handle is unavailable.
|
|
31
|
+
* Returns a non-null token for known extensions so the router can skip as "no-converter".
|
|
32
|
+
*/
|
|
33
|
+
export function documentExtFromPath(path: string): string | null {
|
|
34
|
+
const base = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
|
|
35
|
+
const dot = base.lastIndexOf(".");
|
|
36
|
+
if (dot < 0) return null;
|
|
37
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
38
|
+
return DOCUMENT_EXTENSIONS.has(ext) ? ext : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cached: Promise<AnydocHandle | null> | undefined;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the anydoc handle once per process. Returns null when the native addon is
|
|
45
|
+
* missing or fails to load — callers treat documents as skipped: "no-converter".
|
|
46
|
+
*/
|
|
47
|
+
export function getAnydoc(): Promise<AnydocHandle | null> {
|
|
48
|
+
cached ??= import("@firecrawl/anydoc")
|
|
49
|
+
.then((mod): AnydocHandle => Object.freeze({
|
|
50
|
+
formatFromPath: (p: string) => mod.formatFromPath(p),
|
|
51
|
+
toMarkdown: (p: string) => mod.toMarkdown(p),
|
|
52
|
+
}))
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Test seam: force the next getAnydoc() call to return a fixed handle (or null).
|
|
59
|
+
* Pass `undefined` to restore the real lazy loader.
|
|
60
|
+
*/
|
|
61
|
+
export function setAnydocForTest(handle: AnydocHandle | null | undefined): void {
|
|
62
|
+
if (handle === undefined) {
|
|
63
|
+
cached = undefined;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
cached = Promise.resolve(handle);
|
|
67
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact human-readable listing of currently loaded context files for the parent LLM.
|
|
3
|
+
* Shows paths and token estimates — NOT full file contents.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ContextFile } from "./types.ts";
|
|
7
|
+
import { isContextFile } from "./namespace.ts";
|
|
8
|
+
|
|
9
|
+
/** Maximum files shown in the compact LLM listing before truncation. */
|
|
10
|
+
const MAX_LLM_LISTING_FILES = 200;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a context payload (ContextFile[] or empty) for injection into the parent agent's
|
|
14
|
+
* message stream. Empty state points at autoSeedCwd / external add_context — never
|
|
15
|
+
* `add_context(".")`, which would re-pack the already-seeded cwd under a ctx/ prefix.
|
|
16
|
+
*/
|
|
17
|
+
export function formatContextListing(context: unknown): string {
|
|
18
|
+
const files = toFileList(context);
|
|
19
|
+
if (files.length === 0) {
|
|
20
|
+
return [
|
|
21
|
+
"RLM `context` is EMPTY — no files loaded yet.",
|
|
22
|
+
"The working directory seeds automatically on the first `repl()` call (autoSeedCwd).",
|
|
23
|
+
'Use `add_context("/path/to/dir")` / `add_context("docs.pdf")` / `add_context("https://…")` for external sources.',
|
|
24
|
+
"Documents (PDF, DOCX, XLSX, PPTX, CSV, …) are converted to Markdown on the way in.",
|
|
25
|
+
"",
|
|
26
|
+
"Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let totalTokens = 0;
|
|
31
|
+
let totalChars = 0;
|
|
32
|
+
for (let i = 0; i < files.length; i++) {
|
|
33
|
+
totalTokens += files[i].tokens;
|
|
34
|
+
totalChars += files[i].content.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shown = files.slice(0, MAX_LLM_LISTING_FILES);
|
|
38
|
+
const truncated = files.length > MAX_LLM_LISTING_FILES
|
|
39
|
+
? `... and ${files.length - MAX_LLM_LISTING_FILES} more files (truncated)`
|
|
40
|
+
: "";
|
|
41
|
+
|
|
42
|
+
const listingParts = new Array<string>(shown.length);
|
|
43
|
+
for (let i = 0; i < shown.length; i++) {
|
|
44
|
+
const f = shown[i];
|
|
45
|
+
listingParts[i] =
|
|
46
|
+
`${f.path} (${f.tokens.toLocaleString()} tok, ${f.content.length.toLocaleString()} chars)`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
`Context: ${files.length.toLocaleString()} files, ${totalTokens.toLocaleString()} estimated tokens, ${totalChars.toLocaleString()} total characters.`,
|
|
51
|
+
"",
|
|
52
|
+
listingParts.join("\n"),
|
|
53
|
+
truncated,
|
|
54
|
+
"",
|
|
55
|
+
"File contents are loaded in the REPL `context` variable — file-reading tools are disabled.",
|
|
56
|
+
"Use repl({code}) and delegate semantic reading to llm_query / llm_query_batched / llm_query_chunked.",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toFileList(context: unknown): readonly ContextFile[] {
|
|
61
|
+
if (!Array.isArray(context)) return Object.freeze([]);
|
|
62
|
+
const out = new Array<ContextFile>(context.length);
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (let i = 0; i < context.length; i++) {
|
|
65
|
+
const entry: unknown = context[i];
|
|
66
|
+
if (isContextFile(entry)) out[n++] = entry;
|
|
67
|
+
}
|
|
68
|
+
out.length = n;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk Markdown cache for anydoc conversions, keyed by (size, mtimeMs).
|
|
3
|
+
*
|
|
4
|
+
* Cache dir: $XDG_CACHE_HOME/pi-rlm/anydoc (else ~/.cache/…).
|
|
5
|
+
* Per source: <name>-<sha8(absPath)>.md + .json stamp {source, size, mtimeMs}.
|
|
6
|
+
*
|
|
7
|
+
* The stamp is ALWAYS the pre-conversion (size, mtimeMs). Capturing after toMarkdown races
|
|
8
|
+
* with mid-conversion edits and would stamp the new mtime against the old body — every future
|
|
9
|
+
* read would be a permanent stale hit. writeMdCache also refuses to write when a post-conversion
|
|
10
|
+
* stat disagrees with the captured stamp.
|
|
11
|
+
*
|
|
12
|
+
* Write ordering is load-bearing: Markdown body first, stamp second. A crash between them
|
|
13
|
+
* leaves stale-body-no-stamp, which reads as a miss. All cache failures are swallowed —
|
|
14
|
+
* the cache is an optimisation, never a dependency.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
export interface FileStamp {
|
|
23
|
+
readonly size: number;
|
|
24
|
+
readonly mtimeMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CacheStamp {
|
|
28
|
+
readonly source: string;
|
|
29
|
+
readonly size: number;
|
|
30
|
+
readonly mtimeMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheRoot(): string {
|
|
34
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
35
|
+
if (typeof xdg === "string" && xdg.trim() !== "") return join(xdg, "pi-rlm", "anydoc");
|
|
36
|
+
return join(homedir(), ".cache", "pi-rlm", "anydoc");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function entryBase(absPath: string): string {
|
|
40
|
+
const name = basename(absPath).replace(/[^\w.-]+/g, "-").slice(0, 80) || "doc";
|
|
41
|
+
const sha8 = createHash("sha256").update(absPath).digest("hex").slice(0, 8);
|
|
42
|
+
return `${name}-${sha8}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isStamp(value: unknown): value is CacheStamp {
|
|
46
|
+
if (value === null || typeof value !== "object") return false;
|
|
47
|
+
const r = value as Record<string, unknown>;
|
|
48
|
+
return typeof r.source === "string"
|
|
49
|
+
&& typeof r.size === "number"
|
|
50
|
+
&& typeof r.mtimeMs === "number";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Capture (size, mtimeMs) for a source file. Returns undefined on I/O failure. */
|
|
54
|
+
export async function captureStamp(absPath: string): Promise<FileStamp | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
const s = await stat(absPath);
|
|
57
|
+
return Object.freeze({ size: s.size, mtimeMs: s.mtimeMs });
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read a cached Markdown body if the stamp still matches a fresh stat of `absPath`.
|
|
65
|
+
* Returns undefined on any miss or I/O failure.
|
|
66
|
+
*/
|
|
67
|
+
export async function readMdCache(absPath: string): Promise<string | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
const base = join(cacheRoot(), entryBase(absPath));
|
|
70
|
+
const stampRaw = await readFile(`${base}.json`, "utf-8");
|
|
71
|
+
const stamp: unknown = JSON.parse(stampRaw);
|
|
72
|
+
if (!isStamp(stamp)) return undefined;
|
|
73
|
+
const s = await stat(absPath);
|
|
74
|
+
if (s.size !== stamp.size || s.mtimeMs !== stamp.mtimeMs) return undefined;
|
|
75
|
+
return await readFile(`${base}.md`, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write Markdown body then the PRE-CAPTURED stamp. Failures are swallowed.
|
|
83
|
+
*
|
|
84
|
+
* If a post-conversion stat disagrees with `stamp`, the write is skipped (file changed
|
|
85
|
+
* mid-conversion — stamping the new mtime against the old body would freeze stale content).
|
|
86
|
+
* Body-before-stamp is load-bearing (see module docstring).
|
|
87
|
+
*/
|
|
88
|
+
export async function writeMdCache(
|
|
89
|
+
absPath: string,
|
|
90
|
+
markdown: string,
|
|
91
|
+
stamp: FileStamp,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
// Refuse to cache if the source moved under us during conversion.
|
|
95
|
+
const s = await stat(absPath);
|
|
96
|
+
if (s.size !== stamp.size || s.mtimeMs !== stamp.mtimeMs) return;
|
|
97
|
+
|
|
98
|
+
const root = cacheRoot();
|
|
99
|
+
await mkdir(root, { recursive: true });
|
|
100
|
+
const base = join(root, entryBase(absPath));
|
|
101
|
+
// Body first, stamp second — crash between leaves a miss, never a wrong hit.
|
|
102
|
+
await writeFile(`${base}.md`, markdown, "utf-8");
|
|
103
|
+
const out: CacheStamp = Object.freeze({
|
|
104
|
+
source: absPath,
|
|
105
|
+
size: stamp.size,
|
|
106
|
+
mtimeMs: stamp.mtimeMs,
|
|
107
|
+
});
|
|
108
|
+
await writeFile(`${base}.json`, `${JSON.stringify(out)}\n`, "utf-8");
|
|
109
|
+
} catch {
|
|
110
|
+
// optimisation only
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge / filter helpers for the live context list.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from the old library-context module with `lib/` → `ctx/` and the empty-prefix
|
|
5
|
+
* short-circuit for cwd sources.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { estimateTokens } from "../text/tokens.ts";
|
|
9
|
+
import {
|
|
10
|
+
contextEntryPath,
|
|
11
|
+
isContextFile,
|
|
12
|
+
namespaceContextFiles,
|
|
13
|
+
payloadPrefix,
|
|
14
|
+
} from "./namespace.ts";
|
|
15
|
+
import type { ContextFile } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export interface FilteredContext {
|
|
18
|
+
readonly files: readonly ContextFile[];
|
|
19
|
+
/** Prefixes that selected zero files — the caller decides whether that is fatal. */
|
|
20
|
+
readonly unmatched: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Narrow a context payload to entries under any of `prefixes` (plain prefix match, no globs).
|
|
25
|
+
*
|
|
26
|
+
* Backs `rlm_query(prompt, paths=[…])`. Prefix-only is deliberate: the sandbox's own filters use
|
|
27
|
+
* Python `fnmatch`, which has no host-side equivalent here, and a subtree prefix is what callers
|
|
28
|
+
* actually want — the child can still `search()` inside the slice.
|
|
29
|
+
*/
|
|
30
|
+
export function filterContextByPaths(context: unknown, prefixes: readonly string[]): FilteredContext {
|
|
31
|
+
if (!Array.isArray(context) || prefixes.length === 0) {
|
|
32
|
+
return Object.freeze({ files: Object.freeze([]), unmatched: Object.freeze(Array.from(prefixes)) });
|
|
33
|
+
}
|
|
34
|
+
const hit = new Array<boolean>(prefixes.length).fill(false);
|
|
35
|
+
const out = new Array<ContextFile>(context.length); // pre-allocated, trimmed once below
|
|
36
|
+
let n = 0;
|
|
37
|
+
for (let i = 0; i < context.length; i++) {
|
|
38
|
+
const entry: unknown = context[i];
|
|
39
|
+
if (!isContextFile(entry)) continue;
|
|
40
|
+
for (let p = 0; p < prefixes.length; p++) {
|
|
41
|
+
if (!entry.path.startsWith(prefixes[p])) continue;
|
|
42
|
+
hit[p] = true;
|
|
43
|
+
out[n++] = entry;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
out.length = n;
|
|
48
|
+
const unmatched = new Array<string>(prefixes.length); // pre-allocated, no .push()
|
|
49
|
+
let u = 0;
|
|
50
|
+
for (let p = 0; p < prefixes.length; p++) {
|
|
51
|
+
if (!hit[p]) unmatched[u++] = prefixes[p];
|
|
52
|
+
}
|
|
53
|
+
unmatched.length = u;
|
|
54
|
+
return Object.freeze({ files: Object.freeze(out), unmatched: Object.freeze(unmatched) });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append a source payload into an existing list context.
|
|
59
|
+
* Skips a payload whose `ctx/<id>/` prefix is already present, so a repeat load is a no-op.
|
|
60
|
+
*
|
|
61
|
+
* Cwd-seeded (un-prefixed) files never carry a `ctx/<id>/` prefix, so `payloadPrefix` returns
|
|
62
|
+
* undefined for them and this path-scan does not fire. Idempotency for the cwd seed is owned
|
|
63
|
+
* by the host `loaded` set (sentinel `""`) and the seeded-cwd absolute-path short-circuit in
|
|
64
|
+
* bridge/add-context.ts — not by this merge.
|
|
65
|
+
*/
|
|
66
|
+
export function mergeIntoContext(base: unknown, sourcePayload: unknown): unknown {
|
|
67
|
+
if (!Array.isArray(base)) return base;
|
|
68
|
+
if (Array.isArray(sourcePayload)) {
|
|
69
|
+
if (sourcePayload.length === 0) return base;
|
|
70
|
+
const prefix = payloadPrefix(sourcePayload);
|
|
71
|
+
// Only `ctx/<id>/` prefixes participate in path-scan dedup (payloadPrefix never returns "").
|
|
72
|
+
if (prefix !== undefined) {
|
|
73
|
+
for (let i = 0; i < base.length; i++) {
|
|
74
|
+
const path = contextEntryPath(base[i]);
|
|
75
|
+
if (path !== undefined && path.startsWith(prefix)) return base; // already present
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const merged = new Array<unknown>(base.length + sourcePayload.length);
|
|
79
|
+
for (let i = 0; i < base.length; i++) merged[i] = base[i];
|
|
80
|
+
for (let i = 0; i < sourcePayload.length; i++) merged[base.length + i] = sourcePayload[i];
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
83
|
+
if (typeof sourcePayload === "string") {
|
|
84
|
+
// Raw string payload: wrap once under the unknown prefix.
|
|
85
|
+
return mergeIntoContext(base, namespaceContextFiles(sourcePayload, "unknown"));
|
|
86
|
+
}
|
|
87
|
+
return base;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build a frozen ContextFile from path + content (shared by every source-* producer). */
|
|
91
|
+
export function makeContextFile(path: string, content: string): ContextFile {
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
path,
|
|
94
|
+
content,
|
|
95
|
+
tokens: Math.max(1, estimateTokens(content.length)),
|
|
96
|
+
});
|
|
97
|
+
}
|