@hicaru/pi-rlm 0.1.7 → 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 +41 -4
- package/package.json +2 -1
- package/src/bridge/library.ts +155 -0
- package/src/bridge/llm-query.ts +1 -0
- package/src/bridge/rlm-query.ts +56 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +266 -0
- package/src/context/repomix-context.ts +2 -48
- package/src/core/answer.ts +1 -10
- package/src/core/artifacts.ts +88 -0
- package/src/core/critique.ts +92 -0
- package/src/core/engine.ts +446 -53
- package/src/core/gates.ts +301 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +196 -28
- package/src/core/types.ts +5 -3
- package/src/index.ts +3 -6
- package/src/mode/native-guards.ts +2 -2
- package/src/prompts/phases.ts +104 -0
- package/src/prompts/system.ts +59 -16
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +29 -11
- package/src/sandbox/sandbox.ts +77 -2
- package/src/sandbox/worker.py +215 -46
- package/src/state/index.ts +2 -1
- package/src/state/paths.ts +4 -2
- package/src/state/reads.ts +31 -2
- package/src/state/resume.ts +31 -6
- package/src/state/rows.ts +8 -2
- package/src/state/writes.ts +5 -3
- package/src/text/tokens.ts +7 -1
- package/src/tool/repl-details.ts +2 -3
- package/src/tool/repl-tool.ts +52 -57
- 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 -8
- package/src/tool/subcall-store.ts +2 -0
- package/src/ui/config-panel.ts +8 -1
- package/src/registry/edit-registry.ts +0 -22
- package/src/text/edits.ts +0 -16
- package/src/tool/apply-edits-tool.ts +0 -288
package/README.md
CHANGED
|
@@ -100,8 +100,8 @@ sub-LLM calls, hence the name.
|
|
|
100
100
|
|
|
101
101
|
While a run is active, a **live tree** shows the root orchestrator and every sub-LLM /
|
|
102
102
|
recursive child with status, model, cost, tokens, and duration. The final answer is posted
|
|
103
|
-
to the chat as markdown
|
|
104
|
-
|
|
103
|
+
to the chat as markdown. File changes use Pi's native `edit` / `write` tools (with their
|
|
104
|
+
built-in diff preview).
|
|
105
105
|
|
|
106
106
|
## Sandbox API
|
|
107
107
|
|
|
@@ -117,11 +117,44 @@ These functions are injected into the model's Python namespace inside the REPL:
|
|
|
117
117
|
| `rlm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent recursive child RLMs |
|
|
118
118
|
| `todo` | `(action, **kwargs) -> str` | Task list: `create`/`update`/`list`/`get`/`delete`/`clear` |
|
|
119
119
|
| `ask_user_question` | `(questions) -> list[dict]` | Ask the user structured questions (depth 0 only) |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
120
|
+
| `load_library` | `(source) -> dict \| str` | Append an external dir, file, or git URL into `context` under `lib/<id>/` |
|
|
121
|
+
| `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only). Returns preflight gate critique. |
|
|
122
|
+
| `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
|
|
122
123
|
| `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
|
|
123
124
|
| `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
|
|
124
125
|
|
|
126
|
+
### Loading external libraries
|
|
127
|
+
|
|
128
|
+
When the task needs an **external library, another source tree, or standalone docs** that are
|
|
129
|
+
not in the packed repo `context`, the model calls `load_library(source)` mid-run:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
info = load_library("../some-lib") # local directory → packed + appended
|
|
133
|
+
info = load_library("docs/api.md") # single file → one entry in context
|
|
134
|
+
info = load_library("https://github.com/x/y.git") # shallow clone, then pack + append
|
|
135
|
+
# Files land in the SAME `context` list under lib/<source_id>/…
|
|
136
|
+
# info == {"source_id", "path_prefix", "files", "chars", "context_len", "already_loaded", …}
|
|
137
|
+
lib = [f for f in context if f["path"].startswith(info["path_prefix"])]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
There is no `context_1` / `context_2` — only `context`. Paths are namespaced so multiple
|
|
141
|
+
libraries do not collide. Toggle via `/rlm-config` → **Library loader** (`libraryLoader`,
|
|
142
|
+
default on). On headless runs with persistence, each load writes a resume sidecar
|
|
143
|
+
(`context.<N>.json`) that is **merged back into `context`** on resume.
|
|
144
|
+
|
|
145
|
+
### Artifact-gated pipeline (opt-in via `pipeline: true`)
|
|
146
|
+
|
|
147
|
+
When enabled at root depth:
|
|
148
|
+
|
|
149
|
+
1. **Goal capture** — the brief is written verbatim to `.rlm/artifacts/goal/goal-<ts>.md` with a pre-run dirty-tree baseline.
|
|
150
|
+
2. **Stages** — `clarify → research → blueprint → validate` (**read-only** — produces a validated plan; does not write code). Each produces a durable markdown artifact with frontmatter contracts; chat history is **reset** at every phase boundary (artifacts are the only channel; REPL vars persist).
|
|
151
|
+
3. **Clarify (intake)** — interviews the user via `ask_user_question` (intent first, then evidence-confirmed decisions). Writes `.rlm/artifacts/clarifications/*` with `decisions_count` / `open_questions_count`. Engine gate: **≥1 serviced ask round** + artifact contract. When **`askUserQuestion` is off**, clarify is skipped and the run starts at research.
|
|
152
|
+
4. **Gates (TypeScript, never LLM judgment)** — `status: ready`; clarify structure; plan `phases:` ≡ fence-aware `## Phase N:` headings; every `file:line` citation resolves; validate carries `blockers_count` + `verdict`. Preflight critique runs on every `save_artifact`.
|
|
153
|
+
5. **Validate** — adversarial plan review against the tree (not a post-implementation diff check). Final answer is the validated plan.
|
|
154
|
+
6. **Corrective loop** — `blockers_count > 0` re-enters blueprint (superseded plan kept for context), bounded by `maxBackwardJumps` (default 2).
|
|
155
|
+
|
|
156
|
+
Native RLM mode authors file changes with Pi's native `edit` / `write` tools. Sub-LLMs extract and locate; they never ship code.
|
|
157
|
+
|
|
125
158
|
## Settings (`/rlm-config`)
|
|
126
159
|
|
|
127
160
|
| Setting | Default | Meaning |
|
|
@@ -137,11 +170,15 @@ These functions are injected into the model's Python namespace inside the REPL:
|
|
|
137
170
|
| Token ceiling | none | total input+output token cap for the whole recursive tree |
|
|
138
171
|
| Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
|
|
139
172
|
| Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
|
|
173
|
+
| Phase pipeline | off | artifact-gated clarify→research→blueprint→validate (read-only plan pipeline) |
|
|
174
|
+
| Max validate→blueprint loops | `2` | bounded corrective re-entries when validation reports blockers |
|
|
175
|
+
| Ask user question | on | when pipeline is on, enables clarify intake; when off, pipeline starts at research |
|
|
140
176
|
| Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
|
|
141
177
|
| Root model output cap (tok) | `16384` | max output tokens per root-model turn |
|
|
142
178
|
| Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
|
|
143
179
|
| `askUserQuestion` | on | expose `ask_user_question()` to the model |
|
|
144
180
|
| `todo` | on | expose `todo()` to the model |
|
|
181
|
+
| Library loader | on | expose `load_library()` for external dirs/files/git repos |
|
|
145
182
|
|
|
146
183
|
> **Concurrency note:** each `rlm_query` child spawns its own `python3` worker (~50–150 ms
|
|
147
184
|
> cold start). Worst-case concurrent interpreters ≈ `maxConcurrentSubcalls`^(depth−1); at
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hicaru/pi-rlm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Save 99% tokens, Recursive Language Model (RLM) for the Pi",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"check": "tsc --noEmit",
|
|
30
|
+
"test": "bun run test/smoke.ts",
|
|
30
31
|
"prepublishOnly": "npm run check"
|
|
31
32
|
},
|
|
32
33
|
"pi": {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared load_library handler for headless engine and native repl() mode.
|
|
3
|
+
*
|
|
4
|
+
* Host packs the source via resolveLibrarySource (namespaced under lib/<id>/),
|
|
5
|
+
* assigns a resume-sidecar index, and returns the payload for the worker to
|
|
6
|
+
* append into the single `context` list.
|
|
7
|
+
*
|
|
8
|
+
* Idempotency is host-side: re-loading a source that was already packed does
|
|
9
|
+
* not consume an index, write a sidecar, or re-clone/pack. That keeps resume
|
|
10
|
+
* trails free of duplicate library slots.
|
|
11
|
+
*
|
|
12
|
+
* Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
|
|
13
|
+
* across native repl() calls — getOrCreate only installs handlers at spawn.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
17
|
+
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
18
|
+
import {
|
|
19
|
+
libraryNamespace,
|
|
20
|
+
resolveLibrarySource,
|
|
21
|
+
} from "../context/library-context.ts";
|
|
22
|
+
import { previewText } from "../text/preview.ts";
|
|
23
|
+
|
|
24
|
+
export interface LibraryBridgeOpts {
|
|
25
|
+
/** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
|
|
26
|
+
readonly cwd?: string;
|
|
27
|
+
/** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
|
|
28
|
+
readonly getCwd?: () => string;
|
|
29
|
+
readonly emitter?: RlmEmitter;
|
|
30
|
+
/** Native mode: read the live emitter each call. */
|
|
31
|
+
readonly getEmitter?: () => RlmEmitter | null | undefined;
|
|
32
|
+
readonly parentId?: string;
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
/** First resume-sidecar index (slot 0 = repo). Resume passes 1 + max restored. */
|
|
35
|
+
readonly startIndex: number;
|
|
36
|
+
/**
|
|
37
|
+
* Prefixes already present in context (e.g. restored from sidecars).
|
|
38
|
+
* Seeded so re-load after resume is still a no-op without re-packing.
|
|
39
|
+
*/
|
|
40
|
+
readonly loadedPrefixes?: readonly string[];
|
|
41
|
+
/** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
|
|
42
|
+
readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LibraryHandlerBundle {
|
|
46
|
+
readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
|
|
47
|
+
/** Reset the sidecar index counter (call when the sandbox is discarded and will re-spawn). */
|
|
48
|
+
readonly reset: () => void;
|
|
49
|
+
/** Prefixes loaded in this sandbox lifetime (for tests). */
|
|
50
|
+
readonly loadedPrefixes: () => ReadonlySet<string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
|
|
54
|
+
let nextIndex = opts.startIndex;
|
|
55
|
+
/** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
|
|
56
|
+
const loaded = new Set<string>(opts.loadedPrefixes ?? []);
|
|
57
|
+
return {
|
|
58
|
+
reset: () => {
|
|
59
|
+
nextIndex = opts.startIndex;
|
|
60
|
+
loaded.clear();
|
|
61
|
+
},
|
|
62
|
+
loadedPrefixes: () => loaded,
|
|
63
|
+
handlers: {
|
|
64
|
+
async loadLibrary(source, depth) {
|
|
65
|
+
const emitter = opts.getEmitter?.() ?? opts.emitter;
|
|
66
|
+
const cwd = opts.getCwd?.() ?? opts.cwd;
|
|
67
|
+
if (cwd === undefined || cwd === "") {
|
|
68
|
+
throw new Error("load_library: no cwd configured");
|
|
69
|
+
}
|
|
70
|
+
const id = emitter?.emitSubcallCreated({
|
|
71
|
+
kind: "tool", parentId: opts.parentId,
|
|
72
|
+
label: "load_library",
|
|
73
|
+
args: previewText(source, 80),
|
|
74
|
+
depth,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
// Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
|
|
78
|
+
const { sourceId: preId, pathPrefix: prefix } = libraryNamespace(source, cwd);
|
|
79
|
+
if (loaded.has(prefix)) {
|
|
80
|
+
if (id) {
|
|
81
|
+
emitter?.emitSubcallUpdated({
|
|
82
|
+
id,
|
|
83
|
+
status: "done",
|
|
84
|
+
resultPreview: `already loaded (${prefix}*)`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// No index consumed, no sidecar written — resume stays consistent.
|
|
88
|
+
return {
|
|
89
|
+
payload: Object.freeze([]),
|
|
90
|
+
index: -1,
|
|
91
|
+
files: 0,
|
|
92
|
+
chars: 0,
|
|
93
|
+
sourceId: preId,
|
|
94
|
+
pathPrefix: prefix,
|
|
95
|
+
alreadyLoaded: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resolved = await resolveLibrarySource(source, cwd, opts.signal);
|
|
100
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
101
|
+
const { payload, files, chars, sourceId, pathPrefix } = resolved.value;
|
|
102
|
+
|
|
103
|
+
// Race: another concurrent load of the same prefix finished while we packed.
|
|
104
|
+
if (loaded.has(pathPrefix)) {
|
|
105
|
+
if (id) {
|
|
106
|
+
emitter?.emitSubcallUpdated({
|
|
107
|
+
id,
|
|
108
|
+
status: "done",
|
|
109
|
+
resultPreview: `already loaded (${pathPrefix}*)`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
payload: Object.freeze([]),
|
|
114
|
+
index: -1,
|
|
115
|
+
files: 0,
|
|
116
|
+
chars: 0,
|
|
117
|
+
sourceId,
|
|
118
|
+
pathPrefix,
|
|
119
|
+
alreadyLoaded: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Increment only after a successful sidecar write (or when no hook is set).
|
|
124
|
+
const index = nextIndex;
|
|
125
|
+
if (opts.onLoaded) {
|
|
126
|
+
await opts.onLoaded(index, payload);
|
|
127
|
+
}
|
|
128
|
+
nextIndex = index + 1;
|
|
129
|
+
loaded.add(pathPrefix);
|
|
130
|
+
|
|
131
|
+
if (id) {
|
|
132
|
+
emitter?.emitSubcallUpdated({
|
|
133
|
+
id,
|
|
134
|
+
status: "done",
|
|
135
|
+
resultPreview:
|
|
136
|
+
`+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars)`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
payload,
|
|
141
|
+
index,
|
|
142
|
+
files,
|
|
143
|
+
chars,
|
|
144
|
+
sourceId,
|
|
145
|
+
pathPrefix,
|
|
146
|
+
alreadyLoaded: false,
|
|
147
|
+
};
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
|
150
|
+
throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/bridge/llm-query.ts
CHANGED
|
@@ -125,6 +125,7 @@ export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
|
|
|
125
125
|
if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
|
|
126
126
|
status: error ? "error" : "done", costUsd: cost, tokens,
|
|
127
127
|
resultPreview, detail: error,
|
|
128
|
+
failedCount: failed, totalCount: out.length,
|
|
128
129
|
});
|
|
129
130
|
return out;
|
|
130
131
|
},
|
package/src/bridge/rlm-query.ts
CHANGED
|
@@ -4,18 +4,30 @@
|
|
|
4
4
|
* A child RLM gets its own sandbox and iterates over the prompt as its context. At/over the
|
|
5
5
|
* depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
|
|
6
6
|
* concurrency pool bounds parallel children for `rlm_query_batched`.
|
|
7
|
+
*
|
|
8
|
+
* `childRun` is the single spawn path shared by `rlmQuery` / `rlmQueryBatched`.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import type { RunRlm } from "../core/types.ts";
|
|
11
|
+
import type { RlmResult, RunRlm } from "../core/types.ts";
|
|
10
12
|
import type { LlmBridge } from "./llm-query.ts";
|
|
11
13
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
12
14
|
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
13
15
|
import { formatError } from "../util/errors.ts";
|
|
14
16
|
import { mapPool } from "../util/concurrency.ts";
|
|
15
17
|
|
|
18
|
+
export interface ChildRunInput {
|
|
19
|
+
readonly rootPrompt: string;
|
|
20
|
+
readonly context: unknown;
|
|
21
|
+
readonly depth: number;
|
|
22
|
+
readonly label?: string;
|
|
23
|
+
readonly model?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export interface RlmHandlers {
|
|
17
27
|
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
18
28
|
rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
29
|
+
/** Full child-run result (answer + usage) for recursive spawns. */
|
|
30
|
+
childRun(input: ChildRunInput): Promise<RlmResult>;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export interface RlmBridgeOptions {
|
|
@@ -33,29 +45,50 @@ export interface RlmBridgeOptions {
|
|
|
33
45
|
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
function emptyResult(answer: string): RlmResult {
|
|
49
|
+
return {
|
|
50
|
+
answer,
|
|
51
|
+
iterations: 0,
|
|
52
|
+
costUsd: 0,
|
|
53
|
+
inputTokens: 0,
|
|
54
|
+
outputTokens: 0,
|
|
55
|
+
durationMs: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
37
|
-
async function
|
|
38
|
-
const childDepth = depth
|
|
60
|
+
async function childRun(input: ChildRunInput): Promise<RlmResult> {
|
|
61
|
+
const childDepth = input.depth;
|
|
39
62
|
// At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
|
|
40
|
-
|
|
63
|
+
// (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
|
|
64
|
+
if (childDepth >= opts.maxDepth) {
|
|
65
|
+
const answer = await opts.llm.llmQuery(
|
|
66
|
+
input.rootPrompt || String(input.context),
|
|
67
|
+
input.model ?? null,
|
|
68
|
+
childDepth - 1,
|
|
69
|
+
);
|
|
70
|
+
return emptyResult(answer);
|
|
71
|
+
}
|
|
41
72
|
let subId: string | undefined;
|
|
42
73
|
try {
|
|
43
74
|
const rem = opts.remainingBudget?.() ?? {};
|
|
44
75
|
// Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
|
|
45
76
|
// (reference: _subcall checks remaining_budget/timeout before spawning).
|
|
46
77
|
const limitError = checkResourceLimits(rem);
|
|
47
|
-
if (limitError) return limitError;
|
|
78
|
+
if (limitError) return emptyResult(limitError);
|
|
79
|
+
const label = input.label ?? "rlm_query";
|
|
80
|
+
const detailSource = input.rootPrompt || String(input.context);
|
|
48
81
|
subId = opts.emitter.emitSubcallCreated({
|
|
49
|
-
kind: "rlm", parentId: opts.parentNodeId, label
|
|
50
|
-
model: model ?? undefined, detail:
|
|
82
|
+
kind: "rlm", parentId: opts.parentNodeId, label,
|
|
83
|
+
model: input.model ?? undefined, detail: detailSource.slice(0, 60),
|
|
51
84
|
depth: childDepth,
|
|
52
85
|
});
|
|
53
86
|
const res = await opts.run({
|
|
54
|
-
rootPrompt:
|
|
55
|
-
context:
|
|
87
|
+
rootPrompt: input.rootPrompt,
|
|
88
|
+
context: input.context,
|
|
56
89
|
depth: childDepth,
|
|
57
90
|
parentNodeId: subId,
|
|
58
|
-
modelOverride: model ?? undefined,
|
|
91
|
+
modelOverride: input.model ?? undefined,
|
|
59
92
|
remainingBudgetUsd: rem.budgetUsd,
|
|
60
93
|
remainingTimeoutMs: rem.timeoutMs,
|
|
61
94
|
});
|
|
@@ -63,16 +96,27 @@ export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
|
63
96
|
opts.emitter.emitSubcallUpdated({ id: subId,
|
|
64
97
|
status: "done", resultPreview: res.answer.slice(0, 200),
|
|
65
98
|
});
|
|
66
|
-
return res
|
|
99
|
+
return res;
|
|
67
100
|
} catch (err) {
|
|
68
101
|
const msg = err instanceof Error ? err.message : String(err);
|
|
69
102
|
if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
70
|
-
return formatError(`child RLM failed - ${msg}`);
|
|
103
|
+
return emptyResult(formatError(`child RLM failed - ${msg}`));
|
|
71
104
|
}
|
|
72
105
|
}
|
|
73
106
|
|
|
107
|
+
async function child(prompt: string, model: string | null, depth: number): Promise<string> {
|
|
108
|
+
const res = await childRun({
|
|
109
|
+
rootPrompt: "",
|
|
110
|
+
context: prompt,
|
|
111
|
+
depth: depth + 1,
|
|
112
|
+
model,
|
|
113
|
+
});
|
|
114
|
+
return res.answer;
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
return {
|
|
75
118
|
rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
|
|
76
119
|
rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
|
|
120
|
+
childRun,
|
|
77
121
|
};
|
|
78
122
|
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -21,12 +21,14 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
21
21
|
maxErrors: 5,
|
|
22
22
|
orchestrator: true,
|
|
23
23
|
pipeline: false,
|
|
24
|
+
maxBackwardJumps: 2,
|
|
24
25
|
compaction: true,
|
|
25
26
|
compactionThresholdPct: 0.65,
|
|
26
27
|
python: "python3",
|
|
27
28
|
sandboxInitTimeoutMs: 30_000,
|
|
28
29
|
askUserQuestion: true,
|
|
29
30
|
todo: true,
|
|
31
|
+
libraryLoader: true,
|
|
30
32
|
rootSampling: Object.freeze({ maxTokens: 16_384 }),
|
|
31
33
|
subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
|
|
32
34
|
subSampling: Object.freeze({ maxTokens: 8192 }),
|
package/src/config/settings.ts
CHANGED
|
@@ -75,6 +75,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
75
75
|
if (orchestrator !== undefined) out.orchestrator = orchestrator;
|
|
76
76
|
const pipeline = validateBoolean(r.pipeline);
|
|
77
77
|
if (pipeline !== undefined) out.pipeline = pipeline;
|
|
78
|
+
const maxBackwardJumps = validateNumber(r.maxBackwardJumps, 0);
|
|
79
|
+
if (maxBackwardJumps !== undefined) out.maxBackwardJumps = maxBackwardJumps;
|
|
78
80
|
const compaction = validateBoolean(r.compaction);
|
|
79
81
|
if (compaction !== undefined) out.compaction = compaction;
|
|
80
82
|
const compactionThresholdPct = validateNumber(r.compactionThresholdPct, 0);
|
|
@@ -92,6 +94,8 @@ function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
92
94
|
if (askUserQuestion !== undefined) out.askUserQuestion = askUserQuestion;
|
|
93
95
|
const todo = validateBoolean(r.todo);
|
|
94
96
|
if (todo !== undefined) out.todo = todo;
|
|
97
|
+
const libraryLoader = validateBoolean(r.libraryLoader);
|
|
98
|
+
if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
|
|
95
99
|
if (typeof r.subSampling === "object" && r.subSampling !== null) {
|
|
96
100
|
const ss = r.subSampling as Record<string, unknown>;
|
|
97
101
|
const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve load_library(source) into a sandbox-ready payload.
|
|
3
|
+
*
|
|
4
|
+
* Sources: local directory (repomix-packed), single file (utf-8), or remote git URL
|
|
5
|
+
* (shallow clone then pack). Host-side only — never runs in the sandbox.
|
|
6
|
+
*
|
|
7
|
+
* Every successful payload is a namespaced list of ContextFile under
|
|
8
|
+
* `lib/<source_id>/…` so the worker can append into the single `context` variable.
|
|
9
|
+
* Source ids include a short content fingerprint so two libraries that share a
|
|
10
|
+
* basename never collide.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import {
|
|
20
|
+
packRepository,
|
|
21
|
+
serializeForSandbox,
|
|
22
|
+
type ContextBundle,
|
|
23
|
+
type ContextFile,
|
|
24
|
+
} from "./repomix-context.ts";
|
|
25
|
+
import { estimateTokens } from "../text/tokens.ts";
|
|
26
|
+
import type { Result } from "../util/errors.ts";
|
|
27
|
+
import { errorMessage } from "../util/errors.ts";
|
|
28
|
+
|
|
29
|
+
const execFileP = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
/** Single-file sources above this must use open() + llm_query_chunked in the REPL. */
|
|
32
|
+
export const MAX_LIBRARY_FILE_BYTES = 8 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** Legacy catch-all prefix for pre-namespace string sidecars — never an identity key. */
|
|
35
|
+
const LEGACY_UNKNOWN_PREFIX = "lib/unknown/";
|
|
36
|
+
|
|
37
|
+
export interface LibrarySource {
|
|
38
|
+
/** Always a namespaced file list (dirs, single files, and git clones). */
|
|
39
|
+
readonly payload: readonly ContextFile[];
|
|
40
|
+
readonly files: number;
|
|
41
|
+
/** Sum of raw content lengths — what the model should size batches against. */
|
|
42
|
+
readonly chars: number;
|
|
43
|
+
readonly sourceId: string;
|
|
44
|
+
readonly pathPrefix: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LibraryNamespace {
|
|
48
|
+
readonly sourceId: string;
|
|
49
|
+
readonly pathPrefix: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** https://host/… or git@host:… — option-injection safe (never starts with "-"). */
|
|
53
|
+
const GIT_URL = /^(https:\/\/|git@)[\w.-]+[:/]\S+$/;
|
|
54
|
+
|
|
55
|
+
/** Short, stable discriminator so two sources never share a namespace. */
|
|
56
|
+
function sourceFingerprint(canonical: string): string {
|
|
57
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 8);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize a path/url basename into a stable, filesystem-safe source id.
|
|
62
|
+
* `resolvedPath` (absolute path) or the git URL is fingerprinted into the id so
|
|
63
|
+
* distinct sources sharing a basename get distinct namespaces
|
|
64
|
+
* (`lib/utils-3f9a1c02/` vs `lib/utils-a1b2c3d4/`).
|
|
65
|
+
*/
|
|
66
|
+
export function librarySourceId(source: string, resolvedPath?: string): string {
|
|
67
|
+
const trimmed = source.trim();
|
|
68
|
+
const isGit = GIT_URL.test(trimmed);
|
|
69
|
+
let raw: string;
|
|
70
|
+
if (isGit) {
|
|
71
|
+
const m = trimmed.match(/(?:\/|:)([\w.-]+?)(?:\.git)?\/?\s*$/);
|
|
72
|
+
raw = m?.[1] ?? "repo";
|
|
73
|
+
} else {
|
|
74
|
+
raw = basename(resolvedPath ?? trimmed);
|
|
75
|
+
}
|
|
76
|
+
const cleaned = raw
|
|
77
|
+
.replace(/\.git$/i, "")
|
|
78
|
+
.replace(/[^\w.-]+/g, "-")
|
|
79
|
+
.replace(/^-+|-+$/g, "")
|
|
80
|
+
.slice(0, 80);
|
|
81
|
+
const canonical = isGit ? trimmed : (resolvedPath ?? trimmed);
|
|
82
|
+
return `${cleaned.length > 0 ? cleaned : "lib"}-${sourceFingerprint(canonical)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function pathPrefixFor(sourceId: string): string {
|
|
86
|
+
return `lib/${sourceId}/`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Derive the namespace for a source string without packing (host-side idempotency).
|
|
91
|
+
* Returns both sourceId and pathPrefix so callers never un-parse the prefix.
|
|
92
|
+
*/
|
|
93
|
+
export function libraryNamespace(source: string, cwd: string): LibraryNamespace {
|
|
94
|
+
const trimmed = source.trim();
|
|
95
|
+
const sourceId = GIT_URL.test(trimmed)
|
|
96
|
+
? librarySourceId(trimmed)
|
|
97
|
+
: librarySourceId(trimmed, isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed));
|
|
98
|
+
return Object.freeze({ sourceId, pathPrefix: pathPrefixFor(sourceId) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Derive the path prefix for a source string without packing. */
|
|
102
|
+
export function libraryPathPrefix(source: string, cwd: string): string {
|
|
103
|
+
return libraryNamespace(source, cwd).pathPrefix;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Namespaced files plus summed content chars (one pass). */
|
|
107
|
+
export function namespaceLibraryFilesWithChars(
|
|
108
|
+
payload: unknown,
|
|
109
|
+
sourceId: string,
|
|
110
|
+
): { readonly files: readonly ContextFile[]; readonly chars: number } {
|
|
111
|
+
const prefix = pathPrefixFor(sourceId);
|
|
112
|
+
if (typeof payload === "string") {
|
|
113
|
+
return Object.freeze({
|
|
114
|
+
files: Object.freeze([
|
|
115
|
+
Object.freeze({
|
|
116
|
+
path: `${prefix}content`,
|
|
117
|
+
content: payload,
|
|
118
|
+
tokens: Math.max(1, estimateTokens(payload.length)),
|
|
119
|
+
}),
|
|
120
|
+
]),
|
|
121
|
+
chars: payload.length,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(payload)) return Object.freeze({ files: Object.freeze([]), chars: 0 });
|
|
125
|
+
const out = new Array<ContextFile>(payload.length);
|
|
126
|
+
let n = 0;
|
|
127
|
+
let chars = 0;
|
|
128
|
+
for (const item of payload) {
|
|
129
|
+
if (item === null || typeof item !== "object") continue;
|
|
130
|
+
const rec = item as Record<string, unknown>;
|
|
131
|
+
const content = typeof rec.content === "string" ? rec.content : String(rec.content ?? "");
|
|
132
|
+
let path = typeof rec.path === "string" ? rec.path : "unknown";
|
|
133
|
+
path = path.replace(/^\/+/, "");
|
|
134
|
+
if (!path.startsWith(prefix)) path = `${prefix}${path}`;
|
|
135
|
+
const tokens = typeof rec.tokens === "number" && Number.isFinite(rec.tokens)
|
|
136
|
+
? Math.max(0, Math.floor(rec.tokens))
|
|
137
|
+
: Math.max(1, estimateTokens(content.length));
|
|
138
|
+
out[n++] = Object.freeze({ path, content, tokens });
|
|
139
|
+
chars += content.length;
|
|
140
|
+
}
|
|
141
|
+
out.length = n;
|
|
142
|
+
return Object.freeze({ files: Object.freeze(out), chars });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Namespace file entries under `lib/<sourceId>/…` (single shared implementation). */
|
|
146
|
+
export function namespaceLibraryFiles(
|
|
147
|
+
payload: unknown,
|
|
148
|
+
sourceId: string,
|
|
149
|
+
): readonly ContextFile[] {
|
|
150
|
+
return namespaceLibraryFilesWithChars(payload, sourceId).files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** First `lib/<id>/` prefix found in the payload, or undefined. Skips `lib/unknown/`. */
|
|
154
|
+
export function payloadPrefix(payload: readonly unknown[]): string | undefined {
|
|
155
|
+
for (let i = 0; i < payload.length; i++) {
|
|
156
|
+
const item = payload[i];
|
|
157
|
+
if (item === null || typeof item !== "object") continue;
|
|
158
|
+
const path = (item as { path?: unknown }).path;
|
|
159
|
+
if (typeof path !== "string") continue;
|
|
160
|
+
const m = /^(lib\/[^/]+\/)/.exec(path);
|
|
161
|
+
// `lib/unknown/` is the legacy catch-all: never treat it as an identity.
|
|
162
|
+
if (m?.[1] !== undefined && m[1] !== LEGACY_UNKNOWN_PREFIX) return m[1];
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Append a library payload into an existing list context (host-side resume merge).
|
|
169
|
+
* Skips a payload whose path prefix is already present (resume-safe dedup).
|
|
170
|
+
*/
|
|
171
|
+
export function mergeLibraryIntoContext(base: unknown, libraryPayload: unknown): unknown {
|
|
172
|
+
if (!Array.isArray(base)) return base;
|
|
173
|
+
if (Array.isArray(libraryPayload)) {
|
|
174
|
+
if (libraryPayload.length === 0) return base;
|
|
175
|
+
const prefix = payloadPrefix(libraryPayload);
|
|
176
|
+
if (prefix !== undefined) {
|
|
177
|
+
for (let i = 0; i < base.length; i++) {
|
|
178
|
+
const item = base[i];
|
|
179
|
+
if (item !== null && typeof item === "object"
|
|
180
|
+
&& typeof (item as { path?: unknown }).path === "string"
|
|
181
|
+
&& (item as { path: string }).path.startsWith(prefix)) {
|
|
182
|
+
return base; // already present
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const merged = new Array<unknown>(base.length + libraryPayload.length);
|
|
187
|
+
for (let i = 0; i < base.length; i++) merged[i] = base[i];
|
|
188
|
+
for (let i = 0; i < libraryPayload.length; i++) merged[base.length + i] = libraryPayload[i];
|
|
189
|
+
return merged;
|
|
190
|
+
}
|
|
191
|
+
if (typeof libraryPayload === "string") {
|
|
192
|
+
// Legacy string sidecars: wrap once under an unknown prefix.
|
|
193
|
+
return mergeLibraryIntoContext(base, namespaceLibraryFiles(libraryPayload, "unknown"));
|
|
194
|
+
}
|
|
195
|
+
return base;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toLibrarySource(payload: unknown, sourceId: string): LibrarySource {
|
|
199
|
+
const { files, chars } = namespaceLibraryFilesWithChars(payload, sourceId);
|
|
200
|
+
return {
|
|
201
|
+
payload: files,
|
|
202
|
+
files: files.length,
|
|
203
|
+
chars,
|
|
204
|
+
sourceId,
|
|
205
|
+
pathPrefix: pathPrefixFor(sourceId),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function resolveLibrarySource(
|
|
210
|
+
source: string,
|
|
211
|
+
cwd: string,
|
|
212
|
+
signal?: AbortSignal,
|
|
213
|
+
): Promise<Result<LibrarySource, string>> {
|
|
214
|
+
const trimmed = source.trim();
|
|
215
|
+
if (trimmed === "") return { ok: false, error: "load_library: empty source" };
|
|
216
|
+
if (GIT_URL.test(trimmed)) return await cloneAndPack(trimmed, signal);
|
|
217
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
218
|
+
return { ok: false, error: `unsupported URL scheme (only https:// and git@ are allowed): ${trimmed}` };
|
|
219
|
+
}
|
|
220
|
+
const path = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
|
|
221
|
+
let s: Awaited<ReturnType<typeof stat>>;
|
|
222
|
+
try {
|
|
223
|
+
s = await stat(path);
|
|
224
|
+
} catch {
|
|
225
|
+
return { ok: false, error: `load_library: path not found: ${path}` };
|
|
226
|
+
}
|
|
227
|
+
const sourceId = librarySourceId(trimmed, path);
|
|
228
|
+
if (s.isDirectory()) return await packDir(path, sourceId, signal);
|
|
229
|
+
if (s.size > MAX_LIBRARY_FILE_BYTES) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: `load_library: ${path} is ${s.size.toLocaleString()} bytes `
|
|
233
|
+
+ `(limit ${MAX_LIBRARY_FILE_BYTES.toLocaleString()}) — `
|
|
234
|
+
+ "open() it in the REPL and delegate with llm_query_chunked instead",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const text = await readFile(path, "utf-8");
|
|
238
|
+
return { ok: true, value: toLibrarySource(text, sourceId) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function packDir(
|
|
242
|
+
dir: string,
|
|
243
|
+
sourceId: string,
|
|
244
|
+
signal?: AbortSignal,
|
|
245
|
+
): Promise<Result<LibrarySource, string>> {
|
|
246
|
+
const packed = await packRepository(dir, signal);
|
|
247
|
+
if (!packed.ok) return { ok: false, error: `pack failed for ${dir} — ${packed.error}` };
|
|
248
|
+
return { ok: true, value: bundleToSource(packed.value, sourceId) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bundleToSource(bundle: ContextBundle, sourceId: string): LibrarySource {
|
|
252
|
+
return toLibrarySource(serializeForSandbox(bundle), sourceId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function cloneAndPack(url: string, signal?: AbortSignal): Promise<Result<LibrarySource, string>> {
|
|
256
|
+
const dir = await mkdtemp(join(tmpdir(), "rlm-lib-"));
|
|
257
|
+
const sourceId = librarySourceId(url);
|
|
258
|
+
try {
|
|
259
|
+
await execFileP("git", ["clone", "--depth", "1", "--", url, dir], { signal, timeout: 120_000 });
|
|
260
|
+
return await packDir(dir, sourceId, signal);
|
|
261
|
+
} catch (err: unknown) {
|
|
262
|
+
return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
|
|
263
|
+
} finally {
|
|
264
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
}
|