@hicaru/pi-rlm 0.2.2 → 0.3.1

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 (50) hide show
  1. package/README.md +38 -16
  2. package/README.ru.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +1 -1
  7. package/src/config/defaults.ts +2 -1
  8. package/src/config/settings.ts +5 -2
  9. package/src/context/anydoc.ts +67 -0
  10. package/src/context/listing.ts +70 -0
  11. package/src/context/md-cache.ts +112 -0
  12. package/src/context/merge.ts +97 -0
  13. package/src/context/namespace.ts +180 -0
  14. package/src/context/resolve.ts +122 -0
  15. package/src/context/source-dir.ts +166 -0
  16. package/src/context/source-doc.ts +71 -0
  17. package/src/context/source-git.ts +51 -0
  18. package/src/context/source-text.ts +45 -0
  19. package/src/context/types.ts +88 -0
  20. package/src/context/walk.ts +250 -0
  21. package/src/core/engine.ts +15 -19
  22. package/src/core/types.ts +7 -2
  23. package/src/index.ts +119 -47
  24. package/src/mode/rlm-mode.ts +5 -4
  25. package/src/mode/subagent.ts +68 -0
  26. package/src/prompts/glossary.ts +31 -28
  27. package/src/prompts/native.ts +4 -4
  28. package/src/prompts/system.ts +2 -2
  29. package/src/sandbox/context-file.ts +4 -4
  30. package/src/sandbox/interrupts.ts +25 -10
  31. package/src/sandbox/protocol.ts +13 -7
  32. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  34. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  35. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  36. package/src/sandbox/py/guards.py +8 -1
  37. package/src/sandbox/py/hostio.py +57 -0
  38. package/src/sandbox/py/retrieval.py +1 -1
  39. package/src/sandbox/py/tasks.py +17 -4
  40. package/src/sandbox/py/worker.py +71 -52
  41. package/src/sandbox/sandbox-manager.ts +18 -16
  42. package/src/sandbox/sandbox.ts +9 -2
  43. package/src/text/tokens.ts +3 -3
  44. package/src/tool/repl-details.ts +1 -1
  45. package/src/tool/repl-tool.ts +31 -19
  46. package/src/tool/rlm-tool.ts +1 -1
  47. package/src/ui/config-panel.ts +8 -4
  48. package/src/bridge/library.ts +0 -190
  49. package/src/context/library-context.ts +0 -339
  50. package/src/context/repomix-context.ts +0 -204
