@hicaru/pi-rlm 0.2.1 → 0.3.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.
Files changed (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  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 +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. 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 add_context() 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,129 @@
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], 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
+ """
54
+ per = [_reduce_batch(n) for n in sizes]
55
+
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)
59
+ out: list[str] = []
60
+ for red, rep in zip(per, replies):
61
+ if drop_empty:
62
+ out.extend(x for x in red([rep]) if x)
63
+ else:
64
+ out.extend(red([rep]))
65
+ return out
66
+ return reduce
67
+
68
+
69
+ def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
70
+ """Flatten the batch replies (same as chunked), then regroup them per path.
71
+
72
+ A file larger than the per-prompt budget contributed several requests; its answers rejoin
73
+ in order, which is what makes map_files a {path: answer} dict rather than a flat list.
74
+ """
75
+ flatten = _reduce_chunked(sizes, drop_empty=False)
76
+
77
+ def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
78
+ responses = flatten(replies)
79
+ out: dict[str, str] = {}
80
+ cursor = 0
81
+ for path, count in spans:
82
+ part = responses[cursor:cursor + count]
83
+ cursor += count
84
+ out[path] = part[0] if count == 1 and part else "\n\n".join(part)
85
+ return out
86
+ return reduce
87
+
88
+
89
+ def _spawnable(name: str):
90
+ """Tag a sync scaffold fn with the request kind spawn() should route it to."""
91
+ def mark(fn):
92
+ fn._rlm_name = name
93
+ return fn
94
+ return mark
95
+
96
+
97
+ class Task:
98
+ """Handle for parent-side work in flight, returned by spawn().
99
+
100
+ Opaque to model code apart from `done` and repr. A Task may be awaited in a later
101
+ ```repl``` block than the one that created it.
102
+ """
103
+
104
+ __slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
105
+
106
+ def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
107
+ self.kind = kind
108
+ self.label = label
109
+ self._worker = worker
110
+ self._rids = tuple(rids)
111
+ self._reduce = reduce
112
+ self._value: Any = None
113
+ self._settled = False
114
+
115
+ @staticmethod
116
+ def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
117
+ """A Task that never hit the wire — validation errors and empty inputs."""
118
+ task = Task(worker, kind, (), lambda _replies: value, label)
119
+ task._value = value
120
+ task._settled = True
121
+ return task
122
+
123
+ @property
124
+ def done(self) -> bool:
125
+ """True once every reply has landed — awaiting will not block."""
126
+ return self._settled or all(r in self._worker.inbox for r in self._rids)
127
+
128
+ def __repr__(self) -> str:
129
+ return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"