@hicaru/pi-rlm 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -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 =
@@ -16,26 +12,22 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
16
12
  maxIterations: 30,
17
13
  execTimeoutS: 120,
18
14
  requestTimeoutMs: 10 * 60_000,
19
- maxConcurrentSubcalls: 4,
15
+ // Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
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,
20
22
  maxPromptChars: 400_000,
21
23
  maxErrors: 5,
22
24
  orchestrator: true,
23
- pipeline: false,
24
- maxBackwardJumps: 2,
25
25
  compaction: true,
26
26
  compactionThresholdPct: 0.65,
27
27
  python: "python3",
28
28
  sandboxInitTimeoutMs: 30_000,
29
- askUserQuestion: true,
30
- todo: true,
31
29
  libraryLoader: true,
32
30
  rootSampling: Object.freeze({ maxTokens: 16_384 }),
33
31
  subSystemPrompt: DEFAULT_SUB_SYSTEM_PROMPT,
34
32
  subSampling: Object.freeze({ maxTokens: 8192 }),
35
- runLog: Object.freeze({
36
- enabled: true,
37
- dir: DEFAULT_RUN_DIR,
38
- snapshot: true,
39
- maxRuns: 50,
40
- }),
41
33
  });
@@ -1,15 +1,16 @@
1
- /** Persist RLM settings (tunable config + chosen worker model id). */
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, RunLogConfig } from "../core/types.ts";
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
- readonly worker?: string;
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,14 +82,8 @@ 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
- const askUserQuestion = validateBoolean(r.askUserQuestion);
108
- if (askUserQuestion !== undefined) out.askUserQuestion = askUserQuestion;
109
- const todo = validateBoolean(r.todo);
110
- if (todo !== undefined) out.todo = todo;
111
87
  const libraryLoader = validateBoolean(r.libraryLoader);
112
88
  if (libraryLoader !== undefined) out.libraryLoader = libraryLoader;
113
89
  if (typeof r.subSampling === "object" && r.subSampling !== null) {
@@ -142,7 +118,8 @@ export async function loadSettings(): Promise<PersistedSettings> {
142
118
  const r = raw as Record<string, unknown>;
143
119
  return {
144
120
  config: validateConfig(r.config),
145
- worker: typeof r.worker === "string" ? r.worker : undefined,
121
+ // `worker` is the pre-rename key still read so an existing pin survives the upgrade.
122
+ llm: validateString(r.llm) ?? validateString(r.worker),
146
123
  };
147
124
  } catch {
148
125
  return { config: {} };
@@ -167,7 +144,6 @@ export function mergeConfig(partial: Partial<RlmConfig>): RlmConfig {
167
144
  ...partial,
168
145
  subSampling: { ...DEFAULT_CONFIG.subSampling, ...partial.subSampling },
169
146
  rootSampling: Object.freeze({ ...DEFAULT_CONFIG.rootSampling, ...partial.rootSampling }),
170
- ...(partial.runLog ? { runLog: Object.freeze({ ...DEFAULT_CONFIG.runLog, ...partial.runLog }) } : {}),
171
147
  };
172
148
  }
173
149
 
@@ -31,7 +31,7 @@ const execFileP = promisify(execFile);
31
31
  /** Single-file sources above this must use open() + llm_query_chunked in the REPL. */
32
32
  export const MAX_LIBRARY_FILE_BYTES = 8 * 1024 * 1024;
33
33
 
34
- /** Legacy catch-all prefix for pre-namespace string sidecars — never an identity key. */
34
+ /** Catch-all prefix for a raw string payload with no namespace — never an identity key. */
35
35
  const LEGACY_UNKNOWN_PREFIX = "lib/unknown/";
36
36
 
37
37
  export interface LibrarySource {
@@ -150,23 +150,100 @@ export function namespaceLibraryFiles(
150
150
  return namespaceLibraryFilesWithChars(payload, sourceId).files;
151
151
  }
152
152
 
153
+ /** The one `lib/<id>/` matcher. Never re-declare this regex; use the helpers below. */
154
+ const LIB_PREFIX_RE = /^(lib\/[^/]+\/)/;
155
+
156
+ /** Narrow an unknown context entry to a ContextFile. Type guard, never a cast. */
157
+ export function isContextFile(entry: unknown): entry is ContextFile {
158
+ if (entry === null || typeof entry !== "object") return false;
159
+ return "path" in entry && typeof entry.path === "string"
160
+ && "content" in entry && typeof entry.content === "string";
161
+ }
162
+
163
+ /** `path` of a context entry, or undefined when the entry is not file-shaped. */
164
+ export function contextEntryPath(entry: unknown): string | undefined {
165
+ return isContextFile(entry) ? entry.path : undefined;
166
+ }
167
+
168
+ /** The `lib/<id>/` prefix owning this path, or undefined. Skips the legacy catch-all. */
169
+ function libPrefixOf(path: string): string | undefined {
170
+ const prefix = LIB_PREFIX_RE.exec(path)?.[1];
171
+ // `lib/unknown/` is the legacy catch-all: never treat it as an identity.
172
+ return prefix === undefined || prefix === LEGACY_UNKNOWN_PREFIX ? undefined : prefix;
173
+ }
174
+
153
175
  /** First `lib/<id>/` prefix found in the payload, or undefined. Skips `lib/unknown/`. */
154
176
  export function payloadPrefix(payload: readonly unknown[]): string | undefined {
155
177
  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];
178
+ const path = contextEntryPath(payload[i]);
179
+ if (path === undefined) continue;
180
+ const prefix = libPrefixOf(path);
181
+ if (prefix !== undefined) return prefix;
163
182
  }
164
183
  return undefined;
165
184
  }
