@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
@@ -10,21 +10,27 @@ import type { WorkerInterrupt } from "./protocol.ts";
10
10
  import { writeContextTempFile } from "./context-file.ts";
11
11
  import { errorMessage, formatError } from "../util/errors.ts";
12
12
 
13
- /** Result of a host-side library pack requested by `load_library`. */
14
- export interface LibraryLoadResult {
15
- readonly payload: unknown; // always ContextFile[] under lib/<id>/
13
+ /** Result of a host-side pack requested by `add_context`. */
14
+ export interface AddContextResult {
15
+ readonly payload: unknown; // always ContextFile[] under ctx/<id>/ (or un-prefixed for cwd)
16
16
  readonly files?: number;
17
17
  readonly chars: number;
18
18
  readonly sourceId: string;
19
19
  readonly pathPrefix: string;
20
- /** Host already has this library — no pack, empty payload. */
20
+ /** Host already has this source — no pack, empty payload. */
21
21
  readonly alreadyLoaded?: boolean;
22
+ /** Document-type files in the payload (fresh + cache hits). */
23
+ readonly documents?: number;
24
+ /** Documents freshly converted this call (cache hits excluded). */
25
+ readonly converted?: number;
26
+ /** Paths skipped during packing (model-facing). */
27
+ readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
22
28
  }
23
29
 
24
30
  /**
25
31
  * Per-interrupt routing context for the sub-LLM handlers.
26
32
  *
27
- * Only the four sub-call kinds can be spawned, so only they carry it; load_library is
33
+ * Only the four sub-call kinds can be spawned, so only they carry it; add_context is
28
34
  * always synchronous within one exec.
29
35
  */
30
36
  export interface SubcallOpts {
@@ -43,7 +49,7 @@ export interface SubLlmHandlers {
43
49
  llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
44
50
  rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
45
51
  rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
46
- loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
52
+ addContext(source: string, depth: number): Promise<AddContextResult>;
47
53
  }
48
54
 
49
55
  /** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
@@ -65,7 +71,7 @@ export const REJECT: SubLlmHandlers = {
65
71
  llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
66
72
  rlmQuery: async () => formatError("sub-LLM bridge not configured"),
67
73
  rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
68
- loadLibrary: async () => { throw new Error("load_library not configured"); },
74
+ addContext: async () => { throw new Error("add_context not configured"); },
69
75
  };
70
76
 
71
77
  /** Body of a reply frame — the union of every handler's payload shape. */
@@ -79,6 +85,9 @@ export interface ReplyBody {
79
85
  source_id?: string;
80
86
  path_prefix?: string;
81
87
  already_loaded?: boolean;
88
+ documents?: number;
89
+ converted?: number;
90
+ skipped?: readonly { readonly path: string; readonly reason: string }[];
82
91
  error?: string;
83
92
  }
84
93
 
@@ -114,8 +123,8 @@ export async function serviceInterrupt(
114
123
  } else if (msg.type === "rlm_query_batched") {
115
124
  const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
116
125
  reply(msg.rid, { responses });
117
- } else if (msg.type === "load_library") {
118
- const lib = await h.loadLibrary(msg.source ?? "", d);
126
+ } else if (msg.type === "add_context") {
127
+ const lib = await h.addContext(msg.source ?? "", d);
119
128
  if (lib.alreadyLoaded) {
120
129
  // No temp file — worker short-circuits on already_loaded.
121
130
  reply(msg.rid, {
@@ -124,10 +133,13 @@ export async function serviceInterrupt(
124
133
  chars: lib.chars,
125
134
  source_id: lib.sourceId,
126
135
  path_prefix: lib.pathPrefix,
136
+ documents: lib.documents ?? 0,
137
+ converted: lib.converted ?? 0,
138
+ skipped: lib.skipped,
127
139
  });
128
140
  } else {
129
141
  const { path, json: isJson } = await writeContextTempFile(lib.payload);
130
- // Worker reads then unlinks (worker._load_library). Host must not unlink here —
142
+ // Worker reads then unlinks (worker._add_context). Host must not unlink here —
131
143
  // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
132
144
  reply(msg.rid, {
133
145
  path,
@@ -136,6 +148,9 @@ export async function serviceInterrupt(
136
148
  chars: lib.chars,
137
149
  source_id: lib.sourceId,
138
150
  path_prefix: lib.pathPrefix,
151
+ documents: lib.documents ?? 0,
152
+ converted: lib.converted ?? 0,
153
+ skipped: lib.skipped,
139
154
  });
140
155
  }
141
156
  }
@@ -18,15 +18,21 @@ export interface LlmReply {
18
18
  readonly rid: string;
19
19
  readonly response?: string;
20
20
  readonly responses?: readonly string[];
21
- /** load_library reply: temp file with the packed payload (+ namespace metadata). */
21
+ /** add_context reply: temp file with the packed payload (+ namespace metadata). */
22
22
  readonly path?: string;
23
23
  readonly json?: boolean;
24
24
  readonly files?: number;
25
25
  readonly chars?: number;
26
26
  readonly source_id?: string;
27
27
  readonly path_prefix?: string;
28
- /** Host-side idempotency: library already loaded — no path payload. */
28
+ /** Host-side idempotency: source already loaded — no path payload. */
29
29
  readonly already_loaded?: boolean;
30
+ /** Document-type files in the payload (fresh + cache hits). */
31
+ readonly documents?: number;
32
+ /** Documents freshly converted this call (cache hits excluded). */
33
+ readonly converted?: number;
34
+ /** Paths skipped during packing (binary, no-converter, …). */
35
+ readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
30
36
  readonly error?: string;
31
37
  }
32
38
 
@@ -56,7 +62,7 @@ export type InterruptKind =
56
62
  | "llm_query_batched"
57
63
  | "rlm_query"
58
64
  | "rlm_query_batched"
59
- | "load_library";
65
+ | "add_context";
60
66
 
61
67
  interface InterruptBase {
62
68
  readonly rid: string;
@@ -88,8 +94,8 @@ interface BatchedPromptInterrupt extends InterruptBase {
88
94
  readonly paths?: readonly string[];
89
95
  }
90
96
 
91
- export interface LoadLibraryInterrupt extends InterruptBase {
92
- readonly type: "load_library";
97
+ export interface AddContextInterrupt extends InterruptBase {
98
+ readonly type: "add_context";
93
99
  readonly source?: string;
94
100
  }
95
101
 
@@ -97,7 +103,7 @@ export interface LoadLibraryInterrupt extends InterruptBase {
97
103
  export type WorkerInterrupt =
98
104
  | PromptInterrupt
99
105
  | BatchedPromptInterrupt
100
- | LoadLibraryInterrupt;
106
+ | AddContextInterrupt;
101
107
 
102
108
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
103
109
 
@@ -106,7 +112,7 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
106
112
  "llm_query_batched",
107
113
  "rlm_query",
108
114
  "rlm_query_batched",
109
- "load_library",
115
+ "add_context",
110
116
  ]));
111
117
 
112
118
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -19,6 +19,13 @@ import sys
19
19
  from contextlib import contextmanager
20
20
  from typing import Any
21
21
 
22
+ from hostio import pin_stdio_utf8
23
+
24
+ # Pin UTF-8 before anything reads or writes a frame. On Windows these three default to the
25
+ # locale encoding (cp1252), while the JSONL protocol Node speaks is UTF-8 both ways — see
26
+ # issue #7. reconfigure() mutates in place, so the REAL_* captures below are the same objects.
27
+ pin_stdio_utf8()
28
+
22
29
  # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
23
30
  # All protocol writes must go to the real stdout even while user code's prints are captured.
24
31
  REAL_STDOUT = sys.stdout
@@ -86,7 +93,7 @@ RESERVED = frozenset(
86
93
  "spawn", "rlm_await", "rlm_await_all",
87
94
  "map_files", "llm_map_reduce",
88
95
  "search", "grep_context", "outline",
89
- "load_library",
96
+ "add_context",
90
97
  "SHOW_VARS", "answer", "context",
91
98
  }
92
99
  )
@@ -0,0 +1,57 @@
1
+ """Host↔worker transport, pinned to UTF-8.
2
+
3
+ Every byte the host hands this worker is UTF-8: the context temp files come from Node's
4
+ FileHandle.write(string) (default utf8, see sandbox/context-file.ts) and the JSONL frames come
5
+ off a pipe Node writes the same way. Python does NOT match that — text I/O defaults to the
6
+ locale encoding, which is cp1252 on a Western Windows install. That mismatch is issue #7: a
7
+ packed repo holding any non-ASCII byte raised UnicodeDecodeError before the REPL ever ran.
8
+
9
+ Encoding is therefore never left to the locale here. Interpreter-wide UTF-8 mode (-X utf8=1,
10
+ set by the host in sandbox.ts) covers model-written open(); these two functions cover the
11
+ scaffold's own I/O, and they win outright — PYTHONIOENCODING overrides -X utf8=1, and
12
+ reconfigure() overrides PYTHONIOENCODING.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import sys
20
+ from typing import Any
21
+
22
+ ENCODING = "utf-8"
23
+
24
+
25
+ def read_host_payload(path: str, is_json: bool) -> Any:
26
+ """Read a payload the host serialized to a temp file.
27
+
28
+ Strict on decode errors by design. The host always writes UTF-8, so a failure here is a
29
+ transport bug, not bad input — and errors="replace" would hand the model silently
30
+ mojibake'd source code instead of surfacing the problem.
31
+
32
+ The explicit encoding= is intentionally redundant with -X utf8=1 under a normal spawn:
33
+ UTF-8 mode already makes the default encoding UTF-8. It still matters so a standalone
34
+ import of this module (or a worker started without -X utf8=1) cannot silently reintroduce
35
+ issue #7. phase-encoding.ts check 6 (AST / EncodingWarning) is the only automated guard
36
+ that encoding= is present — do not drop either side of that net.
37
+ """
38
+ with io.open(path, "r", encoding=ENCODING) as f:
39
+ return json.load(f) if is_json else f.read()
40
+
41
+
42
+ def pin_stdio_utf8() -> None:
43
+ """Force the three real streams to UTF-8, whatever the locale or PYTHONIOENCODING says.
44
+
45
+ The write side uses surrogatepass, not strict: model code can synthesize a lone surrogate
46
+ (a bad decode, a truncated pair), and json.dumps(ensure_ascii=False) would then raise
47
+ inside _send — killing the worker on a request it had already completed. The read side
48
+ uses replace so a corrupt frame comes back as a "bad json" error response instead of an
49
+ exception that escapes main()'s loop and takes the worker down (fail-soft I/O, AGENTS.md).
50
+ """
51
+ for stream, errors in (
52
+ (sys.stdin, "replace"),
53
+ (sys.stdout, "surrogatepass"),
54
+ (sys.stderr, "surrogatepass"),
55
+ ):
56
+ if isinstance(stream, io.TextIOWrapper): # not a TextIOWrapper under some test harnesses
57
+ stream.reconfigure(encoding=ENCODING, errors=errors)
@@ -46,7 +46,7 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
46
46
 
47
47
 
48
48
  _INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
49
- _INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
49
+ _INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
50
50
  _SNIPPET_CHARS = 400
51
51
  _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
52
52
  _BM25_K1 = 1.2
@@ -41,14 +41,27 @@ def _reduce_batch(n: int):
41
41
  return reduce
42
42
 
43
43
 
44
- def _reduce_chunked(sizes: list[int]):
45
- """Concatenate several llm_query_batched replies back into one flat chunk list."""
44
+ def _reduce_chunked(sizes: list[int], drop_empty: bool = True):
45
+ """Concatenate several llm_query_batched replies back into one flat chunk list.
46
+
47
+ drop_empty=True (llm_query_chunked): filter "" replies so results never degrade
48
+ to blank entries; the flattened list may then be shorter than the chunk count,
49
+ so callers must consume answers in order and never index-match a chunk to a slot.
50
+ drop_empty=False (map_files): keep "" placeholders because _reduce_map_files
51
+ regroups by position — dropping an empty reply there would shift every later
52
+ slice and merge file contents into the wrong paths.
53
+ """
46
54
  per = [_reduce_batch(n) for n in sizes]
47
55
 
48
56
  def reduce(replies: list[dict[str, Any]]) -> list[str]:
57
+ if len(replies) != len(per):
58
+ return [f"Error: chunk reply count mismatch ({len(replies)} != {len(per)})"] * sum(sizes)
49
59
  out: list[str] = []
50
60
  for red, rep in zip(per, replies):
51
- out.extend(red([rep]))
61
+ if drop_empty:
62
+ out.extend(x for x in red([rep]) if x)
63
+ else:
64
+ out.extend(red([rep]))
52
65
  return out
53
66
  return reduce
54
67
 
@@ -59,7 +72,7 @@ def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
59
72
  A file larger than the per-prompt budget contributed several requests; its answers rejoin
60
73
  in order, which is what makes map_files a {path: answer} dict rather than a flat list.
61
74
  """
62
- flatten = _reduce_chunked(sizes)
75
+ flatten = _reduce_chunked(sizes, drop_empty=False)
63
76
 
64
77
  def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
65
78
  responses = flatten(replies)
@@ -7,9 +7,9 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
7
7
  Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
8
8
  Protocol (worker -> parent): {"id","ok",...result} # response to a request
9
9
  {"type":"llm_query"|"llm_query_batched"|"rlm_query"|
10
- "rlm_query_batched"|"load_library","rid",...}
10
+ "rlm_query_batched"|"add_context","rid",...}
11
11
  # mid-exec helper request
12
- When sandbox code calls llm_query/rlm_query/load_library, the worker writes a
12
+ When sandbox code calls llm_query/rlm_query/add_context, the worker writes a
13
13
  request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
14
14
  The parent services the request in-process (it holds API keys).
15
15
 
@@ -44,6 +44,7 @@ from guards import (
44
44
  REAL_STDERR as _REAL_STDERR,
45
45
  REAL_STDIN as _REAL_STDIN,
46
46
  )
47
+ from hostio import read_host_payload
47
48
  from retrieval import (
48
49
  _Bm25Index,
49
50
  _chunk_text,
@@ -113,7 +114,7 @@ class Worker:
113
114
  builtins = _SAFE_BUILTINS.copy()
114
115
  builtins["open"] = open
115
116
  self.ns = {"__builtins__": builtins, "__name__": "__main__"}
116
- self._context_payload: Any | None = None # pristine restore for the single `context` var
117
+ self._context_payload: Any = [] # empty list the only starting value that needs no bootstrap branch
117
118
  self._nudged: set[str] = set()
118
119
  self._index: _Bm25Index | None = None
119
120
  self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
@@ -144,7 +145,7 @@ class Worker:
144
145
  ns["answers"] = {}
145
146
  if not isinstance(ns.get("plan"), dict):
146
147
  ns["plan"] = {}
147
- ns["load_library"] = self._load_library
148
+ ns["add_context"] = self._add_context
148
149
  ns["SHOW_VARS"] = self._show_vars
149
150
  if not isinstance(ns.get("answer"), _AnswerDict):
150
151
  cur = ns.get("answer")
@@ -157,9 +158,8 @@ class Worker:
157
158
  ns["answer"] = ans
158
159
  # Single context variable (RLM paper: the context lives in the environment and
159
160
  # the model may transform it in place). Re-inject only if the model deleted the
160
- # name entirely; mutations and re-binds persist within the run.
161
- if self._context_payload is not None:
162
- ns.setdefault("context", self._context_payload)
161
+ # name entirely; mutations and re-binds persist within the run. Always a list.
162
+ ns.setdefault("context", self._context_payload)
163
163
  # Scrub any legacy context_N names so the model never sees multi-slot APIs.
164
164
  for k in list(ns.keys()):
165
165
  if k != "context" and _CONTEXT_NAME.match(k):
@@ -412,7 +412,7 @@ class Worker:
412
412
  """Build the BM25 index on first use; rebuild when `context` was replaced or resized.
413
413
 
414
414
  Identity+length is a cheap stamp that catches the two ways context actually changes:
415
- load_library() extending the list, and the model re-binding the name. In-place edits
415
+ add_context() extending the list, and the model re-binding the name. In-place edits
416
416
  that preserve length are not detected — documented, and rare in practice.
417
417
  """
418
418
  ctx = self.ns.get("context")
@@ -556,23 +556,24 @@ class Worker:
556
556
  def _rlm_query(self, prompt: str, model: str | None = None, paths=None) -> str:
557
557
  return self._await_task(self._start_rlm_query(prompt, model, paths))
558
558
 
559
- def _load_library(self, source: str) -> dict[str, Any] | str:
559
+ def _add_context(self, source: str) -> dict[str, Any] | str:
560
560
  """Pack an external dir/file/git-URL on the host and append it into `context`.
561
561
 
562
- Paths are namespaced under lib/<source_id>/ (host). Content is always in the
562
+ Paths are namespaced under ctx/<source_id>/ (host). Content is always in the
563
563
  single `context` list — never a new context_N variable.
564
564
  Host-side idempotency may return already_loaded without a payload path.
565
+ Documents (PDF/DOCX/…) are converted to Markdown on the host.
565
566
  """
566
- r = self._rpc("load_library", {"source": str(source)})
567
+ r = self._rpc("add_context", {"source": str(source)})
567
568
  if r.get("error"):
568
569
  return f"Error: {r['error']}"
569
570
  if r.get("already_loaded"):
570
- source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "lib"
571
- path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"lib/{source_id}/"
571
+ source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "ctx"
572
+ path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"ctx/{source_id}/"
572
573
  ctx = self.ns.get("context")
573
574
  ctx_len = len(ctx) if isinstance(ctx, list) else 0
574
575
  print(
575
- f"[rlm] load_library: already loaded {source_id} "
576
+ f"[rlm] add_context: already loaded {source_id} "
576
577
  f"(paths under {path_prefix}, context len={ctx_len})"
577
578
  )
578
579
  return {
@@ -583,69 +584,85 @@ class Worker:
583
584
  "chars": r.get("chars"),
584
585
  "context_len": ctx_len,
585
586
  "already_loaded": True,
587
+ "documents": 0,
588
+ "converted": 0,
589
+ "skipped": [],
586
590
  }
587
591
  path = r.get("path")
588
592
  if not isinstance(path, str):
589
- return "Error: malformed load_library reply (no path)"
593
+ return "Error: malformed add_context reply (no path)"
590
594
  try:
591
- with io.open(path, "r") as f:
592
- payload = json.load(f) if r.get("json") else f.read()
595
+ payload = read_host_payload(path, bool(r.get("json")))
593
596
  finally:
594
597
  try:
595
598
  os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
596
599
  except OSError:
597
600
  pass
598
- return self._append_library(str(source), payload, r)
601
+ return self._append_context(str(source), payload, r)
599
602
 
600
- def _append_library(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
601
- """Append host-packed library files into `context` (idempotent by path prefix).
603
+ def _append_context(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
604
+ """Append host-packed files into `context` (idempotent by path prefix).
602
605
 
603
606
  The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
604
- NO_FILES_PRODUCED in src/bridge/library.ts, so the host never commits a
607
+ NO_FILES_PRODUCED in src/bridge/add-context.ts, so the host never commits a
605
608
  loaded-prefix for an append that fails here. Reaching either one means host and worker
606
609
  disagree about `context`; keep the wording identical to its twin.
607
610
  """
608
611
  ctx = self.ns.get("context")
609
612
  if not isinstance(ctx, list):
610
613
  kind = type(ctx).__name__ if ctx is not None else "None"
611
- return f"Error: load_library requires list context (file bundle); got {kind}"
614
+ return f"Error: add_context requires list context (file bundle); got {kind}"
612
615
 
613
616
  source_id = meta.get("source_id")
614
617
  if not isinstance(source_id, str) or not source_id:
615
- source_id = "lib"
618
+ source_id = "ctx"
616
619
  path_prefix = meta.get("path_prefix")
617
- if not isinstance(path_prefix, str) or not path_prefix:
618
- path_prefix = f"lib/{source_id}/"
619
-
620
- # Idempotent: already present if any path uses this library prefix.
621
- for item in ctx:
622
- if isinstance(item, dict) and str(item.get("path", "")).startswith(path_prefix):
623
- print(
624
- f"[rlm] load_library: already loaded {source_id} "
625
- f"(paths under {path_prefix}, context len={len(ctx)})"
626
- )
627
- return {
628
- "source": source,
629
- "source_id": source_id,
630
- "path_prefix": path_prefix,
631
- "files": 0,
632
- "chars": meta.get("chars"),
633
- "context_len": len(ctx),
634
- "already_loaded": True,
635
- }
636
-
637
- files = self._library_file_entries(payload, path_prefix)
620
+ if not isinstance(path_prefix, str):
621
+ path_prefix = f"ctx/{source_id}/"
622
+ # Empty path_prefix is valid (cwd seed) but add_context always sends a non-empty ctx/ prefix.
623
+ # Guard startsWith on empty prefix: "anything".startswith("") is always True.
624
+ check_prefix = path_prefix if path_prefix != "" else None
625
+
626
+ # Idempotent: already present if any path uses this prefix.
627
+ if check_prefix is not None:
628
+ for item in ctx:
629
+ if isinstance(item, dict) and str(item.get("path", "")).startswith(check_prefix):
630
+ print(
631
+ f"[rlm] add_context: already loaded {source_id} "
632
+ f"(paths under {path_prefix}, context len={len(ctx)})"
633
+ )
634
+ return {
635
+ "source": source,
636
+ "source_id": source_id,
637
+ "path_prefix": path_prefix,
638
+ "files": 0,
639
+ "chars": meta.get("chars"),
640
+ "context_len": len(ctx),
641
+ "already_loaded": True,
642
+ "documents": 0,
643
+ "converted": 0,
644
+ "skipped": [],
645
+ }
646
+
647
+ files = self._context_file_entries(payload, path_prefix)
638
648
  if not files:
639
- return "Error: load_library produced no files"
649
+ return "Error: add_context produced no files"
640
650
 
641
651
  ctx.extend(files)
642
652
  # Keep restore payload in sync with the live list.
643
653
  self._context_payload = ctx
644
654
  self.ns["context"] = ctx
645
655
 
656
+ documents = meta.get("documents") if isinstance(meta.get("documents"), int) else 0
657
+ converted = meta.get("converted") if isinstance(meta.get("converted"), int) else 0
658
+ skipped = meta.get("skipped") if isinstance(meta.get("skipped"), list) else []
659
+ skip_n = len(skipped)
660
+ extra = ""
661
+ if documents or converted or skip_n:
662
+ extra = f"; documents={documents}, converted={converted}, skipped={skip_n}"
646
663
  print(
647
- f"[rlm] load_library: +{len(files)} files into context "
648
- f"(len={len(ctx)}); paths under {path_prefix}"
664
+ f"[rlm] add_context: +{len(files)} files into context "
665
+ f"(len={len(ctx)}); paths under {path_prefix}{extra}"
649
666
  )
650
667
  return {
651
668
  "source": source,
@@ -655,14 +672,17 @@ class Worker:
655
672
  "chars": meta.get("chars"),
656
673
  "context_len": len(ctx),
657
674
  "already_loaded": False,
675
+ "documents": documents,
676
+ "converted": converted,
677
+ "skipped": skipped,
658
678
  }
659
679
 
660
680
  @staticmethod
661
- def _library_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
681
+ def _context_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
662
682
  """Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
663
683
  if isinstance(payload, str):
664
684
  return [{
665
- "path": f"{path_prefix}content",
685
+ "path": f"{path_prefix}content" if path_prefix else "content",
666
686
  "content": payload,
667
687
  "tokens": max(1, (len(payload) + 3) // 4),
668
688
  }]
@@ -684,10 +704,9 @@ class Worker:
684
704
  """Load the packed world into the single REPL variable `context`.
685
705
 
686
706
  `index` is accepted for protocol compatibility but ignored — there is only
687
- one context slot. Libraries are merged on the host (or via load_library).
707
+ one context slot. Sources are merged on the host (or via add_context).
688
708
  """
689
- with open(path, "r") as f:
690
- payload = json.load(f) if is_json else f.read()
709
+ payload = read_host_payload(path, bool(is_json))
691
710
  self._context_payload = payload
692
711
  self.ns["context"] = payload
693
712
  # Drop legacy multi-slot names if present.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
8
8
  import type { ReplResult } from "./protocol.ts";
9
- import { mergeLibraryIntoContext } from "../context/library-context.ts";
9
+ import { mergeIntoContext } from "../context/merge.ts";
10
10
 
11
11
  /** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
12
12
  export interface SandboxManagerConfig {
@@ -28,23 +28,26 @@ export class SandboxManager {
28
28
  /** Serialized execution queue — concurrent repl() calls wait for predecessor. */
29
29
  private execQueue: Promise<void> = Promise.resolve();
30
30
  private pendingExecCount = 0;
31
- /** Context payload to load on first sandbox creation. Set externally before getOrCreate. */
32
- contextPayload: unknown = null;
31
+ /**
32
+ * Context payload to load on first sandbox creation. Starts as an empty list — context is
33
+ * empty by default; the first repl() seeds the cwd when autoSeedCwd is on.
34
+ */
35
+ contextPayload: unknown = [];
33
36
  /** True once contextPayload has been loaded into the sandbox (prevents reload + race fix). */
34
37
  private contextLoaded = false;
35
38
 
36
39
  constructor(private readonly config: SandboxManagerConfig) {}
37
40
 
38
41
  /**
39
- * Append a library payload to the context this manager replays on death-recreate.
42
+ * Append a source payload to the context this manager replays on death-recreate.
40
43
  *
41
- * The live worker has ALREADY appended it in-process (worker.py `_append_library`), so this
44
+ * The live worker has ALREADY appended it in-process (worker.py `_append_context`), so this
42
45
  * deliberately does not reload — it only keeps the host's replay copy truthful. Without it a
43
- * recreate silently rolls the sandbox back to a repo-only context, and any child inheriting
44
- * this payload would never see the library. Dedups by `lib/<id>/` prefix.
46
+ * recreate silently rolls the sandbox back to a pre-append context, and any child inheriting
47
+ * this payload would never see the source. Dedups by `ctx/<id>/` prefix.
45
48
  */
46
- appendLibrary(payload: unknown): void {
47
- this.contextPayload = mergeLibraryIntoContext(this.contextPayload, payload);
49
+ appendContext(payload: unknown): void {
50
+ this.contextPayload = mergeIntoContext(this.contextPayload, payload);
48
51
  }
49
52
 
50
53
  /**
@@ -52,15 +55,14 @@ export class SandboxManager {
52
55
  * given handlers. Subsequent calls return the existing sandbox immediately.
53
56
  * Deduplicates concurrent calls via initPromise.
54
57
  *
55
- * If contextPayload is set, it is loaded before the sandbox is returned.
58
+ * If contextPayload is defined, it is loaded before the sandbox is returned.
56
59
  */
57
60
  async getOrCreate(handlers: Partial<SubLlmHandlers>): Promise<PythonSandbox> {
58
61
  if (this.disposed) throw new Error("SandboxManager disposed");
59
62
  if (this.sandbox) {
60
- // RACE FIX: contextPayload may arrive after the sandbox was created (the
61
- // "context" event's async packRepository resolves after the first repl() call).
62
- // Load it into the live sandbox now if still pending.
63
- if (this.contextPayload !== null && !this.contextLoaded) {
63
+ // RACE FIX: contextPayload may arrive after the sandbox was created (lazy seed
64
+ // resolves after the first getOrCreate). Load it into the live sandbox now if pending.
65
+ if (this.contextPayload !== undefined && !this.contextLoaded) {
64
66
  await this.sandbox.loadContext(this.contextPayload);
65
67
  this.contextLoaded = true;
66
68
  }
@@ -79,8 +81,8 @@ export class SandboxManager {
79
81
  awaitTimeoutS: this.config.awaitTimeoutS,
80
82
  handlers,
81
83
  }).then(async (s) => {
82
- // Load context on first creation if available.
83
- if (this.contextPayload !== null) {
84
+ // Load context on first creation if available (empty list is a valid starting value).
85
+ if (this.contextPayload !== undefined) {
84
86
  await s.loadContext(this.contextPayload);
85
87
  this.contextLoaded = true;
86
88
  }
@@ -24,7 +24,7 @@ import { pinContext, type PinnedContext } from "./context-file.ts";
24
24
  import { REJECT, serviceInterrupt, type ReplyBody, type SubLlmHandlers } from "./interrupts.ts";
25
25
  import { trace, traceEnabled } from "../util/trace.ts";
26
26
 
27
- export type { LibraryLoadResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
27
+ export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
28
28
 
29
29
  export interface SandboxOptions {
30
30
  /** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
@@ -111,6 +111,11 @@ export class PythonSandbox {
111
111
  this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
112
112
  const python = opts.python ?? "python3";
113
113
  const workerArgs = [
114
+ // -X utf8=1: the scaffold states its own encoding explicitly (py/hostio.py), but MODEL
115
+ // code gets a real open() — guards.py exposes it deliberately — and on Windows that
116
+ // would default to cp1252 (issue #7). UTF-8 mode covers the whole interpreter; the
117
+ // scaffold's explicit encoding= still wins where PYTHONIOENCODING would override this.
118
+ "-X", "utf8=1",
114
119
  "-u", WORKER_PATH,
115
120
  "--depth", String(opts.depth ?? 1),
116
121
  "--timeout", String(opts.execTimeoutS ?? 600),
@@ -124,7 +129,9 @@ export class PythonSandbox {
124
129
  this.proc = spawn(
125
130
  python,
126
131
  workerArgs,
127
- { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv() },
132
+ // windowsHide: without it each sandbox flashes a console window on Windows (pi sets
133
+ // this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
134
+ { stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
128
135
  ) as ChildProcessWithoutNullStreams;
129
136
 
130
137
  this.proc.stdout.setEncoding("utf8");