@hicaru/pi-rlm 0.1.9 → 0.2.0

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.
@@ -17,8 +17,11 @@ services the request in-process (it holds API keys).
17
17
  from __future__ import annotations
18
18
 
19
19
  import argparse
20
+ import fnmatch
21
+ import heapq
20
22
  import io
21
23
  import json
24
+ import math
22
25
  import os
23
26
  import pickle
24
27
  import re
@@ -102,12 +105,17 @@ RESERVED = frozenset(
102
105
  {
103
106
  "llm_query", "llm_query_batched", "llm_query_chunked",
104
107
  "rlm_query", "rlm_query_batched",
108
+ "map_files", "llm_map_reduce",
109
+ "search", "grep_context", "outline",
105
110
  "advance_phase", "save_artifact",
106
111
  "ask_user_question", "todo",
107
112
  "load_library",
108
113
  "SHOW_VARS", "answer", "context",
109
114
  }
110
115
  )
116
+ # NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
117
+ # owned by the model, so they must appear in SHOW_VARS and be captured by snapshots — losing a
118
+ # memoized answer across a resume is exactly the failure the memo exists to prevent.
111
119
  # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
112
120
  _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
113
121
 
@@ -134,6 +142,145 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
134
142
  return chunks
135
143
 
136
144
 