166
185
 
167
186
  /**
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).
187
+ * Every distinct `lib/<id>/` prefix present in a context payload.
188
+ *
189
+ * The loaded-prefix set in bridge/library.ts is a CACHE of this — derived state, never
190
+ * independent state, so it may only be cleared by re-deriving it from the live payload.
191
+ */
192
+ export function libraryPrefixesIn(context: unknown): readonly string[] {
193
+ if (!Array.isArray(context)) return Object.freeze([]);
194
+ const seen = new Set<string>();
195
+ for (let i = 0; i < context.length; i++) {
196
+ const path = contextEntryPath(context[i]);
197
+ if (path === undefined) continue;
198
+ const prefix = libPrefixOf(path);
199
+ if (prefix !== undefined) seen.add(prefix);
200
+ }
201
+ return Object.freeze(Array.from(seen));
202
+ }
203
+
204
+ export interface FilteredContext {
205
+ readonly files: readonly ContextFile[];
206
+ /** Prefixes that selected zero files — the caller decides whether that is fatal. */
207
+ readonly unmatched: readonly string[];
208
+ }
209
+
210
+ /**
211
+ * Narrow a context payload to entries under any of `prefixes` (plain prefix match, no globs).
212
+ *
213
+ * Backs `rlm_query(prompt, paths=[…])`. Prefix-only is deliberate: the sandbox's own filters use
214
+ * Python `fnmatch`, which has no host-side equivalent here, and a subtree prefix is what callers
215
+ * actually want — the child can still `search()` inside the slice.
216
+ */
217
+ export function filterContextByPaths(context: unknown, prefixes: readonly string[]): FilteredContext {
218
+ if (!Array.isArray(context) || prefixes.length === 0) {
219
+ return Object.freeze({ files: Object.freeze([]), unmatched: Object.freeze(Array.from(prefixes)) });
220
+ }
221
+ const hit = new Array<boolean>(prefixes.length).fill(false);
222
+ const out = new Array<ContextFile>(context.length); // pre-allocated, trimmed once below
223
+ let n = 0;
224
+ for (let i = 0; i < context.length; i++) {
225
+ const entry: unknown = context[i];
226
+ if (!isContextFile(entry)) continue;
227
+ for (let p = 0; p < prefixes.length; p++) {
228
+ if (!entry.path.startsWith(prefixes[p])) continue;
229
+ hit[p] = true;
230
+ out[n++] = entry;
231
+ break;
232
+ }
233
+ }
234
+ out.length = n;
235
+ const unmatched = new Array<string>(prefixes.length); // pre-allocated, no .push()
236
+ let u = 0;
237
+ for (let p = 0; p < prefixes.length; p++) {
238
+ if (!hit[p]) unmatched[u++] = prefixes[p];
239
+ }
240
+ unmatched.length = u;
241
+ return Object.freeze({ files: Object.freeze(out), unmatched: Object.freeze(unmatched) });
242
+ }
243
+
244
+ /**
245
+ * Append a library payload into an existing list context.
246
+ * Skips a payload whose path prefix is already present, so a repeat load is a no-op.
170
247
  */
171
248
  export function mergeLibraryIntoContext(base: unknown, libraryPayload: unknown): unknown {
172
249
  if (!Array.isArray(base)) return base;
@@ -175,12 +252,8 @@ export function mergeLibraryIntoContext(base: unknown, libraryPayload: unknown):
175
252
  const prefix = payloadPrefix(libraryPayload);
176
253
  if (prefix !== undefined) {
177
254
  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
- }
255
+ const path = contextEntryPath(base[i]);
256
+ if (path !== undefined && path.startsWith(prefix)) return base; // already present
184
257
  }
185
258
  }
186
259
  const merged = new Array<unknown>(base.length + libraryPayload.length);
@@ -189,7 +262,7 @@ export function mergeLibraryIntoContext(base: unknown, libraryPayload: unknown):
189
262
  return merged;
190
263
  }
191
264
  if (typeof libraryPayload === "string") {
192
- // Legacy string sidecars: wrap once under an unknown prefix.
265
+ // Raw string payload: wrap once under the unknown prefix.
193
266
  return mergeLibraryIntoContext(base, namespaceLibraryFiles(libraryPayload, "unknown"));
194
267
  }
195
268
  return base;