@hicaru/pi-rlm 0.1.7 → 0.1.8
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 +35 -1
- package/package.json +1 -1
- package/src/bridge/library.ts +77 -0
- package/src/bridge/rlm-query.ts +58 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +79 -0
- package/src/core/artifacts.ts +88 -0
- package/src/core/engine.ts +432 -35
- package/src/core/gates.ts +272 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +170 -27
- package/src/core/types.ts +4 -0
- package/src/index.ts +6 -1
- package/src/prompts/phases.ts +125 -0
- package/src/prompts/system.ts +38 -7
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +25 -2
- package/src/sandbox/sandbox.ts +42 -1
- package/src/sandbox/worker.py +42 -5
- 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 +19 -1
- package/src/state/rows.ts +6 -0
- package/src/state/writes.ts +5 -3
- package/src/text/edits.ts +148 -0
- package/src/tool/apply-edits-tool.ts +36 -29
- package/src/tool/repl-tool.ts +23 -2
- package/src/tool/rlm-tool.ts +0 -1
- package/src/ui/config-panel.ts +8 -1
package/README.md
CHANGED
|
@@ -117,11 +117,41 @@ 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
|
+
| `load_library` | `(source) -> dict \| str` | Load an external dir, file, or git URL into a new `context_N` slot |
|
|
120
121
|
| `stage_edit` | `(path, old_text, new_text) -> str` | Stage a file edit; relayed to the host's native edit flow |
|
|
121
|
-
| `
|
|
122
|
+
| `save_artifact` | `(kind, content) -> str` | Persist a stage artifact (`clarification` / `research` / `plan` / `validation`) under `.rlm/artifacts/` (root depth only) |
|
|
123
|
+
| `advance_phase` | `(phase, summary=None) -> str` | Advance one step in order `clarify → research → blueprint → implement → validate` (clarify skipped when `askUserQuestion` is off). **Engine-gated** on the latest artifact + interview rounds. Rejected transitions return the gate error. |
|
|
122
124
|
| `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
|
|
123
125
|
| `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
|
|
124
126
|
|
|
127
|
+
### Loading external libraries
|
|
128
|
+
|
|
129
|
+
When the task needs an **external library, another source tree, or standalone docs** that are
|
|
130
|
+
not in the packed repo `context`, the model calls `load_library(source)` mid-run:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
info = load_library("../some-lib") # local directory → repomix-packed list[dict]
|
|
134
|
+
info = load_library("docs/api.md") # single file → plain str
|
|
135
|
+
info = load_library("https://github.com/x/y.git") # shallow clone, then pack
|
|
136
|
+
# info == {"index": 1, "var": "context_1", "files": …, "chars": …}
|
|
137
|
+
# then chunk context_1 exactly like context
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Slots start at `context_1` (`context` / `context_0` remains the repo). Toggle via
|
|
141
|
+
`/rlm-config` → **Library loader** (`libraryLoader`, default on). On headless runs with
|
|
142
|
+
persistence, each loaded slot is written as a resume sidecar (`context.<N>.json`).
|
|
143
|
+
|
|
144
|
+
### Artifact-gated pipeline (opt-in via `pipeline: true`)
|
|
145
|
+
|
|
146
|
+
When enabled at root depth:
|
|
147
|
+
|
|
148
|
+
1. **Goal capture** — the brief is written verbatim to `.rlm/artifacts/goal/goal-<ts>.md` with a pre-run dirty-tree baseline.
|
|
149
|
+
2. **Stages** — `clarify → research → blueprint → implement → validate`. 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).
|
|
150
|
+
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.
|
|
151
|
+
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`.
|
|
152
|
+
5. **Implement fanout** — on `advance_phase("implement")` the engine runs one **serial** child RLM per plan phase and applies that child's edits before the next phase starts.
|
|
153
|
+
6. **Corrective loop** — `blockers_count > 0` re-enters blueprint, bounded by `maxBackwardJumps` (default 2).
|
|
154
|
+
|
|
125
155
|
## Settings (`/rlm-config`)
|
|
126
156
|
|
|
127
157
|
| Setting | Default | Meaning |
|
|
@@ -137,11 +167,15 @@ These functions are injected into the model's Python namespace inside the REPL:
|
|
|
137
167
|
| Token ceiling | none | total input+output token cap for the whole recursive tree |
|
|
138
168
|
| Max consecutive errors | `5` | stop after N consecutive failing turns (none = off) |
|
|
139
169
|
| Orchestrator addendum | on | divide-and-conquer guidance in the root system prompt |
|
|
170
|
+
| Phase pipeline | off | artifact-gated clarify→research→blueprint→implement fanout→validate |
|
|
171
|
+
| Max validate→blueprint loops | `2` | bounded corrective re-entries when validation reports blockers |
|
|
172
|
+
| Ask user question | on | when pipeline is on, enables clarify intake; when off, pipeline starts at research |
|
|
140
173
|
| Trajectory compaction | on (0.65) | summarize old turns when history nears the context window |
|
|
141
174
|
| Root model output cap (tok) | `16384` | max output tokens per root-model turn |
|
|
142
175
|
| Sandbox init timeout | `30000` ms | how long to wait for the Python worker to start |
|
|
143
176
|
| `askUserQuestion` | on | expose `ask_user_question()` to the model |
|
|
144
177
|
| `todo` | on | expose `todo()` to the model |
|
|
178
|
+
| Library loader | on | expose `load_library()` for external dirs/files/git repos |
|
|
145
179
|
|
|
146
180
|
> **Concurrency note:** each `rlm_query` child spawns its own `python3` worker (~50–150 ms
|
|
147
181
|
> cold start). Worst-case concurrent interpreters ≈ `maxConcurrentSubcalls`^(depth−1); at
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared load_library handler for headless engine and native repl() mode.
|
|
3
|
+
*
|
|
4
|
+
* Host assigns context slot indices (slot 0 = repo); packs the source via
|
|
5
|
+
* resolveLibrarySource; optional onLoaded writes resume sidecars.
|
|
6
|
+
*
|
|
7
|
+
* Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
|
|
8
|
+
* across native repl() calls — getOrCreate only installs handlers at spawn.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
12
|
+
import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
13
|
+
import { resolveLibrarySource } from "../context/library-context.ts";
|
|
14
|
+
import { previewText } from "../text/preview.ts";
|
|
15
|
+
|
|
16
|
+
export interface LibraryBridgeOpts {
|
|
17
|
+
/** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
|
|
18
|
+
readonly cwd?: string;
|
|
19
|
+
/** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
|
|
20
|
+
readonly getCwd?: () => string;
|
|
21
|
+
readonly emitter?: RlmEmitter;
|
|
22
|
+
/** Native mode: read the live emitter each call. */
|
|
23
|
+
readonly getEmitter?: () => RlmEmitter | null | undefined;
|
|
24
|
+
readonly parentId?: string;
|
|
25
|
+
readonly signal?: AbortSignal;
|
|
26
|
+
/** First slot to assign (slot 0 is the repo context). Resume passes 1 + restored slots. */
|
|
27
|
+
readonly startIndex: number;
|
|
28
|
+
/** Post-load hook — the engine writes the resume sidecar here; native mode omits it. */
|
|
29
|
+
readonly onLoaded?: (index: number, payload: unknown) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LibraryHandlerBundle {
|
|
33
|
+
readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
|
|
34
|
+
/** Reset the slot counter (call when the sandbox is discarded and will re-spawn). */
|
|
35
|
+
readonly reset: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
|
|
39
|
+
let nextIndex = opts.startIndex;
|
|
40
|
+
return {
|
|
41
|
+
reset: () => { nextIndex = opts.startIndex; },
|
|
42
|
+
handlers: {
|
|
43
|
+
async loadLibrary(source, depth) {
|
|
44
|
+
const emitter = opts.getEmitter?.() ?? opts.emitter;
|
|
45
|
+
const cwd = opts.getCwd?.() ?? opts.cwd;
|
|
46
|
+
if (cwd === undefined || cwd === "") {
|
|
47
|
+
throw new Error("load_library: no cwd configured");
|
|
48
|
+
}
|
|
49
|
+
const id = emitter?.emitSubcallCreated({
|
|
50
|
+
kind: "tool", parentId: opts.parentId,
|
|
51
|
+
label: "load_library",
|
|
52
|
+
args: previewText(source, 80),
|
|
53
|
+
depth,
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
const resolved = await resolveLibrarySource(source, cwd, opts.signal);
|
|
57
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
58
|
+
const index = nextIndex++;
|
|
59
|
+
await opts.onLoaded?.(index, resolved.value.payload);
|
|
60
|
+
if (id) emitter?.emitSubcallUpdated({
|
|
61
|
+
id, status: "done",
|
|
62
|
+
resultPreview: `context_${index}: ${resolved.value.files ?? 1} file(s), ${resolved.value.chars.toLocaleString()} chars`,
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
payload: resolved.value.payload,
|
|
66
|
+
index,
|
|
67
|
+
files: resolved.value.files,
|
|
68
|
+
chars: resolved.value.chars,
|
|
69
|
+
};
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
|
|
72
|
+
throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/bridge/rlm-query.ts
CHANGED
|
@@ -4,18 +4,31 @@
|
|
|
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: rlmQuery returns `.answer`; implement fanout also
|
|
9
|
+
* needs `.edits` — both share this implementation (DRY).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import type { RunRlm } from "../core/types.ts";
|
|
12
|
+
import type { RlmResult, RunRlm } from "../core/types.ts";
|
|
10
13
|
import type { LlmBridge } from "./llm-query.ts";
|
|
11
14
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
12
15
|
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
13
16
|
import { formatError } from "../util/errors.ts";
|
|
14
17
|
import { mapPool } from "../util/concurrency.ts";
|
|
15
18
|
|
|
19
|
+
export interface ChildRunInput {
|
|
20
|
+
readonly rootPrompt: string;
|
|
21
|
+
readonly context: unknown;
|
|
22
|
+
readonly depth: number;
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
readonly model?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export interface RlmHandlers {
|
|
17
28
|
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
18
29
|
rlmQueryBatched(prompts: string[], model: string | null, depth: number): Promise<string[]>;
|
|
30
|
+
/** Full child-run result (answer + edits + usage) for engine-driven fanout. */
|
|
31
|
+
childRun(input: ChildRunInput): Promise<RlmResult>;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface RlmBridgeOptions {
|
|
@@ -33,29 +46,51 @@ export interface RlmBridgeOptions {
|
|
|
33
46
|
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
34
47
|
}
|
|
35
48
|
|
|
49
|
+
function emptyResult(answer: string): RlmResult {
|
|
50
|
+
return {
|
|
51
|
+
answer,
|
|
52
|
+
edits: [],
|
|
53
|
+
iterations: 0,
|
|
54
|
+
costUsd: 0,
|
|
55
|
+
inputTokens: 0,
|
|
56
|
+
outputTokens: 0,
|
|
57
|
+
durationMs: 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
37
|
-
async function
|
|
38
|
-
const childDepth = depth
|
|
62
|
+
async function childRun(input: ChildRunInput): Promise<RlmResult> {
|
|
63
|
+
const childDepth = input.depth;
|
|
39
64
|
// At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
|
|
40
|
-
|
|
65
|
+
// (Callers pass the absolute child depth; rlmQuery wraps with depth+1.)
|
|
66
|
+
if (childDepth >= opts.maxDepth) {
|
|
67
|
+
const answer = await opts.llm.llmQuery(
|
|
68
|
+
input.rootPrompt || String(input.context),
|
|
69
|
+
input.model ?? null,
|
|
70
|
+
childDepth - 1,
|
|
71
|
+
);
|
|
72
|
+
return emptyResult(answer);
|
|
73
|
+
}
|
|
41
74
|
let subId: string | undefined;
|
|
42
75
|
try {
|
|
43
76
|
const rem = opts.remainingBudget?.() ?? {};
|
|
44
77
|
// Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
|
|
45
78
|
// (reference: _subcall checks remaining_budget/timeout before spawning).
|
|
46
79
|
const limitError = checkResourceLimits(rem);
|
|
47
|
-
if (limitError) return limitError;
|
|
80
|
+
if (limitError) return emptyResult(limitError);
|
|
81
|
+
const label = input.label ?? "rlm_query";
|
|
82
|
+
const detailSource = input.rootPrompt || String(input.context);
|
|
48
83
|
subId = opts.emitter.emitSubcallCreated({
|
|
49
|
-
kind: "rlm", parentId: opts.parentNodeId, label
|
|
50
|
-
model: model ?? undefined, detail:
|
|
84
|
+
kind: "rlm", parentId: opts.parentNodeId, label,
|
|
85
|
+
model: input.model ?? undefined, detail: detailSource.slice(0, 60),
|
|
51
86
|
depth: childDepth,
|
|
52
87
|
});
|
|
53
88
|
const res = await opts.run({
|
|
54
|
-
rootPrompt:
|
|
55
|
-
context:
|
|
89
|
+
rootPrompt: input.rootPrompt,
|
|
90
|
+
context: input.context,
|
|
56
91
|
depth: childDepth,
|
|
57
92
|
parentNodeId: subId,
|
|
58
|
-
modelOverride: model ?? undefined,
|
|
93
|
+
modelOverride: input.model ?? undefined,
|
|
59
94
|
remainingBudgetUsd: rem.budgetUsd,
|
|
60
95
|
remainingTimeoutMs: rem.timeoutMs,
|
|
61
96
|
});
|
|
@@ -63,16 +98,27 @@ export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
|
|
|
63
98
|
opts.emitter.emitSubcallUpdated({ id: subId,
|
|
64
99
|
status: "done", resultPreview: res.answer.slice(0, 200),
|
|
65
100
|
});
|
|
66
|
-
return res
|
|
101
|
+
return res;
|
|
67
102
|
} catch (err) {
|
|
68
103
|
const msg = err instanceof Error ? err.message : String(err);
|
|
69
104
|
if (subId) opts.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
70
|
-
return formatError(`child RLM failed - ${msg}`);
|
|
105
|
+
return emptyResult(formatError(`child RLM failed - ${msg}`));
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
108
|
|
|
109
|
+
async function child(prompt: string, model: string | null, depth: number): Promise<string> {
|
|
110
|
+
const res = await childRun({
|
|
111
|
+
rootPrompt: "",
|
|
112
|
+
context: prompt,
|
|
113
|
+
depth: depth + 1,
|
|
114
|
+
model,
|
|
115
|
+
});
|
|
116
|
+
return res.answer;
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
return {
|
|
75
120
|
rlmQuery: (prompt, model, depth) => child(prompt, model, depth),
|
|
76
121
|
rlmQueryBatched: (prompts, model, depth) => mapPool(prompts, opts.maxConcurrent, (p) => child(p, model, depth)),
|
|
122
|
+
childRun,
|
|
77
123
|
};
|
|
78
124
|
}
|
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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve load_library(source) into a sandbox-ready payload.
|
|
3
|
+
*
|
|
4
|
+
* Sources: local directory (repomix-packed), single file (utf-8 string), or
|
|
5
|
+
* remote git URL (shallow clone then pack). Host-side only — never runs in the sandbox.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { packRepository, serializeForSandbox, type ContextBundle } from "./repomix-context.ts";
|
|
14
|
+
import type { Result } from "../util/errors.ts";
|
|
15
|
+
import { errorMessage } from "../util/errors.ts";
|
|
16
|
+
|
|
17
|
+
const execFileP = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
export interface LibrarySource {
|
|
20
|
+
readonly payload: unknown; // str for a single file; ContextFile[] for a packed dir/repo
|
|
21
|
+
readonly files?: number; // undefined for single-file payloads
|
|
22
|
+
readonly chars: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** https://host/… or git@host:… — option-injection safe (never starts with "-"). */
|
|
26
|
+
const GIT_URL = /^(https:\/\/|git@)[\w.-]+[:/]\S+$/;
|
|
27
|
+
|
|
28
|
+
export async function resolveLibrarySource(
|
|
29
|
+
source: string,
|
|
30
|
+
cwd: string,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
): Promise<Result<LibrarySource, string>> {
|
|
33
|
+
const trimmed = source.trim();
|
|
34
|
+
if (trimmed === "") return { ok: false, error: "load_library: empty source" };
|
|
35
|
+
if (GIT_URL.test(trimmed)) return await cloneAndPack(trimmed, signal);
|
|
36
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
37
|
+
return { ok: false, error: `unsupported URL scheme (only https:// and git@ are allowed): ${trimmed}` };
|
|
38
|
+
}
|
|
39
|
+
const path = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
|
|
40
|
+
let s: Awaited<ReturnType<typeof stat>>;
|
|
41
|
+
try {
|
|
42
|
+
s = await stat(path);
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, error: `load_library: path not found: ${path}` };
|
|
45
|
+
}
|
|
46
|
+
if (s.isDirectory()) return await packDir(path, signal);
|
|
47
|
+
const text = await readFile(path, "utf-8");
|
|
48
|
+
return { ok: true, value: { payload: text, chars: text.length } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function packDir(dir: string, signal?: AbortSignal): Promise<Result<LibrarySource, string>> {
|
|
52
|
+
const packed = await packRepository(dir, signal); // repomix + existing per-path cache
|
|
53
|
+
if (!packed.ok) return { ok: false, error: `pack failed for ${dir} — ${packed.error}` };
|
|
54
|
+
return { ok: true, value: bundleToSource(packed.value) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bundleToSource(bundle: ContextBundle): LibrarySource {
|
|
58
|
+
const payload = serializeForSandbox(bundle);
|
|
59
|
+
// Match slot-0 contextLength for object/array payloads: JSON-serialized size,
|
|
60
|
+
// not sum-of-content-lengths (bundle.totalChars), so the model sees one ruler.
|
|
61
|
+
return {
|
|
62
|
+
payload,
|
|
63
|
+
files: bundle.totalFiles,
|
|
64
|
+
chars: JSON.stringify(payload).length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cloneAndPack(url: string, signal?: AbortSignal): Promise<Result<LibrarySource, string>> {
|
|
69
|
+
const dir = await mkdtemp(join(tmpdir(), "rlm-lib-"));
|
|
70
|
+
try {
|
|
71
|
+
// "--" terminates options; URL regex already forbids a leading dash.
|
|
72
|
+
await execFileP("git", ["clone", "--depth", "1", "--", url, dir], { signal, timeout: 120_000 });
|
|
73
|
+
return await packDir(dir, signal);
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
return { ok: false, error: `git clone failed for ${url} — ${errorMessage(err)}` };
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact plumbing for the gated RLM pipeline: goal capture, baseline dirty-tree
|
|
3
|
+
* snapshot, and timestamped stage artifact writes under `.rlm/artifacts/`.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { Result } from "../util/errors.ts";
|
|
9
|
+
|
|
10
|
+
export const ARTIFACTS_DIR = ".rlm/artifacts";
|
|
11
|
+
|
|
12
|
+
const stamp = (): string => new Date().toISOString().replace(/[:.]/g, "-");
|
|
13
|
+
|
|
14
|
+
export interface GoalCapture {
|
|
15
|
+
readonly goalPath: string; // repo-relative
|
|
16
|
+
readonly baselinePath: string; // repo-relative
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SaveOutcome =
|
|
20
|
+
| { readonly ok: true; readonly path: string }
|
|
21
|
+
| { readonly ok: false; readonly error: string };
|
|
22
|
+
|
|
23
|
+
export type GoalCaptureResult =
|
|
24
|
+
| { readonly ok: true; readonly value: GoalCapture }
|
|
25
|
+
| { readonly ok: false; readonly error: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Capture the user's brief VERBATIM: no frontmatter, no headers — the raw file
|
|
29
|
+
* is the only artifact that preserves explicit user constraints unrefracted. The
|
|
30
|
+
* baseline snapshot records paths ALREADY dirty before the run, so validate
|
|
31
|
+
* judges only the run's own delta. Best-effort: git failure ⇒ empty baseline.
|
|
32
|
+
* Failures never throw (unwritable cwd etc.) — returns error for the engine to surface.
|
|
33
|
+
*/
|
|
34
|
+
export function captureGoal(cwd: string, brief: string): GoalCaptureResult {
|
|
35
|
+
try {
|
|
36
|
+
const ts = stamp();
|
|
37
|
+
const dir = join(ARTIFACTS_DIR, "goal");
|
|
38
|
+
mkdirSync(join(cwd, dir), { recursive: true });
|
|
39
|
+
const goalPath = join(dir, `goal-${ts}.md`);
|
|
40
|
+
writeFileSync(join(cwd, goalPath), brief, "utf-8");
|
|
41
|
+
let paths: readonly string[] = [];
|
|
42
|
+
try {
|
|
43
|
+
paths = execFileSync("git", ["status", "--short"], {
|
|
44
|
+
cwd,
|
|
45
|
+
encoding: "utf-8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
})
|
|
48
|
+
.split("\n")
|
|
49
|
+
.filter((l) => l.trim() !== "")
|
|
50
|
+
.map((l) => {
|
|
51
|
+
const rest = l.slice(3).trim();
|
|
52
|
+
const arrow = rest.indexOf(" -> ");
|
|
53
|
+
return arrow >= 0 ? rest.slice(arrow + 4).trim() : rest;
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
paths = [];
|
|
57
|
+
}
|
|
58
|
+
const baselinePath = join(dir, `baseline-${ts}.json`);
|
|
59
|
+
writeFileSync(join(cwd, baselinePath), JSON.stringify({ paths }, null, 2), "utf-8");
|
|
60
|
+
return { ok: true, value: { goalPath, baselinePath } };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
return { ok: false, error: message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write a stage artifact under its dir; timestamped so runs never collide. */
|
|
68
|
+
export function saveArtifact(cwd: string, dir: string, slug: string, content: string): SaveOutcome {
|
|
69
|
+
try {
|
|
70
|
+
const rel = join(ARTIFACTS_DIR, dir, `${stamp()}_${slug}.md`);
|
|
71
|
+
mkdirSync(join(cwd, ARTIFACTS_DIR, dir), { recursive: true });
|
|
72
|
+
writeFileSync(join(cwd, rel), content, "utf-8");
|
|
73
|
+
return { ok: true, path: rel };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return { ok: false, error: message };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read a previously saved artifact (repo-relative path). Failures never throw. */
|
|
81
|
+
export function readArtifact(cwd: string, relPath: string): Result<string, string> {
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, value: readFileSync(join(cwd, relPath), "utf-8") };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
return { ok: false, error: `could not read artifact ${relPath}: ${message}` };
|
|
87
|
+
}
|
|
88
|
+
}
|