@@ -25,7 +25,7 @@ export function estimateMessageTokens(messages: { content: string }[]): number {
25
25
  * Character length of one context entry. `ContextFile`-shaped entries report their content
26
26
  * length; anything else falls back to its serialized form.
27
27
  *
28
- * Deliberately does NOT import `isContextFile` from context/library-context.ts: that module
28
+ * Deliberately does NOT import `isContextFile` from context/namespace.ts: that module
29
29
  * imports `estimateTokens` from here, so the reverse import would be a cycle. `in`-narrowing
30
30
  * needs no type guard and no cast.
31
31
  */
@@ -74,8 +74,8 @@ const isTokenizedEntry = (v: unknown): v is { readonly tokens: number } =>
74
74
  typeof v === "object" && v !== null && typeof (v as { readonly tokens?: unknown }).tokens === "number";
75
75
 
76
76
  /** Per-file token distribution for a context payload; `undefined` for plain strings or empty arrays.
77
- * Handles both serialized ContextFile[] (flat array from serializeForSandbox) and raw ContextBundle
78
- * objects ({ files: [...] }) so callers don't need to know which form they received. */
77
+ * Handles both a flat ContextFile[] and a raw bundle object ({ files: [...] }) so callers
78
+ * don't need to know which form they received. */
79
79
  export function contextSizeStats(context: unknown): ContextSizeStats | undefined {
80
80
  // Normalise to a flat entry list: accept either a direct array or an object with a .files array.
81
81
  const entries: readonly unknown[] = Array.isArray(context)
@@ -2,7 +2,7 @@
2
2
  * ReplDetails — structured payload for the repl() tool's AgentToolResult<T>.
3
3
  *
4
4
  * Mirrors RlmDetails but scoped to a single code execution. Sub-calls (llm_query,
5
- * rlm_query, load_library) triggered during sandbox execution are
5
+ * rlm_query, add_context) triggered during sandbox execution are
6
6
  * accumulated into the subcalls array for tree rendering.
7
7
  */
8
8
 
@@ -20,8 +20,8 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
20
20
  import { Text } from "@earendil-works/pi-tui";
21
21
  import type { Model, Usage, Api } from "@earendil-works/pi-ai";
22
22
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
23
- import { buildLibraryHandler } from "../bridge/library.ts";
24
- import { libraryPrefixesIn } from "../context/library-context.ts";
23
+ import { buildAddContextHandler, type AddContextHandlerBundle } from "../bridge/add-context.ts";
24
+ import { contextPrefixesIn } from "../context/namespace.ts";
25
25
  import type { SubcallGates } from "../util/concurrency.ts";
26
26
  import { LimitGuard, limitsFromConfig } from "../core/limits.ts";
27
27
  import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
@@ -115,8 +115,13 @@ export interface ReplToolDeps {
115
115
  readonly signal?: AbortSignal;
116
116
  readonly onUsage?: (usage: Usage, role: "sub") => void;
117
117
  readonly ensureContext?: () => Promise<void>;
118
- /** Register a reset hook for sandbox death/dispose (e.g. load_library slot counter). */
118
+ /** Register a reset hook for sandbox death/dispose (e.g. add_context prefix cache). */
119
119
  readonly registerDiscardHook?: (reset: () => void) => void;
120
+ /**
121
+ * Hands the live add_context bundle to the extension so the cwd seed can
122
+ * markLoaded("") / markSeededCwd(abs) — without this, add_context(".") doubles the tree.
123
+ */
124
+ readonly registerContextBundle?: (bundle: AddContextHandlerBundle) => void;
120
125
  }
121
126
 
122
127
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
@@ -159,15 +164,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
159
164
  signal,
160
165
  onUsage,
161
166
  runChild,
162
- // The session sandbox's context is the child's world. Read lazily so a load_library from an
167
+ // The session sandbox's context is the child's world. Read lazily so an add_context from an
163
168
  // earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
164
169
  // fire: execute() awaits ensureContext() before getOrCreate().
165
170
  getChildContext: () => sandboxManager.contextPayload ?? undefined,
166
171
  trackDetached: (task) => background.track(task),
167
172
  });
168
173
 