145
+ # ---- deterministic retrieval over `context` -----------------------------------------------
146
+ #
147
+ # The RLM paper's trajectories retrieve by having the root model hand-write regex over the
148
+ # context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
149
+ # the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
150
+ # These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
151
+ # spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
152
+
153
+ _INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
154
+ _INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
155
+ _SNIPPET_CHARS = 400
156
+ _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
157
+ _BM25_K1 = 1.2
158
+ _BM25_B = 0.75
159
+
160
+ _TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
161
+ _CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
162
+
163
+ # Definition-ish lines across the languages this plugin is likely to meet. Deliberately
164
+ # lexical: an outline is an orientation aid, not a parse tree.
165
+ _OUTLINE_LINE = re.compile(
166
+ r"^\s*(?:"
167
+ r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
168
+ r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
169
+ r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
170
+ r"|def\s+\w+|class\s+\w+"
171
+ r"|func\s+\w+"
172
+ r"|#{1,4}\s+\S"
173
+ r")"
174
+ )
175
+
176
+
177
+ def _tokenize(text: str) -> list[str]:
178
+ """Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
179
+ out: list[str] = []
180
+ for raw in _TOKEN_SPLIT.split(text):
181
+ if not raw:
182
+ continue
183
+ lowered = raw.lower()
184
+ out.append(lowered)
185
+ if len(raw) > 3:
186
+ parts = _CAMEL_SPLIT.split(raw)
187
+ if len(parts) > 1:
188
+ for part in parts:
189
+ piece = part.lower()
190
+ if piece and piece != lowered:
191
+ out.append(piece)
192
+ return out
193
+
194
+
195
+ def _context_entries(context: Any) -> list[tuple[str, str]]:
196
+ """(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
197
+ if isinstance(context, str):
198
+ return [("<context>", context)]
199
+ if not isinstance(context, list):
200
+ return []
201
+ out: list[tuple[str, str]] = []
202
+ for i, item in enumerate(context):
203
+ if isinstance(item, dict):
204
+ content = item.get("content", "")
205
+ out.append((
206
+ str(item.get("path", f"<context[{i}]>")),
207
+ content if isinstance(content, str) else str(content),
208
+ ))
209
+ elif isinstance(item, str):
210
+ out.append((f"<context[{i}]>", item))
211
+ return out
212
+
213
+
214
+ class _Bm25Index:
215
+ """Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
216
+
217
+ __slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
218
+
219
+ def __init__(self, entries: list[tuple[str, str]]) -> None:
220
+ self.paths: list[str] = []
221
+ self.starts: list[int] = []
222
+ self.texts: list[str] = []
223
+ self.postings: dict[str, list[tuple[int, int]]] = {}
224
+ self.doc_len: list[int] = []
225
+ self.truncated = False
226
+
227
+ for path, content in entries:
228
+ if not content:
229
+ continue
230
+ lines = content.split("\n")
231
+ for start in range(0, len(lines), _INDEX_WINDOW_LINES):
232
+ if len(self.texts) >= _INDEX_MAX_WINDOWS:
233
+ self.truncated = True
234
+ break
235
+ window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
236
+ idx = len(self.texts)
237
+ self.paths.append(path)
238
+ self.starts.append(start + 1)
239
+ self.texts.append(window)
240
+ terms = _tokenize(window)
241
+ self.doc_len.append(len(terms))
242
+ freq: dict[str, int] = {}
243
+ for term in terms:
244
+ freq[term] = freq.get(term, 0) + 1
245
+ for term, tf in freq.items():
246
+ self.postings.setdefault(term, []).append((idx, tf))
247
+ if self.truncated:
248
+ break
249
+
250
+ total = len(self.doc_len)
251
+ self.avg_len = (sum(self.doc_len) / total) if total else 1.0
252
+
253
+ def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
254
+ total = len(self.texts)
255
+ if total == 0:
256
+ return []
257
+ scores: dict[int, float] = {}
258
+ for term in set(terms):
259
+ posting = self.postings.get(term)
260
+ if not posting:
261
+ continue
262
+ df = len(posting)
263
+ idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
264
+ for idx, tf in posting:
265
+ norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
266
+ scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
267
+ if path_glob:
268
+ scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
269
+ if not scores:
270
+ return []
271
+ top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
272
+ out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
273
+ for i, (idx, score) in enumerate(top):
274
+ text = self.texts[idx]
275
+ out[i] = {
276
+ "path": self.paths[idx],
277
+ "line": self.starts[idx],
278
+ "score": round(score, 3),
279
+ "snippet": text[:_SNIPPET_CHARS],
280
+ }
281
+ return out
282
+
283
+
137
284
  class _AnswerDict(dict):
138
285
  """`answer` dict; flipping `ready` True captures the final answer for the parent."""
139
286
 
@@ -174,6 +321,8 @@ class Worker:
174
321
  self.ns = {"__builtins__": builtins, "__name__": "__main__"}
175
322
  self._context_payload: Any | None = None # pristine restore for the single `context` var
176
323
  self._nudged: set[str] = set()
324
+ self._index: _Bm25Index | None = None
325
+ self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
177
326
  self._restore_scaffold()
178
327
 
179
328
  def _capture_answer(self, content: Any) -> None:
@@ -187,6 +336,17 @@ class Worker:
187
336
  ns["llm_query_chunked"] = self._llm_query_chunked
188
337
  ns["rlm_query"] = self._rlm_query
189
338
  ns["rlm_query_batched"] = self._rlm_query_batched
339
+ ns["map_files"] = self._map_files
340
+ ns["llm_map_reduce"] = self._llm_map_reduce
341
+ ns["search"] = self._search
342
+ ns["grep_context"] = self._grep_context
343
+ ns["outline"] = self._outline
344
+ # env_tips memo (paper App. C.3): "If a value isn't in `answers`, it doesn't exist."
345
+ # Re-created only when deleted — contents must survive every turn.
346
+ if not isinstance(ns.get("answers"), dict):
347
+ ns["answers"] = {}
348
+ if not isinstance(ns.get("plan"), dict):
349
+ ns["plan"] = {}
190
350
  ns["advance_phase"] = self._advance_phase
191
351
  ns["save_artifact"] = self._save_artifact
192
352
  ns["ask_user_question"] = self._ask_user_question
@@ -303,6 +463,200 @@ class Worker:
303
463
  results.extend(self._llm_query_batched(batch, model))
304
464
  return results
305
465
 
466
+ # ---- deterministic retrieval (no sub-LLM calls, no root tokens) -----------------------
467
+
468
+ def _entries(self) -> list[tuple[str, str]]:
469
+ return _context_entries(self.ns.get("context"))
470
+
471
+ def _get_index(self) -> _Bm25Index:
472
+ """Build the BM25 index on first use; rebuild when `context` was replaced or resized.
473
+
474
+ Identity+length is a cheap stamp that catches the two ways context actually changes:
475
+ load_library() extending the list, and the model re-binding the name. In-place edits
476
+ that preserve length are not detected — documented, and rare in practice.
477
+ """
478
+ ctx = self.ns.get("context")
479
+ stamp = (id(ctx), len(ctx) if isinstance(ctx, (list, str)) else 0)
480
+ if self._index is None or self._index_stamp != stamp:
481
+ self._index = _Bm25Index(self._entries())
482
+ self._index_stamp = stamp
483
+ return self._index
484
+
485
+ def _search(self, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
486
+ """Rank `context` windows against a natural-language query (BM25).
487
+
488
+ Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
489
+ named files out of `context` and delegating them to llm_query / map_files.
490
+ """
491
+ terms = _tokenize(str(query))
492
+ if not terms:
493
+ return []
494
+ try:
495
+ limit = max(1, min(int(k), 100))
496
+ except (TypeError, ValueError):
497
+ limit = 10
498
+ return self._get_index().query(terms, limit, path_glob)
499
+
500
+ def _grep_context(
501
+ self,
502
+ pattern: str,
503
+ k: int = 50,
504
+ path_glob: str | None = None,
505
+ before: int = 0,
506
+ after: int = 0,
507
+ ) -> dict[str, Any]:
508
+ """Regex over `context`, capped and shaped.
509
+
510
+ Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
511
+ `counts` is complete even when `hits` is capped, so a wide pattern reports its shape
512
+ instead of flooding stdout.
513
+ """
514
+ try:
515
+ rx = re.compile(pattern)
516
+ except re.error as e:
517
+ return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
518
+ try:
519
+ limit = max(1, min(int(k), _GREP_HARD_CAP))
520
+ except (TypeError, ValueError):
521
+ limit = 50
522
+ pad_before = max(0, min(int(before or 0), 10))
523
+ pad_after = max(0, min(int(after or 0), 10))
524
+
525
+ hits: list[dict[str, Any]] = []
526
+ counts: dict[str, int] = {}
527
+ total = 0
528
+ for path, content in self._entries():
529
+ if path_glob and not fnmatch.fnmatch(path, path_glob):
530
+ continue
531
+ if not rx.search(content):
532
+ continue
533
+ lines = content.split("\n")
534
+ for i, line in enumerate(lines):
535
+ if not rx.search(line):
536
+ continue
537
+ total += 1
538
+ counts[path] = counts.get(path, 0) + 1
539
+ if len(hits) >= limit:
540
+ continue
541
+ lo = max(0, i - pad_before)
542
+ hi = min(len(lines), i + pad_after + 1)
543
+ hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
544
+ return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
545
+
546
+ def _outline(self, path: str) -> str:
547
+ """Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
548
+
549
+ `path` matches exactly, then by suffix, then as a glob.
550
+ """
551
+ target = str(path)
552
+ entries = self._entries()
553
+ content: str | None = None
554
+ for p, c in entries:
555
+ if p == target:
556
+ content = c
557
+ break
558
+ if content is None:
559
+ for p, c in entries:
560
+ if p.endswith(target) or fnmatch.fnmatch(p, target):
561
+ content = c
562
+ target = p
563
+ break
564
+ if content is None:
565
+ return f"Error: no context file matching {path!r} — use search() or list paths from context"
566
+ out: list[str] = [f"# {target}"]
567
+ for i, line in enumerate(content.split("\n")):
568
+ if _OUTLINE_LINE.match(line):
569
+ out.append(f"{i + 1}: {line.strip()[:160]}")
570
+ if len(out) == 1:
571
+ return f"# {target}\n(no definition-like lines found)"
572
+ return "\n".join(out)
573
+
574
+ # ---- one-line delegation (structural: orchestrating must be easier than solving) -------
575
+
576
+ def _map_files(self, files: Any, prompt: str, model: str | None = None) -> dict[str, str]:
577
+ """Ask `prompt` of every given file, batched, and return {path: answer}.
578
+
579
+ `files` accepts context entries (dicts), paths (strings), or a mix — the whole
580
+ chunk/batch/collect loop the system prompt used to spell out, as one call.
581
+ Oversized files are split and their per-chunk answers joined.
582
+ """
583
+ prompt = str(prompt)
584
+ by_path: list[tuple[str, str]] = []
585
+ lookup: dict[str, str] | None = None
586
+ for item in files if isinstance(files, (list, tuple)) else [files]:
587
+ if isinstance(item, dict):
588
+ content = item.get("content", "")
589
+ by_path.append((str(item.get("path", "?")), content if isinstance(content, str) else str(content)))
590
+ elif isinstance(item, str):
591
+ if lookup is None:
592
+ lookup = {p: c for p, c in self._entries()}
593
+ if item in lookup:
594
+ by_path.append((item, lookup[item]))
595
+ else:
596
+ by_path.append((item, ""))
597
+ if not by_path:
598
+ return {}
599
+
600
+ # Per-file prompt budget; anything larger is chunked and its answers concatenated.
601
+ budget = self.max_prompt_chars - len(prompt) - _CHUNK_HEADER_OVERHEAD - 256
602
+ if budget < 1_000:
603
+ return {p: "Error: prompt too long to leave room for file content" for p, _ in by_path}
604
+
605
+ requests: list[str] = []
606
+ spans: list[tuple[str, int]] = [] # (path, number of chunks contributed)
607
+ for path, content in by_path:
608
+ chunks = _chunk_text(content, budget) if len(content) > budget else [content]
609
+ spans.append((path, len(chunks)))
610
+ for j, chunk in enumerate(chunks):
611
+ header = f"[file {path}" + (f", part {j + 1}/{len(chunks)}]" if len(chunks) > 1 else "]")
612
+ requests.append(f"{prompt}\n\n{header}\n{chunk}")
613
+
614
+ responses: list[str] = []
615
+ for i in range(0, len(requests), _MAX_CHUNK_BATCH):
616
+ responses.extend(self._llm_query_batched(requests[i:i + _MAX_CHUNK_BATCH], model))
617
+
618
+ out: dict[str, str] = {}
619
+ cursor = 0
620
+ for path, count in spans:
621
+ part = responses[cursor:cursor + count]
622
+ cursor += count
623
+ out[path] = part[0] if count == 1 and part else "\n\n".join(part)
624
+ return out
625
+
626
+ def _llm_map_reduce(
627
+ self,
628
+ items: Any,
629
+ map_prompt: str,
630
+ reduce_prompt: str,
631
+ model: str | None = None,
632
+ ) -> str:
633
+ """Map `map_prompt` over `items` in one batch, then reduce the answers with one call.
634
+
635
+ The paper's canonical strategy ("query an LLM per chunk ... then query an LLM with all
636
+ the buffers") as a single call, so the root never hand-rolls the loop.
637
+ """
638
+ map_prompt, reduce_prompt = str(map_prompt), str(reduce_prompt)
639
+ seq = list(items) if isinstance(items, (list, tuple)) else [items]
640
+ if not seq:
641
+ return "Error: llm_map_reduce got no items"
642
+ texts = [
643
+ (str(it.get("content", "")) if isinstance(it, dict) else str(it))
644
+ for it in seq
645
+ ]
646
+ labels = [
647
+ (str(it.get("path", f"item {i + 1}")) if isinstance(it, dict) else f"item {i + 1}")
648
+ for i, it in enumerate(seq)
649
+ ]
650
+ mapped: list[str] = []
651
+ for i in range(0, len(texts), _MAX_CHUNK_BATCH):
652
+ batch = [
653
+ f"{map_prompt}\n\n[{labels[i + j]}]\n{t}"
654
+ for j, t in enumerate(texts[i:i + _MAX_CHUNK_BATCH])
655
+ ]
656
+ mapped.extend(self._llm_query_batched(batch, model))
657
+ joined = "\n\n".join(f"[{labels[i]}]\n{a}" for i, a in enumerate(mapped))
658
+ return self._llm_query(f"{reduce_prompt}\n\nPartial answers:\n{joined}", model)
659
+
306
660
  def _rlm_query(self, prompt: str, model: str | None = None) -> str:
307
661
  r = self._rpc("rlm_query", {"prompt": str(prompt), "model": model})
308
662
  return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
@@ -20,7 +20,7 @@ export function generateRunId(
20
20
  now: Date = new Date(),
21
21
  suffix: string = randomBytes(RUN_ID_SUFFIX_BYTES).toString("hex"),
22
22
  ): string {
23
- const pad = (n: number) => String(n).padStart(2, "0");
23
+ const pad = (n: number): string => String(n).padStart(2, "0");
24
24
  const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
25
25
  return `${iso.slice(0, ISO_DATETIME_LENGTH).replaceAll(":", "-").replace("T", "_")}-${suffix}`;
26
26
  }
@@ -12,8 +12,12 @@ import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
12
12
  import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
13
13
  import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
14
14
 
15
- /** Every well-formed row, in trail order. Malformed line → one warn, skipped. */
16
- export async function readRows(cwd: string, dir: string, runId: string): Promise<Row[]> {
15
+ /**
16
+ * Raw JSONL lines of a run's trail, in order. Missing or unreadable file → [].
17
+ * Shared by the fail-soft reader here and the hole-detecting reader in resume.ts, which
18
+ * differ only in how they treat a bad line.
19
+ */
20
+ export async function readTrailLines(cwd: string, dir: string, runId: string): Promise<string[]> {
17
21
  const path = trailPath(cwd, dir, runId);
18
22
  if (!await pathExists(path)) return [];
19
23
  const content = await failSoft(
@@ -21,10 +25,14 @@ export async function readRows(cwd: string, dir: string, runId: string): Promise
21
25
  undefined as string | undefined,
22
26
  );
23
27
  const trimmed = content?.trim();
24
- if (!trimmed) return [];
28
+ return trimmed ? trimmed.split("\n") : [];
29
+ }
25
30
 
31
+ /** Every well-formed row, in trail order. Malformed line → one warn, skipped. */
32
+ export async function readRows(cwd: string, dir: string, runId: string): Promise<Row[]> {
33
+ const lines = await readTrailLines(cwd, dir, runId);
26
34
  const rows: Row[] = [];
27
- for (const line of trimmed.split("\n")) {
35
+ for (const line of lines) {
28
36
  try {
29
37
  const row = JSON.parse(line) as unknown;
30
38
  if (isRow(row)) rows.push(row);
@@ -7,11 +7,10 @@
7
7
  * garbage from a crash is tolerated.
8
8
  */
9
9
 
10
- import { readFile } from "node:fs/promises";
11
10
  import { type ChatMsg } from "../bridge/model.ts";
12
11
  import { appendUserMessage } from "../core/history.ts";
13
12
  import { buildTurnPrompt } from "../prompts/user.ts";
14
- import { readHeader } from "./reads.ts";
13
+ import { readHeader, readTrailLines } from "./reads.ts";
15
14
  import {
16
15
  isCompaction,
17
16
  isHeader,
@@ -24,8 +23,8 @@ import {
24
23
  type Row,
25
24
  type RunHeader,
26
25
  } from "./rows.ts";
27
- import { trailPath, snapshotPath } from "./paths.ts";
28
- import { failSoft, pathExists } from "./internal.ts";
26
+ import { snapshotPath } from "./paths.ts";
27
+ import { pathExists } from "./internal.ts";
29
28
 
30
29
  /** Artifact path + supersede flag reconstructed from phase rows. */
31
30
  export interface PhaseReconArtifact {
@@ -63,15 +62,10 @@ export type ReconstructResult =
63
62
 
64
63
  /** QB: single read + parse — detects mid-file holes without reading the trail twice. */
65
64
  async function readRowsStrict(cwd: string, dir: string, runId: string): Promise<{ readonly rows: Row[]; readonly hole: boolean }> {
66
- const path = trailPath(cwd, dir, runId);
67
- if (!await pathExists(path)) return { rows: [], hole: false };
68
- const content = await failSoft(() => readFile(path, "utf-8"), undefined as string | undefined);
69
- const trimmed = content?.trim();
70
- if (!trimmed) return { rows: [], hole: false };
71
-
65
+ const lines = await readTrailLines(cwd, dir, runId);
72
66
  const rows: Row[] = [];
73
67
  let sawBad = false;
74
- for (const line of trimmed.split("\n")) {
68
+ for (const line of lines) {
75
69
  try {
76
70
  const row = JSON.parse(line) as unknown;
77
71
  if (!isRow(row)) {
@@ -19,12 +19,6 @@ export function findReplBlocks(text: string): string[] {
19
19
  return blocks;
20
20
  }
21
21
 
22
- /** True if the response contains at least one runnable ```repl``` block. */
23
- export function hasReplBlock(text: string): boolean {
24
- FENCE.lastIndex = 0;
25
- return FENCE.test(text);
26
- }
27
-
28
22
  /** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
29
23
  export function truncateOutput(text: string, limit = 20_000): string {
30
24
  if (text.length <= limit) return text;