@hicaru/pi-rlm 0.3.5 → 0.3.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.
@@ -0,0 +1,615 @@
1
+ """RLM worker scaffold — the REPL surface exposed to the model.
2
+
3
+ Pure move out of worker.py (Phase 0 of the v5 port): every method the model can call —
4
+ sub-LLM spawns, await/collect, retrieval (search/grep_context/outline), map_files,
5
+ llm_map_reduce, and add_context — lives on this mixin. worker.py keeps the protocol
6
+ machinery (stdin/stdout pump, inbox, exec loop) and mixes this in via `Worker(WorkerScaffold)`.
7
+
8
+ Sibling module: Python puts this file's directory on sys.path[0], so the import resolves
9
+ without any packaging step (same mechanism as guards.py / retrieval.py / tasks.py).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ from guards import _stall_message, _surfaced_error
17
+ from hostio import read_host_payload
18
+ from retrieval import (
19
+ _Bm25Index,
20
+ _chunk_text,
21
+ _context_entries,
22
+ _CHUNK_HEADER_OVERHEAD,
23
+ _MAX_CHUNK_BATCH,
24
+ _MAX_CHUNKS,
25
+ grep_context as _grep_context_impl,
26
+ outline as _outline_impl,
27
+ search as _search_impl,
28
+ )
29
+ from tasks import (
30
+ _clean_paths,
31
+ _reduce_batch,
32
+ _reduce_chunked,
33
+ _reduce_map_files,
34
+ _reduce_one,
35
+ _spawnable,
36
+ Task,
37
+ )
38
+
39
+
40
+ class _MemoryApi:
41
+ """v5 durable memory surface — thin shims over the `memory` interrupt.
42
+
43
+ memory.query(q, k=8) → retrieved notes; memory.add(text, paths=…, tags=…) → status;
44
+ memory.stats() → store counters. All calls block on the host RPC like add_context does.
45
+ """
46
+
47
+ def __init__(self, worker: "WorkerScaffold"):
48
+ self._w = worker
49
+
50
+ def query(self, q, k: int = 8) -> str:
51
+ return self._w._memory_rpc("query", {"query": str(q), "k": int(k)})
52
+
53
+ def add(self, text, paths=None, tags=None) -> str:
54
+ p = [str(x) for x in (paths or [])]
55
+ t = [str(x) for x in (tags or [])]
56
+ return self._w._memory_rpc("add", {"content": str(text), "paths": p, "tags": t})
57
+
58
+ def stats(self) -> str:
59
+ return self._w._memory_rpc("stats", {})
60
+
61
+
62
+ class WorkerScaffold:
63
+ """Mixin: the model-facing REPL API. Requires the host methods from Worker
64
+ (`_post`, `_rpc`, `_drain_until`, `_take`, `self.inbox`, `self.ns`, `self._handles`)."""
65
+
66
+
67
+ # ---- spawn / await ---------------------------------------------------------------------
68
+
69
+ def _start_prompt(self, kind: str, prompt, paths=None) -> Task:
70
+ text = str(prompt)
71
+ # A sub-LLM asked nothing answers something: the confabulation then sits in `answers`
72
+ # looking exactly like data. Refuse instead of spending a call on it.
73
+ if not text.strip():
74
+ return self._resolved(kind, _surfaced_error(
75
+ f"{kind}() got an empty prompt — a sub-LLM would confabulate an answer to nothing"))
76
+ payload: dict[str, Any] = {"prompt": text}
77
+ clean = _clean_paths(paths)
78
+ if clean is not None:
79
+ payload["paths"] = clean
80
+ rid = self._post(kind, payload)
81
+ return self._task(kind, (rid,), _reduce_one, text[:40])
82
+
83
+ def _start_prompts(self, kind: str, prompts, paths=None) -> Task:
84
+ prompts = [str(p) for p in prompts]
85
+ if not prompts:
86
+ return self._resolved(kind, [])
87
+ # Only the all-blank case: one blank prompt among twenty is the caller's business.
88
+ if not any(p.strip() for p in prompts):
89
+ return self._resolved(kind, [
90
+ _surfaced_error(f"{kind}() got only empty prompts")
91
+ ] * len(prompts))
92
+ payload: dict[str, Any] = {"prompts": prompts}
93
+ # One prefix set for the whole batch: a per-prompt aligned list is an API nobody uses
94
+ # correctly, and every prompt in a batch is asking about the same slice anyway.
95
+ clean = _clean_paths(paths)
96
+ if clean is not None:
97
+ payload["paths"] = clean
98
+ rid = self._post(kind, payload)
99
+ return self._task(kind, (rid,), _reduce_batch(len(prompts)), f"×{len(prompts)}")
100
+
101
+ def _start_llm_query(self, prompt) -> Task:
102
+ return self._start_prompt("llm_query", prompt)
103
+
104
+ def _start_rlm_query(self, prompt, paths=None) -> Task:
105
+ return self._start_prompt("rlm_query", prompt, paths)
106
+
107
+ def _start_llm_batch(self, prompts) -> Task:
108
+ return self._start_prompts("llm_batch", prompts)
109
+
110
+ def _start_rlm_batch(self, prompts, paths=None) -> Task:
111
+ return self._start_prompts("rlm_batch", prompts, paths)
112
+
113
+ def _start_llm_query_chunked(self, text, prompt: str) -> Task:
114
+ """Split oversized text into cap-sized chunks and post EVERY batch at once.
115
+
116
+ One answer per chunk, order preserved. No exceptions escape: errors come back as
117
+ "Error: ..." strings per chunk (same contract as llm_batch). Because all
118
+ batches go on the wire together, a large input costs one round-trip of latency
119
+ rather than one per 20 chunks.
120
+
121
+ NOTE: budget uses Python code-point length (len) while the parent-side cap check counts
122
+ UTF-16 units (JS string.length); astral/emoji-heavy text may be marginally larger on the
123
+ parent and get per-chunk rejected. Acceptable trade-off for typical code/log/profile text.
124
+ """
125
+ text, prompt = str(text), str(prompt)
126
+ if not text:
127
+ return self._resolved("llm_query_chunked", [])
128
+ budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD
129
+ if budget < 1_000:
130
+ return self._resolved("llm_query_chunked", [
131
+ f"Error: prompt leaves under 1,000 chars per chunk (cap {self.max_prompt_chars:,}) — shorten the instruction"
132
+ ])
133
+ chunks = _chunk_text(text, budget)
134
+ total = len(chunks)
135
+ if total > _MAX_CHUNKS:
136
+ return self._resolved("llm_query_chunked", [
137
+ f"Error: {total} chunks would be needed — filter/slice the text in Python first"
138
+ ])
139
+ rids: list[str] = []
140
+ sizes: list[int] = []
141
+ for i in range(0, total, _MAX_CHUNK_BATCH):
142
+ batch = [
143
+ f"{prompt}\n\n[chunk {i + j + 1}/{total} of the input]\n{c}"
144
+ for j, c in enumerate(chunks[i:i + _MAX_CHUNK_BATCH])
145
+ ]
146
+ rids.append(self._post("llm_batch", {"prompts": batch}))
147
+ sizes.append(len(batch))
148
+ return self._task("llm_query_chunked", tuple(rids), _reduce_chunked(sizes), f"{total} chunks")
149
+ def _builder_for(self, name: str):
150
+ # llm_map_reduce is deliberately absent: its reduce step is a SECOND sub-LLM call that
151
+ # depends on its own map results, so it cannot be one (rids, pure reduce) Task.
152
+ return {
153
+ "llm_query": self._start_llm_query,
154
+ "llm_batch": self._start_llm_batch,
155
+ "llm_query_chunked": self._start_llm_query_chunked,
156
+ "map_files": self._start_map_files,
157
+ "rlm_query": self._start_rlm_query,
158
+ "rlm_batch": self._start_rlm_batch,
159
+ }.get(name)
160
+
161
+ def _spawn(self, fn, *args, **kwargs) -> Task:
162
+ """Start a sub-call without waiting for it. `fn` is the scaffold function itself.
163
+
164
+ Returns a Task for await_task, possibly in a later ```repl``` block.
165
+ Misuse returns an already-resolved error Task rather than raising, matching the
166
+ "Error: ..." contract of the synchronous helpers.
167
+ """
168
+ name = getattr(fn, "_rlm_name", None)
169
+ builder = self._builder_for(name) if isinstance(name, str) else None
170
+ if builder is None:
171
+ return self._resolved("spawn", _surfaced_error(
172
+ "spawn() takes llm_query, llm_batch, llm_query_chunked, map_files, "
173
+ "rlm_query or rlm_batch — not llm_map_reduce, whose reduce step depends "
174
+ "on its own map results and so cannot be a single Task"))
175
+ # Sub-LLM kinds already post detached via _post; keep the flag for clarity / future kinds.
176
+ self._detached = True
177
+ try:
178
+ return builder(*args, **kwargs)
179
+ except TypeError as e:
180
+ return self._resolved("spawn", _surfaced_error(f"bad spawn arguments — {e}"))
181
+ finally:
182
+ self._detached = False
183
+
184
+ def _task(self, kind: str, rids, reduce, label: str = "") -> Task:
185
+ t = Task(self, kind, rids, reduce, label)
186
+ self._handles.append(t)
187
+ return t
188
+
189
+ def _resolved(self, kind: str, value: Any, label: str = "") -> Task:
190
+ t = Task.resolved(self, kind, value, label)
191
+ self._handles.append(t)
192
+ return t
193
+
194
+ def _live_handles(self) -> list[Task]:
195
+ return [t for t in self._handles if not t._settled]
196
+
197
+ def _task_name_map(self) -> dict[int, str]:
198
+ return {id(v): k for k, v in self.ns.items() if isinstance(v, Task)}
199
+
200
+ def _list_tasks(self) -> list[dict[str, Any]]:
201
+ """[{kind, label, done, var}] for every Task this worker created."""
202
+ names = self._task_name_map()
203
+ return [
204
+ {"kind": t.kind, "label": t.label, "done": t.done, "var": names.get(id(t))}
205
+ for t in self._handles
206
+ ]
207
+
208
+ def _pending_task_infos(self) -> list[dict[str, Any]]:
209
+ names = self._task_name_map()
210
+ return [
211
+ {"var": names.get(id(t)), "kind": t.kind, "label": t.label}
212
+ for t in self._live_handles()
213
+ ]
214
+
215
+ def _await_one(self, task: Task, *, block: bool = True) -> Any:
216
+ """Collect one Task. Idempotent — the value is memoized.
217
+
218
+ A stall does not settle the Task; the same handle can be awaited again.
219
+ `block=False` settles only if the reply is already parked (batch union drain).
220
+ """
221
+ if task._settled:
222
+ return task._value
223
+ ready = all(r in self.inbox for r in task._rids)
224
+ if not ready:
225
+ if not block or not self._drain_until(task._rids):
226
+ return _surfaced_error(_stall_message(self.await_timeout_s))
227
+ task._value = task._reduce(self._take(task._rids))
228
+ task._settled = True
229
+ return task._value
230
+
231
+ def _await_task(self, task_or_tasks=None) -> Any:
232
+ """Collect result(s). Accepts a Task, a list/tuple of Tasks, or nothing.
233
+
234
+ No argument: collect every still-running Task this worker created (the recovery
235
+ path when the model lost the handle). One live Task unwraps to its result;
236
+ several return a list.
237
+ Canonical name for the model: await_task(...). (bare `await` is a Python keyword.)
238
+ """
239
+ if task_or_tasks is None:
240
+ live = self._live_handles()
241
+ if not live:
242
+ return _surfaced_error(
243
+ "await_task() found no running Tasks — bind rlm_batch/llm_query to a name "
244
+ "and pass it, or call list_tasks()"
245
+ )
246
+ # One live Task (the usual lost-handle case) unwraps so
247
+ # `reports = await_task()` matches `reports = await_task(t)`.
248
+ if len(live) == 1:
249
+ return self._await_one(live[0])
250
+ return self._await_task(live)
251
+ if isinstance(task_or_tasks, Task):
252
+ return self._await_one(task_or_tasks)
253
+ if isinstance(task_or_tasks, (list, tuple)):
254
+ tasks = list(task_or_tasks)
255
+ union: list[str] = []
256
+ seen: set[str] = set()
257
+ for t in tasks:
258
+ if not isinstance(t, Task) or t._settled:
259
+ continue
260
+ for rid in t._rids:
261
+ if rid not in seen:
262
+ seen.add(rid)
263
+ union.append(rid)
264
+ if union:
265
+ self._drain_until(union)
266
+ out: list[Any] = [None] * len(tasks)
267
+ for i, t in enumerate(tasks):
268
+ if isinstance(t, Task):
269
+ # Union already waited once — do not stall again per item.
270
+ out[i] = self._await_one(t, block=False)
271
+ else:
272
+ out[i] = _surfaced_error(
273
+ f"await_task expects Task items, got {type(t).__name__}"
274
+ )
275
+ return out
276
+ return _surfaced_error(
277
+ f"await_task expects a Task or list of Tasks, got {type(task_or_tasks).__name__}"
278
+ )
279
+
280
+ # ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
281
+
282
+ def _entries(self) -> list[tuple[str, str]]:
283
+ return _context_entries(self.ns.get("context"))
284
+
285
+ def _get_index(self) -> _Bm25Index:
286
+ """Build the BM25 index on first use; rebuild when `context` was replaced or resized.
287
+
288
+ Identity+length is a cheap stamp that catches the two ways context actually changes:
289
+ add_context() extending the list, and the model re-binding the name. In-place edits
290
+ that preserve length are not detected — documented, and rare in practice.
291
+ """
292
+ ctx = self.ns.get("context")
293
+ stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
294
+ if self._index is None or self._index_stamp != stamp:
295
+ self._index = _Bm25Index(self._entries())
296
+ self._index_stamp = stamp
297
+ return self._index
298
+
299
+ def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
300
+ """Rank `context` windows against a natural-language query (BM25).
301
+
302
+ Returns [{path, line, score, snippet}] — pointers, not bodies.
303
+ """
304
+ return _search_impl(self._entries(), self._get_index(), query, k, path_glob)
305
+
306
+ def _grep_context(
307
+ self,
308
+ pattern: str,
309
+ k: int = 50,
310
+ path_glob: str | None = None,
311
+ before: int = 0,
312
+ after: int = 0,
313
+ ) -> dict[str, Any]:
314
+ """Regex over `context`, capped and shaped. See retrieval.grep_context."""
315
+ return _grep_context_impl(self._entries(), pattern, k, path_glob, before, after)
316
+
317
+ def _outline(self, path: str) -> str:
318
+ """Definition/heading skeleton of one context file. See retrieval.outline."""
319
+ return _outline_impl(self._entries(), path)
320
+
321
+ # ---- one-line delegation (structural: orchestrating must be easier than solving) -------
322
+
323
+ def _start_map_files(self, files: Any, prompt: str) -> Task:
324
+ """Post every batch map_files needs, WITHOUT waiting. Contract: see _map_files.
325
+
326
+ All batches go on the wire together, so a 100-file map costs one round-trip of latency
327
+ rather than one per 20 files.
328
+ """
329
+ prompt = str(prompt)
330
+ by_path: list[tuple[str, str]] = []
331
+ lookup: dict[str, str] | None = None
332
+ for item in files if isinstance(files, (list, tuple)) else [files]:
333
+ if isinstance(item, dict):
334
+ content = item.get("content", "")
335
+ by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
336
+ elif isinstance(item, str):
337
+ if lookup is None:
338
+ lookup = {p: c for p, c in self._entries()}
339
+ if item in lookup:
340
+ by_path.append((item, lookup[item]))
341
+ else:
342
+ by_path.append((item, ""))
343
+ if not by_path:
344
+ return self._resolved("map_files", {})
345
+
346
+ # Per-file prompt budget; anything larger is chunked and its answers concatenated.
347
+ budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
348
+ if budget < 1_000:
349
+ return self._resolved("map_files", {
350
+ p: "Error: prompt too long to leave room for file content" for p, _ in by_path
351
+ })
352
+
353
+ requests: list[str] = []
354
+ spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
355
+ for path, content in by_path:
356
+ chunks = _chunk_text(content, budget) if len(content) > budget else [content]
357
+ spans.append((path, len(chunks)))
358
+ for j, chunk in enumerate(chunks):
359
+ header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
360
+ requests.append(f"{prompt}\n\n{header}\n{chunk}")
361
+
362
+ rids: list[str] = []
363
+ sizes: list[int] = []
364
+ for i in range(0, len(requests), _MAX_CHUNK_BATCH):
365
+ batch = requests[i:i + _MAX_CHUNK_BATCH]
366
+ rids.append(self._post("llm_batch", {"prompts": batch}))
367
+ sizes.append(len(batch))
368
+ return self._task("map_files", tuple(rids),
369
+ _reduce_map_files(sizes, spans), f"{len(by_path)} files")
370
+
371
+ @_spawnable("map_files")
372
+ def _map_files(self, files: Any, prompt: str) -> Task:
373
+ """Always spawn. Collect with await_task(t) → dict[path, answer].
374
+
375
+ `files` accepts context entries (dicts), paths (strings), or a mix — the whole
376
+ chunk/batch/collect loop the system prompt used to spell out, as one call.
377
+ Oversized files are split and their per-chunk answers joined.
378
+ Posts are detached (↯bg) so fan-out outlives the repl cell.
379
+ """
380
+ return self._start_map_files(files, prompt)
381
+
382
+ def _llm_map_reduce(
383
+ self,
384
+ items: Any,
385
+ map_prompt: str,
386
+ reduce_prompt: str,
387
+ ) -> str:
388
+ """Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
389
+
390
+ The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
391
+ the buffers") as a single call, so the root never hand-rolls the loop.
392
+ """
393
+ map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
394
+ seq = list(items) if isinstance(items, (list, tuple)) else [items]
395
+ if not seq:
396
+ return "Error: llm_map_reduce got no items"
397
+ texts = [
398
+ (str(it.get("content", "")) if isinstance(it, dict) else str(it))
399
+ for it in seq
400
+ ]
401
+ labels = [
402
+ (str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
403
+ for i, it in enumerate(seq)
404
+ ]
405
+ mapped: list[str] = []
406
+ for i in range(0, len(texts), _MAX_CHUNK_BATCH):
407
+ batch = [
408
+ f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
409
+ for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
410
+ ]
411
+ # Core tools always return Task — helpers must await explicitly.
412
+ part = self._await_task(self._start_llm_batch(batch))
413
+ mapped.extend(part if isinstance(part, list) else [str(part)])
414
+ joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
415
+ reduced = self._await_task(
416
+ self._start_llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}")
417
+ )
418
+ return str(reduced)
419
+
420
+ # ---- Core tools: ALWAYS spawn (return Task). Collect with await_task only. ------------
421
+
422
+ @_spawnable("llm_query")
423
+ def _llm_query(self, prompt: str) -> Task:
424
+ """Always spawn. Collect with await_task(t). Never auto-awaits."""
425
+ return self._start_llm_query(prompt)
426
+
427
+ @_spawnable("llm_batch")
428
+ def _llm_batch(self, prompts) -> Task:
429
+ """Always spawn. Collect with await_task(t) → ordered list[str]."""
430
+ return self._start_llm_batch(prompts)
431
+
432
+ @_spawnable("llm_query_chunked")
433
+ def _llm_query_chunked(self, text, prompt: str) -> Task:
434
+ """Always spawn. Collect with await_task(t) → list[str] (one answer per chunk)."""
435
+ return self._start_llm_query_chunked(text, prompt)
436
+
437
+ @_spawnable("rlm_query")
438
+ def _rlm_query(self, prompt=None, task=None, paths=None) -> Task:
439
+ """Always spawn. Collect with await_task(t). Accepts `task=` or `prompt=` (same string)."""
440
+ p = prompt if prompt is not None else task
441
+ if not isinstance(p, str) or not p.strip():
442
+ raise TypeError(
443
+ "rlm_query() needs the study text: rlm_query(task='…', paths=[…]) "
444
+ "or positionally rlm_query('…', paths=[…])"
445
+ )
446
+ return self._start_rlm_query(p, paths)
447
+
448
+ def _add_context(self, source: str) -> dict[str, Any] | str:
449
+ """Pack an external dir/file/git-URL on the host and append it into `context`.
450
+
451
+ Paths are namespaced under ctx/<source_id>/ (host). Content is always in the
452
+ single `context` list — never a new context_N variable.
453
+ Host-side idempotency may return already_loaded without a payload path.
454
+ Documents (PDF/DOCX/…) are converted to Markdown on the host.
455
+ """
456
+ r = self._rpc("add_context", {"source": str(source)})
457
+ if r.get("error"):
458
+ return f"Error: {r['error']}"
459
+ if r.get("already_loaded"):
460
+ source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "ctx"
461
+ path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"ctx/{source_id}/"
462
+ ctx = self.ns.get("context")
463
+ ctx_len = len(ctx) if isinstance(ctx, list) else 0
464
+ print(
465
+ f"[rlm] add_context: already loaded {source_id} "
466
+ f"(paths under {path_prefix}, context len={ctx_len})"
467
+ )
468
+ return {
469
+ "source": str(source),
470
+ "source_id": source_id,
471
+ "path_prefix": path_prefix,
472
+ "files": 0,
473
+ "chars": r.get("chars"),
474
+ "context_len": ctx_len,
475
+ "already_loaded": True,
476
+ "documents": 0,
477
+ "converted": 0,
478
+ "skipped": [],
479
+ }
480
+ path = r.get("path")
481
+ if not isinstance(path, str):
482
+ return "Error: malformed add_context reply (no path)"
483
+ try:
484
+ payload = read_host_payload(path, bool(r.get("json")))
485
+ finally:
486
+ try:
487
+ os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
488
+ except OSError:
489
+ pass
490
+ return self._append_context(str(source), payload, r)
491
+
492
+ def _append_context(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
493
+ """Append host-packed files into `context` (idempotent by path prefix).
494
+
495
+ The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
496
+ NO_FILES_PRODUCED in src/bridge/add-context.ts, so the host never commits a
497
+ loaded-prefix for an append that fails here. Reaching either one means host and worker
498
+ disagree about `context`; keep the wording identical to its twin.
499
+ """
500
+ ctx = self.ns.get("context")
501
+ if not isinstance(ctx, list):
502
+ kind = type(ctx).__name__ if ctx is not None else "None"
503
+ return f"Error: add_context requires list context (file bundle); got {kind}"
504
+
505
+ source_id = meta.get("source_id")
506
+ if not isinstance(source_id, str) or not source_id:
507
+ source_id = "ctx"
508
+ path_prefix = meta.get("path_prefix")
509
+ if not isinstance(path_prefix, str):
510
+ path_prefix = f"ctx/{source_id}/"
511
+ # Empty path_prefix is valid (cwd seed) but add_context always sends a non-empty ctx/ prefix.
512
+ # Guard startsWith on empty prefix: "anything".startswith("") is always True.
513
+ check_prefix = path_prefix if path_prefix != "" else None
514
+
515
+ # Idempotent: already present if any path uses this prefix.
516
+ if check_prefix is not None:
517
+ for item in ctx:
518
+ if isinstance(item, dict) and str(item.get("path", "")).startswith(check_prefix):
519
+ print(
520
+ f"[rlm] add_context: already loaded {source_id} "
521
+ f"(paths under {path_prefix}, context len={len(ctx)})"
522
+ )
523
+ return {
524
+ "source": source,
525
+ "source_id": source_id,
526
+ "path_prefix": path_prefix,
527
+ "files": 0,
528
+ "chars": meta.get("chars"),
529
+ "context_len": len(ctx),
530
+ "already_loaded": True,
531
+ "documents": 0,
532
+ "converted": 0,
533
+ "skipped": [],
534
+ }
535
+
536
+ files = self._context_file_entries(payload, path_prefix)
537
+ if not files:
538
+ return "Error: add_context produced no files"
539
+
540
+ ctx.extend(files)
541
+ # Keep restore payload in sync with the live list.
542
+ self._context_payload = ctx
543
+ self.ns["context"] = ctx
544
+
545
+ documents = meta.get("documents") if isinstance(meta.get("documents"), int) else 0
546
+ converted = meta.get("converted") if isinstance(meta.get("converted"), int) else 0
547
+ skipped = meta.get("skipped") if isinstance(meta.get("skipped"), list) else []
548
+ skip_n = len(skipped)
549
+ extra = ""
550
+ if documents or converted or skip_n:
551
+ extra = f"; documents={documents}, converted={converted}, skipped={skip_n}"
552
+ print(
553
+ f"[rlm] add_context: +{len(files)} files into context "
554
+ f"(len={len(ctx)}); paths under {path_prefix}{extra}"
555
+ )
556
+ return {
557
+ "source": source,
558
+ "source_id": source_id,
559
+ "path_prefix": path_prefix,
560
+ "files": len(files),
561
+ "chars": meta.get("chars"),
562
+ "context_len": len(ctx),
563
+ "already_loaded": False,
564
+ "documents": documents,
565
+ "converted": converted,
566
+ "skipped": skipped,
567
+ }
568
+
569
+ @staticmethod
570
+ def _context_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
571
+ """Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
572
+ if isinstance(payload, str):
573
+ return [{
574
+ "path": f"{path_prefix}content" if path_prefix else "content",
575
+ "content": payload,
576
+ "tokens": max(1, (len(payload) + 3) // 4),
577
+ }]
578
+ if not isinstance(payload, list):
579
+ return []
580
+ out: list[dict[str, Any]] = []
581
+ for item in payload:
582
+ if isinstance(item, dict) and "path" in item and "content" in item:
583
+ out.append(item)
584
+ return out
585
+
586
+ @_spawnable("rlm_batch")
587
+ def _rlm_batch(self, prompts=None, tasks=None, paths=None) -> Task:
588
+ """Always spawn. Collect with await_task(t) → ordered list of reports.
589
+
590
+ Accepts `tasks=` or `prompts=` (same list) — the docs say `tasks`, legacy calls say `prompts`.
591
+ """
592
+ p = prompts if prompts is not None else tasks
593
+ if not isinstance(p, (list, tuple)) or len(p) == 0:
594
+ raise TypeError(
595
+ "rlm_batch() needs a non-empty list of studies: rlm_batch(tasks=['…','…'], paths=[…])"
596
+ )
597
+ return self._start_rlm_batch(list(p), paths)
598
+
599
+ def _list_claims(self) -> str:
600
+ """v5 blackboard: the host TaskLedger's claims table (inflight + done work)."""
601
+ r = self._rpc("ledger_claims", {})
602
+ if r.get("error"):
603
+ return f"Error: {r['error']}"
604
+ return str(r.get("response") or "ledger: no claims")
605
+
606
+ def _memory_rpc(self, op: str, payload: dict[str, Any]) -> str:
607
+ r = self._rpc("memory", {"op": op, **payload})
608
+ if r.get("error"):
609
+ return f"Error: {r['error']}"
610
+ return str(r.get("response") or "")
611
+
612
+ def _memory_api(self) -> "_MemoryApi":
613
+ return _MemoryApi(self)
614
+
615
+ # ---- context + execution --------------------------------------------------------------