169
- const libraryBundle = getConfig().libraryLoader
170
- ? buildLibraryHandler({
174
+ const contextBundle = getConfig().contextLoader
175
+ ? buildAddContextHandler({
171
176
  getCwd: () => sessionCwd,
172
177
  getEmitter: () => bridgeState.currentEmitter,
173
178
  // Refuse pre-flight whatever the worker would reject, so host idempotency is never
@@ -177,14 +182,20 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
177
182
  signal,
178
183
  // Keep the manager's replay copy in step with the worker's live `context`, and with it
179
184
  // whatever a child spawned after this load will inherit.
180
- onLoaded: (payload) => { sandboxManager.appendLibrary(payload); },
185
+ onLoaded: (payload) => { sandboxManager.appendContext(payload); },
181
186
  })
182
187
  : undefined;
183
- if (libraryBundle) {
188
+ if (contextBundle) {
184
189
  // Re-derive the loaded-prefix cache from the payload that will actually be replayed —
185
- // clearing it outright would make the host re-clone a library the recreated worker already has.
186
- const bundle = libraryBundle;
187
- deps.registerDiscardHook?.(() => bundle.reset(libraryPrefixesIn(sandboxManager.contextPayload)));
190
+ // clearing it outright would make the host re-clone a source the recreated worker already has.
191
+ // Re-plant the cwd sentinel if the seed is still in the payload (un-prefixed files).
192
+ const bundle = contextBundle;
193
+ deps.registerDiscardHook?.(() => {
194
+ const prefixes = contextPrefixesIn(sandboxManager.contextPayload);
195
+ bundle.reset(prefixes);
196
+ if (bundle.seededCwd() !== undefined) bundle.markLoaded("");
197
+ });
198
+ deps.registerContextBundle?.(contextBundle);
188
199
  }
189
200
 
190
201
  return {
@@ -192,14 +203,15 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
192
203
  label: "REPL",
193
204
  description:
194
205
  "PRIMARY tool for ALL repository reading and analysis (read/grep are disabled in RLM mode). " +
195
- "Persistent Python sandbox with every file pre-loaded in `context`. Locate first with the " +
196
- "free primitives search(query) / grep_context(pattern) / outline(path), then delegate the " +
197
- "semantic reading to map_files / llm_query / llm_query_batched / llm_query_chunked " +
198
- "(rlm_query for iterative sub-tasks) — stdout returned to you is hard-capped at 4K chars, " +
199
- "so printing file bodies is useless. Variables, imports, and the `answers`/`plan` memo " +
200
- "persist across calls. Also supports load_library.",
206
+ "Persistent Python sandbox with loaded files in `context` (starts empty; cwd seeds on first " +
207
+ "call). Locate first with the free primitives search(query) / grep_context(pattern) / " +
208
+ "outline(path), then delegate the semantic reading to map_files / llm_query / " +
209
+ "llm_query_batched / llm_query_chunked (rlm_query for iterative sub-tasks) — stdout " +
210
+ "returned to you is hard-capped at 4K chars, so printing file bodies is useless. " +
211
+ "Variables, imports, and the `answers`/`plan` memo persist across calls. Also supports " +
212
+ "add_context for external dirs/files/git URLs and document conversion.",
201
213
  promptSnippet:
202
- "repl: run Python in a persistent sandbox holding the whole repository in `context`; " +
214
+ "repl: run Python in a persistent sandbox holding loaded files in `context`; " +
203
215
  "search/grep_context/outline to locate, map_files/llm_query* to read.",
204
216
  promptGuidelines: [
205
217
  "In RLM mode, read the repository through `repl` only — `read`/`grep` and bash readers are blocked.",
@@ -271,7 +283,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
271
283
  await deps.ensureContext?.();
272
284
  await sandboxManager.getOrCreate({
273
285
  ...subcallHandlers,
274
- ...(libraryBundle?.handlers ?? {}),
286
+ ...(contextBundle?.handlers ?? {}),
275
287
  });
276
288
 
277
289
  // Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
@@ -31,7 +31,7 @@ const CALL_PREVIEW_CHARS = 80;
31
31
 
32
32
  export const RlmToolParams = Object.freeze(Type.Object({
33
33
  prompt: Type.String({ description: "The task or question for the RLM engine" }),
34
- context: Type.Optional(Type.String({ description: "Optional context. If omitted, repo is auto-packed via repomix." })),
34
+ context: Type.Optional(Type.String({ description: "Optional context. If omitted, the working directory is packed into context." })),
35
35
  }));
36
36
 
37
37
  // ── Rendering helpers ──
@@ -20,7 +20,8 @@ const CHOICES = Object.freeze({
20
20
  rootSamplingMaxTokens: Object.freeze(["4096", "8192", "16384", "32768"]),
21
21
  sandboxInitTimeoutMs: Object.freeze(["10000", "30000", "60000", "120000"]),
22
22
  requestTimeoutMs: Object.freeze(["2", "5", "10", "20"]),
23
- libraryLoader: Object.freeze(["on", "off"]),
23
+ contextLoader: Object.freeze(["on", "off"]),
24
+ autoSeedCwd: Object.freeze(["on", "off"]),
24
25
  });
25
26
 
26
27
  function item(id: string, label: string, currentValue: string, values: readonly string[], description: string): SettingItem {
@@ -49,8 +50,10 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
49
50
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
50
51
  item("sandboxInitTimeoutMs", "Sandbox init timeout", String(config.sandboxInitTimeoutMs), CHOICES.sandboxInitTimeoutMs, "How long to wait for the Python worker to start."),
51
52
  item("requestTimeoutMs", "Sandbox request timeout (min)", String(Math.round(config.requestTimeoutMs / 60_000)), CHOICES.requestTimeoutMs, "Parent-side watchdog per sandbox request; on breach the Python worker is killed."),
52
- item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
53
- "Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
53
+ item("contextLoader", "Context loader", config.contextLoader ? "on" : "off", CHOICES.contextLoader,
54
+ "Allow add_context() to pull an external dir, file, document, or git repo into context."),
55
+ item("autoSeedCwd", "Auto-seed cwd", config.autoSeedCwd ? "on" : "off", CHOICES.autoSeedCwd,
56
+ "Seed the working directory into context on the first repl() call (otherwise starts empty)."),
54
57
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
55
58
  ];
56
59
 
@@ -104,7 +107,8 @@ export function applySetting(config: RlmConfig, id: string, value: string): RlmC
104
107
  return Object.freeze({ ...config, rootSampling: Object.freeze({ ...config.rootSampling, maxTokens: Number(value) }) });
105
108
  case "sandboxInitTimeoutMs": return Object.freeze({ ...config, sandboxInitTimeoutMs: Number(value) });
106
109
  case "requestTimeoutMs": return Object.freeze({ ...config, requestTimeoutMs: Number(value) * 60_000 });
107
- case "libraryLoader": return Object.freeze({ ...config, libraryLoader: value === "on" });
110
+ case "contextLoader": return Object.freeze({ ...config, contextLoader: value === "on" });
111
+ case "autoSeedCwd": return Object.freeze({ ...config, autoSeedCwd: value === "on" });
108
112
  default: return config;
109
113
  }
110
114
  }
@@ -1,190 +0,0 @@
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>/) and returns the
5
- * payload for the worker to append into the single `context` list.
6
- *
7
- * Idempotency is host-side: re-loading a source that was already packed does not re-clone or
8
- * re-pack, and the prefix set is the only state that decides it.
9
- *
10
- * Late-bound deps (getCwd / getEmitter) keep a single handler closure correct
11
- * across native repl() calls — getOrCreate only installs handlers at spawn.
12
- */
13
-
14
- import type { RlmEmitter } from "../tool/rlm-events.ts";
15
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
16
- import {
17
- libraryNamespace,
18
- resolveLibrarySource,
19
- } from "../context/library-context.ts";
20
- import { previewText } from "../text/preview.ts";
21
-
22
- export interface LibraryBridgeOpts {
23
- /** Fixed cwd (headless). Prefer getCwd when the sandbox outlives a single invocation. */
24
- readonly cwd?: string;
25
- /** Late-bound cwd (native mode — sandbox handlers outlive a single repl()). */
26
- readonly getCwd?: () => string;
27
- readonly emitter?: RlmEmitter;
28
- /** Native mode: read the live emitter each call. */
29
- readonly getEmitter?: () => RlmEmitter | null | undefined;
30
- readonly parentId?: string;
31
- readonly signal?: AbortSignal;
32
- /** Prefixes already present in context — seeds host-side idempotency after a sandbox restart. */
33
- readonly loadedPrefixes?: readonly string[];
34
- /**
35
- * The live context this sandbox holds. Read to refuse pre-flight exactly what the worker's
36
- * `_append_library` would reject, before any prefix is committed.
37
- */
38
- readonly getContext?: () => unknown;
39
- /**
40
- * Post-load hook. The engine grows its live context here; native mode grows
41
- * SandboxManager.contextPayload.
42
- */
43
- readonly onLoaded?: (payload: unknown) => void | Promise<void>;
44
- }
45
-
46
- export interface LibraryHandlerBundle {
47
- readonly handlers: Pick<SubLlmHandlers, "loadLibrary">;
48
- /**
49
- * Reset the loaded-prefix cache (call when the sandbox is
50
- * discarded and will re-spawn).
51
- *
52
- * `keep` re-seeds the cache from the payload that will be replayed into the fresh worker.
53
- * `loaded` is a CACHE of `libraryPrefixesIn(context)`, never independent state, so it may only
54
- * be cleared by re-deriving it — clearing it outright would make the host re-clone and re-pack
55
- * a library the recreated worker already holds.
56
- */
57
- readonly reset: (keep?: readonly string[]) => void;
58
- /** Prefixes loaded in this sandbox lifetime (for tests). */
59
- readonly loadedPrefixes: () => ReadonlySet<string>;
60
- }
61
-
62
- /**
63
- * JS runtime kind → the Python type name worker.py reports, so both sides emit exactly one
64
- * message for the same refusal. Covers every shape a context payload can take after JSON
65
- * transport; anything else is a plain object, which `json.load` materializes as a dict.
66
- */
67
- const PY_TYPE_NAME: Readonly<Record<string, string>> = Object.freeze({
68
- string: "str", boolean: "bool", number: "int", bigint: "int", undefined: "None",
69
- });
70
-
71
- function pythonKindOf(value: unknown): string {
72
- if (value === null) return "None"; // matches worker.py's `if ctx is not None else "None"`
73
- return PY_TYPE_NAME[typeof value] ?? "dict";
74
- }
75
-
76
- /**
77
- * Refusal messages shared with worker.py `_append_library`. The worker is the backstop; the host
78
- * pre-flights the same two conditions so it never commits a prefix for an append that
79
- * will be rejected. Keep the wording identical — a comment in worker.py points back here.
80
- */
81
- const LIST_CONTEXT_REQUIRED = (kind: string): string =>
82
- `load_library requires list context (file bundle); got ${kind}`;
83
- const NO_FILES_PRODUCED = "load_library produced no files";
84
-
85
- export function buildLibraryHandler(opts: LibraryBridgeOpts): LibraryHandlerBundle {
86
- /** Prefixes already loaded in this sandbox — mirrors the worker's context state. */
87
- const loaded = new Set<string>(opts.loadedPrefixes ?? []);
88
- return {
89
- reset: (keep) => {
90
- const seed = keep ?? opts.loadedPrefixes ?? [];
91
- loaded.clear();
92
- for (const prefix of seed) loaded.add(prefix);
93
- },
94
- loadedPrefixes: () => loaded,
95
- handlers: {
96
- async loadLibrary(source, depth) {
97
- const emitter = opts.getEmitter?.() ?? opts.emitter;
98
- const cwd = opts.getCwd?.() ?? opts.cwd;
99
- if (cwd === undefined || cwd === "") {
100
- throw new Error("load_library: no cwd configured");
101
- }
102
- const id = emitter?.emitSubcallCreated({
103
- kind: "tool", parentId: opts.parentId,
104
- label: "load_library",
105
- args: previewText(source, 80),
106
- depth,
107
- });
108
- try {
109
- // Pre-flight the worker's own refusal: a non-list context cannot be appended to, and
110
- // committing a prefix for it would make the NEXT load lie with already_loaded.
111
- const current = opts.getContext?.();
112
- if (current !== undefined && !Array.isArray(current)) {
113
- throw new Error(LIST_CONTEXT_REQUIRED(pythonKindOf(current)));
114
- }
115
-
116
- // Cheap pre-check BEFORE cloning/packing: same namespace ⇒ nothing to do.
117
- const { sourceId: preId, pathPrefix: prefix } = libraryNamespace(source, cwd);
118
- if (loaded.has(prefix)) {
119
- if (id) {
120
- emitter?.emitSubcallUpdated({
121
- id,
122
- status: "done",
123
- resultPreview: `already loaded (${prefix}*)`,
124
- });
125
- }
126
- return {
127
- payload: Object.freeze([]),
128
- files: 0,
129
- chars: 0,
130
- sourceId: preId,
131
- pathPrefix: prefix,
132
- alreadyLoaded: true,
133
- };
134
- }
135
-
136
- const resolved = await resolveLibrarySource(source, cwd, opts.signal);
137
- if (!resolved.ok) throw new Error(resolved.error);
138
- const { payload, files, chars, sourceId, pathPrefix } = resolved.value;
139
- // The worker's other refusal, pre-flighted for the same reason.
140
- if (payload.length === 0) throw new Error(NO_FILES_PRODUCED);
141
-
142
- // Race: another concurrent load of the same prefix finished while we packed.
143
- if (loaded.has(pathPrefix)) {
144
- if (id) {
145
- emitter?.emitSubcallUpdated({
146
- id,
147
- status: "done",
148
- resultPreview: `already loaded (${pathPrefix}*)`,
149
- });
150
- }
151
- return {
152
- payload: Object.freeze([]),
153
- files: 0,
154
- chars: 0,
155
- sourceId,
156
- pathPrefix,
157
- alreadyLoaded: true,
158
- };
159
- }
160
-
161
- // Mark loaded only after the host has grown its own copy of the context.
162
- if (opts.onLoaded) {
163
- await opts.onLoaded(payload);
164
- }
165
- loaded.add(pathPrefix);
166
-
167
- if (id) {
168
- emitter?.emitSubcallUpdated({
169
- id,
170
- status: "done",
171
- resultPreview:
172
- `+${files} file(s) → context (${pathPrefix}*, ${chars.toLocaleString()} chars)`,
173
- });
174
- }
175
- return {
176
- payload,
177
- files,
178
- chars,
179
- sourceId,
180
- pathPrefix,
181
- alreadyLoaded: false,
182
- };
183
- } catch (err) {
184
- if (id) emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
185
- throw err; // serviceInterrupt catch → {error} reply → "Error: …" in the REPL
186
- }
187
- },
188
- },
189
- };
190
- }