@hicaru/pi-rlm 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +6 -17
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +55 -335
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +23 -12
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -407
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +8 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/{worker.py → py/worker.py} +76 -696
  30. package/src/sandbox/sandbox-manager.ts +13 -0
  31. package/src/sandbox/sandbox.ts +99 -193
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/repl-details.ts +2 -2
  34. package/src/tool/repl-render.ts +58 -0
  35. package/src/tool/repl-result.ts +70 -0
  36. package/src/tool/repl-tool.ts +37 -159
  37. package/src/tool/rlm-aggregator.ts +2 -10
  38. package/src/tool/rlm-details.ts +0 -2
  39. package/src/tool/rlm-events.ts +0 -14
  40. package/src/tool/rlm-tool.ts +1 -12
  41. package/src/ui/config-panel.ts +4 -16
  42. package/src/ui/intro.ts +1 -2
  43. package/src/ui/model-picker.ts +34 -10
  44. package/src/ui/status.ts +3 -7
  45. package/src/util/concurrency.ts +9 -5
  46. package/src/bridge/fallback-todo.ts +0 -148
  47. package/src/bridge/interactive.ts +0 -65
  48. package/src/bridge/pi-interactive.ts +0 -41
  49. package/src/core/artifacts.ts +0 -89
  50. package/src/core/critique.ts +0 -92
  51. package/src/core/gates.ts +0 -301
  52. package/src/core/pipeline-handlers.ts +0 -319
  53. package/src/core/pipeline.ts +0 -268
  54. package/src/prompts/phases.ts +0 -104
  55. package/src/state/index.ts +0 -24
  56. package/src/state/internal.ts +0 -46
  57. package/src/state/paths.ts +0 -44
  58. package/src/state/reads.ts +0 -133
  59. package/src/state/resume.ts +0 -173
  60. package/src/state/rows.ts +0 -123
  61. package/src/state/writes.ts +0 -58
@@ -0,0 +1,265 @@
1
+ """Deterministic retrieval over `context` — free: no sub-LLM call, no root tokens.
2
+
3
+ `search` / `grep_context` / `outline` are what the prompt tells the model to reach for BEFORE
4
+ delegating anything, so they carry no worker state: each takes the already-materialised
5
+ `entries` list and returns pointers, never file bodies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import heapq
12
+ import math
13
+ import re
14
+ from typing import Any
15
+
16
+
17
+ _CHUNK_HEADER_OVERHEAD = 64
18
+ _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
19
+ _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
20
+ _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
21
+
22
+
23
+ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
24
+ """Split text into <=chunk_chars pieces, preferring newline boundaries."""
25
+ chunks: list[str] = []
26
+ n = len(text)
27
+ start = 0
28
+ while start < n:
29
+ end = min(start + chunk_chars, n)
30
+ if end < n:
31
+ nl = text.rfind("\n", start, end)
32
+ if nl > start:
33
+ end = nl + 1
34
+ chunks.append(text[start:end])
35
+ start = end
36
+ return chunks
37
+
38
+
39
+ # ---- deterministic retrieval over `context` -----------------------------------------------
40
+ #
41
+ # The RLM paper's trajectories retrieve by having the root model hand-write regex over the
42
+ # context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
43
+ # the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
44
+ # These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
45
+ # spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
46
+
47
+
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
50
+ _SNIPPET_CHARS = 400
51
+ _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
52
+ _BM25_K1 = 1.2
53
+ _BM25_B = 0.75
54
+
55
+ _TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
56
+ _CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
57
+
58
+ # Definition-ish lines across the languages this plugin is likely to meet. Deliberately
59
+ # lexical: an outline is an orientation aid, not a parse tree.
60
+ _OUTLINE_LINE = re.compile(
61
+ r"^\s*(?:"
62
+ r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
63
+ r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
64
+ r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
65
+ r"|def\s+\w+|class\s+\w+"
66
+ r"|func\s+\w+"
67
+ r"|#{1,4}\s+\S"
68
+ r")"
69
+ )
70
+
71
+
72
+ def _tokenize(text: str) -> list[str]:
73
+ """Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
74
+ out: list[str] = []
75
+ for raw in _TOKEN_SPLIT.split(text):
76
+ if not raw:
77
+ continue
78
+ lowered = raw.lower()
79
+ out.append(lowered)
80
+ if len(raw) > 3:
81
+ parts = _CAMEL_SPLIT.split(raw)
82
+ if len(parts) > 1:
83
+ for part in parts:
84
+ piece = part.lower()
85
+ if piece and piece != lowered:
86
+ out.append(piece)
87
+ return out
88
+
89
+
90
+ def _context_entries(context: Any) -> list[tuple[str, str]]:
91
+ """(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
92
+ if isinstance(context, str):
93
+ return [("<context>", context)]
94
+ if not isinstance(context, list):
95
+ return []
96
+ out: list[tuple[str, str]] = []
97
+ for i, item in enumerate(context):
98
+ if isinstance(item, dict):
99
+ content = item.get("content", "")
100
+ out.append((
101
+ str(item.get("path", f"<context[{i}]>")),
102
+ content if isinstance(content, str) else str(content),
103
+ ))
104
+ elif isinstance(item, str):
105
+ out.append((f"<context[{i}]>", item))
106
+ return out
107
+
108
+
109
+ class _Bm25Index:
110
+ """Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
111
+
112
+ __slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
113
+
114
+ def __init__(self, entries: list[tuple[str, str]]) -> None:
115
+ self.paths: list[str] = []
116
+ self.starts: list[int] = []
117
+ self.texts: list[str] = []
118
+ self.postings: dict[str, list[tuple[int, int]]] = {}
119
+ self.doc_len: list[int] = []
120
+ self.truncated = False
121
+
122
+ for path, content in entries:
123
+ if not content:
124
+ continue
125
+ lines = content.split("\n")
126
+ for start in range(0, len(lines), _INDEX_WINDOW_LINES):
127
+ if len(self.texts) >= _INDEX_MAX_WINDOWS:
128
+ self.truncated = True
129
+ break
130
+ window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
131
+ idx = len(self.texts)
132
+ self.paths.append(path)
133
+ self.starts.append(start + 1)
134
+ self.texts.append(window)
135
+ terms = _tokenize(window)
136
+ self.doc_len.append(len(terms))
137
+ freq: dict[str, int] = {}
138
+ for term in terms:
139
+ freq[term] = freq.get(term, 0) + 1
140
+ for term, tf in freq.items():
141
+ self.postings.setdefault(term, []).append((idx, tf))
142
+ if self.truncated:
143
+ break
144
+
145
+ total = len(self.doc_len)
146
+ self.avg_len = (sum(self.doc_len) / total) if total else 1.0
147
+
148
+ def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
149
+ total = len(self.texts)
150
+ if total == 0:
151
+ return []
152
+ scores: dict[int, float] = {}
153
+ for term in set(terms):
154
+ posting = self.postings.get(term)
155
+ if not posting:
156
+ continue
157
+ df = len(posting)
158
+ idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
159
+ for idx, tf in posting:
160
+ norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
161
+ scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
162
+ if path_glob:
163
+ scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
164
+ if not scores:
165
+ return []
166
+ top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
167
+ out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
168
+ for i, (idx, score) in enumerate(top):
169
+ text = self.texts[idx]
170
+ out[i] = {
171
+ "path": self.paths[idx],
172
+ "line": self.starts[idx],
173
+ "score": round(score, 3),
174
+ "snippet": text[:_SNIPPET_CHARS],
175
+ }
176
+ return out
177
+
178
+
179
+ def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
180
+ """Rank `context` windows against a natural-language query (BM25).
181
+
182
+ Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
183
+ named files out of `context` and delegating them to llm_query / map_files.
184
+ """
185
+ terms = _tokenize(str(query))
186
+ if not terms:
187
+ return []
188
+ try:
189
+ limit = max(1, min(int(k), 100))
190
+ except (TypeError, ValueError):
191
+ limit = 10
192
+ return index.query(terms, limit, path_glob)
193
+
194
+ def grep_context(
195
+ entries: list[tuple[str, str]],
196
+ pattern: str,
197
+ k: int = 50,
198
+ path_glob: str | None = None,
199
+ before: int = 0,
200
+ after: int = 0,
201
+ ) -> dict[str, Any]:
202
+ """Regex over `context`, capped and shaped.
203
+
204
+ Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
205
+ `counts` is complete even when `hits` is capped, so a wide pattern reports its shape
206
+ instead of flooding stdout.
207
+ """
208
+ try:
209
+ rx = re.compile(pattern)
210
+ except re.error as e:
211
+ return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
212
+ try:
213
+ limit = max(1, min(int(k), _GREP_HARD_CAP))
214
+ except (TypeError, ValueError):
215
+ limit = 50
216
+ pad_before = max(0, min(int(before or 0), 10))
217
+ pad_after = max(0, min(int(after or 0), 10))
218
+
219
+ hits: list[dict[str, Any]] = []
220
+ counts: dict[str, int] = {}
221
+ total = 0
222
+ for path, content in entries:
223
+ if path_glob and not fnmatch.fnmatch(path, path_glob):
224
+ continue
225
+ if not rx.search(content):
226
+ continue
227
+ lines = content.split("\n")
228
+ for i, line in enumerate(lines):
229
+ if not rx.search(line):
230
+ continue
231
+ total += 1
232
+ counts[path] = counts.get(path, 0) + 1
233
+ if len(hits) >= limit:
234
+ continue
235
+ lo = max(0, i - pad_before)
236
+ hi = min(len(lines), i + pad_after + 1)
237
+ hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
238
+ return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
239
+
240
+ def outline(entries: list[tuple[str, str]], path: str) -> str:
241
+ """Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
242
+
243
+ `path` matches exactly, then by suffix, then as a glob.
244
+ """
245
+ target = str(path)
246
+ content: str | None = None
247
+ for p, c in entries:
248
+ if p == target:
249
+ content = c
250
+ break
251
+ if content is None:
252
+ for p, c in entries:
253
+ if p.endswith(target) or fnmatch.fnmatch(p, target):
254
+ content = c
255
+ target = p
256
+ break
257
+ if content is None:
258
+ return f"Error: no context file matching {path!r} — use search() or list paths from context"
259
+ out: list[str] = [f"# {target}"]
260
+ for i, line in enumerate(content.split("\n")):
261
+ if _OUTLINE_LINE.match(line):
262
+ out.append(f"{i + 1}: {line.strip()[:160]}")
263
+ if len(out) == 1:
264
+ return f"# {target}\n(no definition-like lines found)"
265
+ return "\n".join(out)
@@ -0,0 +1,116 @@
1
+ """Async sub-call plumbing: the `Task` handle, the spawn allow-list, and the reply reducers.
2
+
3
+ A request and its reply are decoupled — `_post` returns a rid without waiting and replies are
4
+ parked by rid — so a Task started in one exec can be awaited in a later one. Each kind of
5
+ sub-call knows how to fold its parked replies back into a result; that is what a reducer is.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def _clean_paths(paths: Any) -> list[str] | None:
14
+ """Normalize a `paths=` argument to a non-empty list of prefixes, or None.
15
+
16
+ Shared by the single and batched rlm_query builders so the accepted shapes stay identical.
17
+ A bare string is treated as one prefix — the most common typo, and harmless to allow.
18
+ """
19
+ if paths is None:
20
+ return None
21
+ seq = [paths] if isinstance(paths, str) else list(paths)
22
+ out = [str(p).strip() for p in seq if str(p).strip()]
23
+ return out or None
24
+
25
+
26
+ def _reduce_one(replies: list[dict[str, Any]]) -> str:
27
+ r = replies[0]
28
+ return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
29
+
30
+
31
+ def _reduce_batch(n: int):
32
+ """Reducer for a single *_query_batched reply of n prompts."""
33
+ def reduce(replies: list[dict[str, Any]]) -> list[str]:
34
+ r = replies[0]
35
+ if r.get("error"):
36
+ return [f"Error: {r['error']}"] * n
37
+ out = r.get("responses")
38
+ if not isinstance(out, list) or len(out) != n:
39
+ return ["Error: malformed batched response"] * n
40
+ return [s if isinstance(s, str) else f"Error: {s}" for s in out]
41
+ return reduce
42
+
43
+
44
+ def _reduce_chunked(sizes: list[int]):
45
+ """Concatenate several llm_query_batched replies back into one flat chunk list."""
46
+ per = [_reduce_batch(n) for n in sizes]
47
+
48
+ def reduce(replies: list[dict[str, Any]]) -> list[str]:
49
+ out: list[str] = []
50
+ for red, rep in zip(per, replies):
51
+ out.extend(red([rep]))
52
+ return out
53
+ return reduce
54
+
55
+
56
+ def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
57
+ """Flatten the batch replies (same as chunked), then regroup them per path.
58
+
59
+ A file larger than the per-prompt budget contributed several requests; its answers rejoin
60
+ in order, which is what makes map_files a {path: answer} dict rather than a flat list.
61
+ """
62
+ flatten = _reduce_chunked(sizes)
63
+
64
+ def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
65
+ responses = flatten(replies)
66
+ out: dict[str, str] = {}
67
+ cursor = 0
68
+ for path, count in spans:
69
+ part = responses[cursor:cursor + count]
70
+ cursor += count
71
+ out[path] = part[0] if count == 1 and part else "\n\n".join(part)
72
+ return out
73
+ return reduce
74
+
75
+
76
+ def _spawnable(name: str):
77
+ """Tag a sync scaffold fn with the request kind spawn() should route it to."""
78
+ def mark(fn):
79
+ fn._rlm_name = name
80
+ return fn
81
+ return mark
82
+
83
+
84
+ class Task:
85
+ """Handle for parent-side work in flight, returned by spawn().
86
+
87
+ Opaque to model code apart from `done` and repr. A Task may be awaited in a later
88
+ ```repl``` block than the one that created it.
89
+ """
90
+
91
+ __slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
92
+
93
+ def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
94
+ self.kind = kind
95
+ self.label = label
96
+ self._worker = worker
97
+ self._rids = tuple(rids)
98
+ self._reduce = reduce
99
+ self._value: Any = None
100
+ self._settled = False
101
+
102
+ @staticmethod
103
+ def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
104
+ """A Task that never hit the wire — validation errors and empty inputs."""
105
+ task = Task(worker, kind, (), lambda _replies: value, label)
106
+ task._value = value
107
+ task._settled = True
108
+ return task
109
+
110
+ @property
111
+ def done(self) -> bool:
112
+ """True once every reply has landed — awaiting will not block."""
113
+ return self._settled or all(r in self._worker.inbox for r in self._rids)
114
+
115
+ def __repr__(self) -> str:
116
+ return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"