@hicaru/pi-rlm 0.3.19 → 0.3.21
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.
- package/README.md +8 -5
- package/package.json +1 -1
- package/src/bridge/handlers/completion.ts +3 -0
- package/src/bridge/handlers/emitting.ts +0 -4
- package/src/bridge/handlers/rlm-query.ts +3 -3
- package/src/bridge/handlers/task-registry.ts +46 -19
- package/src/bridge/handlers/types.ts +6 -3
- package/src/bridge/model.ts +4 -0
- package/src/commands/rlm.ts +14 -7
- package/src/config/defaults.ts +41 -13
- package/src/config/settings.ts +7 -3
- package/src/config/skillstate.ts +236 -44
- package/src/context/merge.ts +10 -3
- package/src/context/namespace.ts +6 -2
- package/src/context/refresh.ts +32 -11
- package/src/core/answer.ts +15 -0
- package/src/core/budget.ts +39 -17
- package/src/core/compaction.ts +85 -9
- package/src/core/engine.ts +117 -32
- package/src/core/iteration.ts +4 -0
- package/src/core/limits.ts +10 -14
- package/src/core/root-context.ts +83 -19
- package/src/core/root-digest.ts +48 -11
- package/src/core/root-state.ts +39 -12
- package/src/core/run-state.ts +86 -14
- package/src/core/session-archive.ts +174 -0
- package/src/core/types.ts +13 -2
- package/src/index.ts +142 -12
- package/src/mode/rlm-mode.ts +2 -2
- package/src/prompts/glossary.ts +36 -5
- package/src/prompts/native.ts +8 -2
- package/src/prompts/user.ts +6 -4
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +202 -36
- package/src/sandbox/py/scaffold.py +20 -5
- package/src/sandbox/py/worker.py +1 -1
- package/src/sandbox/sandbox-manager.ts +19 -0
- package/src/sandbox/sandbox.ts +13 -1
- package/src/text/parsing.ts +133 -2
- package/src/text/tokens.ts +39 -4
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +38 -2
- package/src/tool/repl-tool.ts +37 -23
- package/src/tool/rlm-aggregator.ts +1 -1
- package/src/tool/rlm-details.ts +1 -2
- package/src/tool/rlm-events.ts +3 -6
- package/src/tool/rlm-tool.ts +1 -1
- package/src/tool/subcall-render.ts +7 -4
- package/src/tool/subcall-store.ts +5 -18
- package/src/ui/config-panel.ts +4 -19
- package/src/ui/intro.ts +1 -1
- package/src/ui/panel/run-registry.ts +2 -2
- package/src/ui/python-highlight.ts +49 -0
- package/src/ui/stage-cards.ts +192 -0
- package/src/ui/tree/tree-model.ts +69 -19
- package/src/ui/tree/tree-rows.ts +2 -1
- package/src/util/abort.ts +34 -0
- package/src/util/bm25.ts +170 -21
- package/src/util/errors.ts +1 -1
package/src/prompts/user.ts
CHANGED
|
@@ -21,10 +21,12 @@ export function buildTurnPrompt(
|
|
|
21
21
|
|
|
22
22
|
/** Asked once when the engine runs out of turns without a submitted answer. Same finalize
|
|
23
23
|
* dialect as the budget wrap-up note (audit M6): answer-ready first, plain text only as an
|
|
24
|
-
* explicit fallback the engine still accepts.
|
|
24
|
+
* explicit fallback the engine still accepts. Deliberately near-unreachable: the cap is
|
|
25
|
+
* large by design (the old 16-cap all-failed oolong mid-retrieval). */
|
|
25
26
|
export const FINALIZE_PROMPT =
|
|
26
27
|
"You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
|
|
27
28
|
"(fenced ```repl```) with your best final answer from everything you have gathered. " +
|
|
29
|
+
"Finalize ONLY through `answer` — no FINAL(...) prose and no ```state fence in this reply. " +
|
|
28
30
|
"Only if the REPL is unavailable, answer as plain text.";
|
|
29
31
|
|
|
30
32
|
/** One-shot retrieval-discipline nudge (the engine owns the when — see core/engine.ts). The
|
|
@@ -43,9 +45,9 @@ export const REASONING_BUDGET_HINT =
|
|
|
43
45
|
"the completion budget with the answer, so long thought may be cut off mid-reasoning. " +
|
|
44
46
|
"Keep thought concise, or raise rootSampling.maxTokens.";
|
|
45
47
|
|
|
46
|
-
/** One-shot verification-discipline nudge (default
|
|
47
|
-
* the root finalizes suspiciously early with a bare
|
|
48
|
-
* coached redo instead of having the answer accepted. */
|
|
48
|
+
/** One-shot verification-discipline nudge (default ON — enableVerificationNudge): fired when
|
|
49
|
+
* the root finalizes suspiciously early with a bare answer, or without having inspected its
|
|
50
|
+
* context at all. The model gets ONE coached redo instead of having the answer accepted. */
|
|
49
51
|
export const VERIFICATION_NUDGE =
|
|
50
52
|
"[coach] That answer was submitted suspiciously early and looks under-verified. Before " +
|
|
51
53
|
"finalizing: recompute the key quantity inside a ```repl block (show the actual computation, " +
|
|
Binary file
|
|
Binary file
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
`search` / `grep_context` / `outline` are what the prompt tells the model to reach for BEFORE
|
|
4
4
|
delegating anything, so they carry no worker state: each takes the already-materialised
|
|
5
5
|
`entries` list and returns pointers, never file bodies.
|
|
6
|
+
|
|
7
|
+
BM25 V2 (twin: util/bm25.ts — keep the two files in lockstep, AGENTS.md duality convention):
|
|
8
|
+
light suffix stemming, bigram phrase bonus, and pseudo-relevance-feedback query expansion
|
|
9
|
+
(Rocchio without embeddings — the LLM Agent Memory Survey's query-reformulation line, fully
|
|
10
|
+
deterministic). Windows overlap by half so evidence straddling a boundary stops diluting.
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
from __future__ import annotations
|
|
@@ -70,6 +75,7 @@ def _snippet_window(text: str, terms: set[str]) -> str:
|
|
|
70
75
|
|
|
71
76
|
|
|
72
77
|
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
78
|
+
_INDEX_WINDOW_STRIDE = 20 # windows overlap by half so boundary-straddled evidence survives
|
|
73
79
|
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
|
|
74
80
|
_SNIPPET_CHARS = 400
|
|
75
81
|
_SNIPPET_LEAD = 100 # chars of lead-in kept before the earliest matched term
|
|
@@ -77,6 +83,16 @@ _GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whateve
|
|
|
77
83
|
_BM25_K1 = 1.2
|
|
78
84
|
_BM25_B = 0.75
|
|
79
85
|
|
|
86
|
+
# BM25 V2 constants (twin: util/bm25.ts — identical values there).
|
|
87
|
+
_PRF_FEEDBACK_DOCS = 3 # top first-pass windows harvested for expansion terms
|
|
88
|
+
_PRF_EXPANSION_TERMS = 8 # max terms added by pseudo-relevance feedback
|
|
89
|
+
_PRF_EXPANSION_WEIGHT = 0.4 # Rocchio beta: expansion terms contribute at this weight
|
|
90
|
+
_PRF_MIN_DOCS = 12 # below this the corpus is too small to harvest from
|
|
91
|
+
_PHRASE_BONUS_WEIGHT = 0.25 # per adjacent query-bigram occurrence in a window
|
|
92
|
+
_RERANK_POOL_MULT = 3 # phrase-bonus pool = top (k * mult) windows, capped
|
|
93
|
+
_RERANK_POOL_CAP = 60
|
|
94
|
+
_STEM_MIN_LEN = 4 # tokens shorter than this are never stemmed
|
|
95
|
+
|
|
80
96
|
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
81
97
|
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
82
98
|
|
|
@@ -94,21 +110,63 @@ _OUTLINE_LINE = re.compile(
|
|
|
94
110
|
)
|
|
95
111
|
|
|
96
112
|
|
|
113
|
+
def _stem(t: str) -> str:
|
|
114
|
+
"""Light deterministic suffix stripper (TWIN: util/bm25.ts `stem` — identical rules).
|
|
115
|
+
|
|
116
|
+
A matching aid, not linguistics: different surface forms converge (files/file → fil,
|
|
117
|
+
running/run → run, studies/study → studi); identical forms always map to themselves.
|
|
118
|
+
Final `y`→`i` and trailing-`e` deletion are what make the folds meet.
|
|
119
|
+
"""
|
|
120
|
+
if len(t) <= 3:
|
|
121
|
+
return t
|
|
122
|
+
if t.endswith("ies"):
|
|
123
|
+
r = t[:-3] + "i" # studies → studi
|
|
124
|
+
elif t.endswith("sses"):
|
|
125
|
+
r = t[:-2] # classes → class
|
|
126
|
+
elif t.endswith("es"):
|
|
127
|
+
stem2 = t[:-2]
|
|
128
|
+
if stem2.endswith(("x", "ch", "sh")):
|
|
129
|
+
r = stem2 # boxes → box, matches → match
|
|
130
|
+
else:
|
|
131
|
+
r = t[:-1] # files → file
|
|
132
|
+
elif t.endswith("s") and not t.endswith(("ss", "us", "is")):
|
|
133
|
+
r = t[:-1] if len(t) > 4 else t # cats → cat; keeps "was"/"its"
|
|
134
|
+
else:
|
|
135
|
+
r = t
|
|
136
|
+
if r.endswith("ing") and len(r) >= 6:
|
|
137
|
+
base = r[:-3] # running → runn
|
|
138
|
+
if len(base) >= 4:
|
|
139
|
+
if len(base) >= 2 and base[-1] == base[-2]:
|
|
140
|
+
base = base[:-1] # runn → run
|
|
141
|
+
r = base # "string" stays whole
|
|
142
|
+
elif r.endswith("ed") and len(r) >= 5:
|
|
143
|
+
base = r[:-2] # mapped → mapp
|
|
144
|
+
if len(base) >= 4:
|
|
145
|
+
if len(base) >= 2 and base[-1] == base[-2]:
|
|
146
|
+
base = base[:-1] # mapp → map
|
|
147
|
+
r = base
|
|
148
|
+
if r.endswith("y") and len(r) > 3:
|
|
149
|
+
r = r[:-1] + "i" # study → studi (meets studies)
|
|
150
|
+
if r.endswith("e") and len(r) > 3:
|
|
151
|
+
r = r[:-1] # file → fil (meets files)
|
|
152
|
+
return r if len(r) >= 3 else t
|
|
153
|
+
|
|
154
|
+
|
|
97
155
|
def _tokenize(text: str) -> list[str]:
|
|
98
|
-
"""
|
|
156
|
+
"""Lowered alphanumeric runs + camelCase parts, all stemmed (`resolveModelId` ↔ model ids)."""
|
|
99
157
|
out: list[str] = []
|
|
100
158
|
for raw in _TOKEN_SPLIT.split(text):
|
|
101
159
|
if not raw:
|
|
102
160
|
continue
|
|
103
161
|
lowered = raw.lower()
|
|
104
|
-
out.append(lowered)
|
|
162
|
+
out.append(_stem(lowered))
|
|
105
163
|
if len(raw) > 3:
|
|
106
164
|
parts = _CAMEL_SPLIT.split(raw)
|
|
107
165
|
if len(parts) > 1:
|
|
108
166
|
for part in parts:
|
|
109
167
|
piece = part.lower()
|
|
110
168
|
if piece and piece != lowered:
|
|
111
|
-
out.append(piece)
|
|
169
|
+
out.append(_stem(piece))
|
|
112
170
|
return out
|
|
113
171
|
|
|
114
172
|
|
|
@@ -132,13 +190,14 @@ def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
|
132
190
|
|
|
133
191
|
|
|
134
192
|
class _Bm25Index:
|
|
135
|
-
"""Okapi BM25 over fixed-line windows of `context`. Built lazily,
|
|
193
|
+
"""Okapi BM25 over overlapping fixed-line windows of `context`. Built lazily, rebuilt on change."""
|
|
136
194
|
|
|
137
|
-
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
195
|
+
__slots__ = ("paths", "starts", "ends", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
138
196
|
|
|
139
197
|
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
140
198
|
self.paths: list[str] = []
|
|
141
199
|
self.starts: list[int] = []
|
|
200
|
+
self.ends: list[int] = []
|
|
142
201
|
self.texts: list[str] = []
|
|
143
202
|
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
144
203
|
self.doc_len: list[int] = []
|
|
@@ -148,14 +207,19 @@ class _Bm25Index:
|
|
|
148
207
|
if not content:
|
|
149
208
|
continue
|
|
150
209
|
lines = content.split("\n")
|
|
151
|
-
for start in range(0, len(lines),
|
|
210
|
+
for start in range(0, len(lines), _INDEX_WINDOW_STRIDE):
|
|
211
|
+
window_lines = lines[start:start + _INDEX_WINDOW_LINES]
|
|
212
|
+
# The tail window fully covered by the previous one adds nothing — skip it.
|
|
213
|
+
if start > 0 and len(window_lines) <= _INDEX_WINDOW_STRIDE:
|
|
214
|
+
break
|
|
152
215
|
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
153
216
|
self.truncated = True
|
|
154
217
|
break
|
|
155
|
-
window = "\n".join(
|
|
218
|
+
window = "\n".join(window_lines)
|
|
156
219
|
idx = len(self.texts)
|
|
157
220
|
self.paths.append(path)
|
|
158
221
|
self.starts.append(start + 1)
|
|
222
|
+
self.ends.append(start + len(window_lines))
|
|
159
223
|
self.texts.append(window)
|
|
160
224
|
terms = _tokenize(window)
|
|
161
225
|
self.doc_len.append(len(terms))
|
|
@@ -170,46 +234,98 @@ class _Bm25Index:
|
|
|
170
234
|
total = len(self.doc_len)
|
|
171
235
|
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
172
236
|
|
|
173
|
-
def
|
|
237
|
+
def idf(self, term: str) -> float:
|
|
238
|
+
total = len(self.doc_len)
|
|
239
|
+
if total == 0:
|
|
240
|
+
return 0.0
|
|
241
|
+
df = len(self.postings.get(term, ()))
|
|
242
|
+
return math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
243
|
+
|
|
244
|
+
def glob_allow(self, path_glob: str | None) -> set[int] | None:
|
|
245
|
+
"""Pre-filter: window indices whose path matches the glob (None = everything).
|
|
246
|
+
|
|
247
|
+
Pre-filtering before scoring (not after) so glob-matched windows outside the global
|
|
248
|
+
top-k still surface — a post-scoring filter could return fewer than k.
|
|
249
|
+
"""
|
|
250
|
+
if not path_glob:
|
|
251
|
+
return None
|
|
252
|
+
return {i for i, p in enumerate(self.paths) if fnmatch.fnmatch(p, path_glob)}
|
|
253
|
+
|
|
254
|
+
def score(self, weights: dict[str, float], allowed: set[int] | None) -> dict[int, float]:
|
|
255
|
+
"""Weighted Okapi accumulation: scores[idx] += weight * idf * tf-sat (set-ordered input)."""
|
|
174
256
|
total = len(self.texts)
|
|
175
257
|
if total == 0:
|
|
176
|
-
return
|
|
258
|
+
return {}
|
|
177
259
|
scores: dict[int, float] = {}
|
|
178
|
-
for term in
|
|
260
|
+
for term, weight in weights.items():
|
|
179
261
|
posting = self.postings.get(term)
|
|
180
262
|
if not posting:
|
|
181
263
|
continue
|
|
182
|
-
|
|
183
|
-
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
264
|
+
idf = self.idf(term)
|
|
184
265
|
for idx, tf in posting:
|
|
266
|
+
if allowed is not None and idx not in allowed:
|
|
267
|
+
continue
|
|
185
268
|
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
186
|
-
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
269
|
+
scores[idx] = scores.get(idx, 0.0) + weight * idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
270
|
+
return scores
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _top(scores: dict[int, float], n: int) -> list[tuple[int, float]]:
|
|
274
|
+
"""Top-n by (score desc, idx asc) — the deterministic tie-break the TS twin mirrors."""
|
|
275
|
+
return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:n]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _expansion_terms(index: _Bm25Index, feedback: list[int], exclude: set[str]) -> list[str]:
|
|
279
|
+
"""Pseudo-relevance-feedback terms: tf-in-feedback × global idf, best first (twin-mirrored).
|
|
280
|
+
|
|
281
|
+
Harvested from the top first-pass windows; deterministic ties break by term codepoints.
|
|
282
|
+
"""
|
|
283
|
+
tf: dict[str, int] = {}
|
|
284
|
+
for idx in feedback:
|
|
285
|
+
for term in _tokenize(index.texts[idx]):
|
|
286
|
+
tf[term] = tf.get(term, 0) + 1
|
|
287
|
+
scored: list[tuple[float, str]] = []
|
|
288
|
+
for term, f in tf.items():
|
|
289
|
+
if term in exclude:
|
|
290
|
+
continue
|
|
291
|
+
scored.append((f * index.idf(term), term))
|
|
292
|
+
scored.sort(key=lambda st: (-st[0], st[1]))
|
|
293
|
+
return [term for _, term in scored[:_PRF_EXPANSION_TERMS]]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _phrase_bigrams(terms: list[str]) -> list[tuple[str, str]]:
|
|
297
|
+
seen: set[tuple[str, str]] = set()
|
|
298
|
+
out: list[tuple[str, str]] = []
|
|
299
|
+
for a, b in zip(terms, terms[1:]):
|
|
300
|
+
if a != b and (a, b) not in seen:
|
|
301
|
+
seen.add((a, b))
|
|
302
|
+
out.append((a, b))
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _adjacent(index: _Bm25Index, idx: int, bigrams: list[tuple[str, str]]) -> float:
|
|
307
|
+
"""Phrase bonus for one window: weight × max-idf per query bigram occurring adjacently."""
|
|
308
|
+
if not bigrams:
|
|
309
|
+
return 0.0
|
|
310
|
+
toks = _tokenize(index.texts[idx])
|
|
311
|
+
pos: dict[str, list[int]] = {}
|
|
312
|
+
for i, t in enumerate(toks):
|
|
313
|
+
pos.setdefault(t, []).append(i)
|
|
314
|
+
bonus = 0.0
|
|
315
|
+
for a, b in bigrams:
|
|
316
|
+
pa, pb = pos.get(a), pos.get(b)
|
|
317
|
+
if pa and pb and any(x + 1 == y for x in pa for y in pb):
|
|
318
|
+
bonus += _PHRASE_BONUS_WEIGHT * max(index.idf(a), index.idf(b))
|
|
319
|
+
return bonus
|
|
206
320
|
|
|
207
321
|
|
|
208
322
|
def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
209
|
-
"""Rank `context` windows against a natural-language query (BM25).
|
|
323
|
+
"""Rank `context` windows against a natural-language query (BM25 V2 pipeline).
|
|
210
324
|
|
|
211
|
-
|
|
212
|
-
|
|
325
|
+
Pipeline: weighted scoring → PRF expansion (corpora ≥ _PRF_MIN_DOCS windows) → phrase
|
|
326
|
+
bonus over a re-rank pool → top-k → adjacent-window merge. Returns [{path, line, score,
|
|
327
|
+
snippet, text}] — pointers, not bodies; merged spans add `end`, an over-cap index adds
|
|
328
|
+
`index_truncated`. `text` aliases `snippet` (same as grep_context hits).
|
|
213
329
|
"""
|
|
214
330
|
terms = _tokenize(str(query))
|
|
215
331
|
if not terms:
|
|
@@ -218,7 +334,57 @@ def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int
|
|
|
218
334
|
limit = max(1, min(int(k), 100))
|
|
219
335
|
except (TypeError, ValueError):
|
|
220
336
|
limit = 10
|
|
221
|
-
|
|
337
|
+
allowed = index.glob_allow(path_glob)
|
|
338
|
+
weights: dict[str, float] = {}
|
|
339
|
+
for t in terms:
|
|
340
|
+
weights.setdefault(t, 1.0)
|
|
341
|
+
scores = index.score(weights, allowed)
|
|
342
|
+
if len(index.doc_len) >= _PRF_MIN_DOCS and scores:
|
|
343
|
+
feedback = [idx for idx, _ in _top(scores, _PRF_FEEDBACK_DOCS)]
|
|
344
|
+
expansion = _expansion_terms(index, feedback, set(weights))
|
|
345
|
+
if expansion:
|
|
346
|
+
for t in expansion:
|
|
347
|
+
weights[t] = _PRF_EXPANSION_WEIGHT
|
|
348
|
+
scores = index.score(weights, allowed)
|
|
349
|
+
if not scores:
|
|
350
|
+
return []
|
|
351
|
+
pool_n = min(len(scores), _RERANK_POOL_CAP, max(limit * _RERANK_POOL_MULT, _PRF_FEEDBACK_DOCS))
|
|
352
|
+
pool = _top(scores, pool_n)
|
|
353
|
+
bigrams = _phrase_bigrams(terms)
|
|
354
|
+
if bigrams:
|
|
355
|
+
boosted = [(idx, score + _adjacent(index, idx, bigrams)) for idx, score in pool]
|
|
356
|
+
boosted.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
357
|
+
pool = boosted
|
|
358
|
+
top = pool[:limit]
|
|
359
|
+
|
|
360
|
+
# Adjacent same-path windows merge into one wider hit (diversity: one file cannot flood k).
|
|
361
|
+
merged: list[dict[str, Any]] = []
|
|
362
|
+
for idx, score in top:
|
|
363
|
+
path = index.paths[idx]
|
|
364
|
+
start = index.starts[idx]
|
|
365
|
+
end = index.ends[idx]
|
|
366
|
+
if merged and merged[-1]["path"] == path and start <= int(merged[-1]["end"]) + 1:
|
|
367
|
+
merged[-1]["end"] = max(int(merged[-1]["end"]), end)
|
|
368
|
+
if score > float(merged[-1]["score"]):
|
|
369
|
+
snip = _snippet_window(index.texts[idx], set(terms))
|
|
370
|
+
merged[-1]["snippet"] = snip
|
|
371
|
+
merged[-1]["text"] = snip
|
|
372
|
+
merged[-1]["score"] = round(max(float(merged[-1]["score"]), score), 3)
|
|
373
|
+
else:
|
|
374
|
+
snip = _snippet_window(index.texts[idx], set(terms))
|
|
375
|
+
merged.append({
|
|
376
|
+
"path": path,
|
|
377
|
+
"line": start,
|
|
378
|
+
"end": end,
|
|
379
|
+
"score": round(score, 3),
|
|
380
|
+
"snippet": snip,
|
|
381
|
+
"text": snip,
|
|
382
|
+
})
|
|
383
|
+
if index.truncated:
|
|
384
|
+
for hit in merged:
|
|
385
|
+
hit["index_truncated"] = True
|
|
386
|
+
return merged
|
|
387
|
+
|
|
222
388
|
|
|
223
389
|
def grep_context(
|
|
224
390
|
entries: list[tuple[str, str]],
|
|
@@ -261,15 +261,30 @@ class WorkerScaffold:
|
|
|
261
261
|
def _entries(self) -> list[tuple[str, str]]:
|
|
262
262
|
return _context_entries(self.ns.get("context"))
|
|
263
263
|
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _fingerprint(entries: list[tuple[str, str]]) -> int:
|
|
266
|
+
"""FNV-1a over per-entry (path, length, head-256, tail-256) — catches in-place edits.
|
|
267
|
+
|
|
268
|
+
Sampling keeps this O(entries) even for 64MB payloads; a mid-file same-length edit that
|
|
269
|
+
dodges both sampled ends is accepted residual risk (the old id+length stamp missed ALL
|
|
270
|
+
same-length edits, not just unsampled ones).
|
|
271
|
+
"""
|
|
272
|
+
h = 0x811C9DC5
|
|
273
|
+
for path, content in entries:
|
|
274
|
+
sample = path + "\x00" + content[:256] + "\x00" + (content[-256:] if len(content) > 256 else "")
|
|
275
|
+
for byte in sample.encode("utf-8", "replace"):
|
|
276
|
+
h = ((h ^ byte) * 0x01000193) & 0xFFFFFFFF
|
|
277
|
+
h = ((h ^ (len(content) & 0xFFFFFFFF)) * 0x01000193) & 0xFFFFFFFF
|
|
278
|
+
return h
|
|
279
|
+
|
|
264
280
|
def _get_index(self) -> _Bm25Index:
|
|
265
|
-
"""Build the BM25 index on first use; rebuild when `context`
|
|
281
|
+
"""Build the BM25 index on first use; rebuild when `context` changed in any detectable way.
|
|
266
282
|
|
|
267
|
-
Identity+
|
|
268
|
-
|
|
269
|
-
that preserve length are not detected — documented, and rare in practice.
|
|
283
|
+
Identity + content fingerprint: catches add_context() growth, model re-binds, AND
|
|
284
|
+
same-length in-place edits at the sampled ends (the old stamp caught only the first two).
|
|
270
285
|
"""
|
|
271
286
|
ctx = self.ns.get("context")
|
|
272
|
-
stamp = (id(ctx),
|
|
287
|
+
stamp = (id(ctx), self._fingerprint(self._entries()))
|
|
273
288
|
if self._index is None or self._index_stamp != stamp:
|
|
274
289
|
self._index = _Bm25Index(self._entries())
|
|
275
290
|
self._index_stamp = stamp
|
package/src/sandbox/py/worker.py
CHANGED
|
@@ -119,7 +119,7 @@ class Worker(WorkerScaffold):
|
|
|
119
119
|
self._context_payload: Any = [] # empty list — the only starting value that needs no bootstrap branch
|
|
120
120
|
self._nudged: set[str] = set()
|
|
121
121
|
self._index: _Bm25Index | None = None
|
|
122
|
-
self._index_stamp: tuple[int, int] | None = None # (id(context),
|
|
122
|
+
self._index_stamp: tuple[int, int] | None = None # (id(context), content fingerprint)
|
|
123
123
|
self._restore_scaffold()
|
|
124
124
|
|
|
125
125
|
def _capture_answer(self, content: Any) -> None:
|
|
@@ -75,6 +75,25 @@ export class SandboxManager {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Recall W1: materialize one SessionArchive segment into the sandbox under
|
|
80
|
+
* `ctx/session-log/`. Same seam as refreshFileFromDisk (unique segment path ⇒ the upsert
|
|
81
|
+
* appends), so the worker's free search/grep_context BM25 covers elided history. Fail-soft:
|
|
82
|
+
* returns false when the worker is absent/dead (the host snapshot still replays on recreate).
|
|
83
|
+
*/
|
|
84
|
+
async upsertArchiveSegment(segmentPath: string, text: string, cwd: string): Promise<boolean> {
|
|
85
|
+
this.contextPayload = upsertContextFile(this.contextPayload, segmentPath, text, cwd);
|
|
86
|
+
if (!this.sandbox || this.disposed) return false;
|
|
87
|
+
const code = patchContextExecCode(segmentPath, text, cwd);
|
|
88
|
+
try {
|
|
89
|
+
await this.execQueued(code);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
this.contextLoaded = false;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
/**
|
|
79
98
|
* Lazy get-or-create the sandbox. On first call, spawns PythonSandbox with the
|
|
80
99
|
* given handlers. Subsequent calls return the existing sandbox immediately.
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -92,7 +92,10 @@ export class PythonSandbox {
|
|
|
92
92
|
private scanOffset = 0;
|
|
93
93
|
private seq = 0;
|
|
94
94
|
private readonly pending = new Map<string, Pending>();
|
|
95
|
-
|
|
95
|
+
// Not readonly: the engine's continuation handoff re-installs a successor run's closures
|
|
96
|
+
// on a live worker (installHandlers) instead of respawning it — respawn would destroy the
|
|
97
|
+
// pinned context accumulator, which is exactly what the chain must preserve.
|
|
98
|
+
private handlers: SubLlmHandlers;
|
|
96
99
|
private readonly requestTimeoutMs: number;
|
|
97
100
|
private readonly initTimeoutMs: number;
|
|
98
101
|
/** Bounded stderr tail (chunks, newest last) — avoids rebuilding the buffer per chunk. */
|
|
@@ -224,6 +227,15 @@ export class PythonSandbox {
|
|
|
224
227
|
return res.index ?? 0;
|
|
225
228
|
}
|
|
226
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Continuation handoff (engine chain): re-install a successor run's sub-call handlers on
|
|
232
|
+
* THIS live worker so a chained engine run can adopt it without a respawn. Respawning
|
|
233
|
+
* would destroy the pinned context accumulator — the one thing the chain must preserve.
|
|
234
|
+
*/
|
|
235
|
+
installHandlers(handlers: SubLlmHandlers): void {
|
|
236
|
+
this.handlers = { ...REJECT, ...handlers };
|
|
237
|
+
}
|
|
238
|
+
|
|
227
239
|
async exec(code: string, signal?: AbortSignal): Promise<ReplResult> {
|
|
228
240
|
const res = await this.request({ type: "exec", code }, signal);
|
|
229
241
|
if (!res.ok) throw new Error(res.error ?? "exec failed");
|
package/src/text/parsing.ts
CHANGED
|
@@ -55,6 +55,31 @@ const STATE_FENCE = /(`{3,})[ \t]*state[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
|
55
55
|
* example JSON never trips the scanner. */
|
|
56
56
|
const BARE_PATCH = /\{"state_patch"\s*:/g;
|
|
57
57
|
|
|
58
|
+
/** Recall W2 fence-echo fix: ```json fences are the EXAMPLE/quote channel — the system
|
|
59
|
+
* prompt itself shows `{"state_patch": …}` in a json-fenced example, and models echo it.
|
|
60
|
+
* A bare-object candidate inside a ```json fence is a quotation, never a commit; only
|
|
61
|
+
* fence-free (mangled) payloads or ```state bodies are authoring. One span scanner serves
|
|
62
|
+
* both the parse path (findStatePatches) and the answer-scrub path (stripStateFences). */
|
|
63
|
+
const JSON_FENCE = /(`{3,})[ \t]*json\b[^\r\n]*\r?\n[\s\S]*?\1/g;
|
|
64
|
+
|
|
65
|
+
function jsonFenceSpans(text: string): readonly { readonly start: number; readonly end: number }[] {
|
|
66
|
+
const spans: { start: number; end: number }[] = [];
|
|
67
|
+
let m: RegExpExecArray | null;
|
|
68
|
+
JSON_FENCE.lastIndex = 0;
|
|
69
|
+
while ((m = JSON_FENCE.exec(text)) !== null) {
|
|
70
|
+
spans.push({ start: m.index, end: m.index + m[0].length });
|
|
71
|
+
}
|
|
72
|
+
return spans;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function insideSpans(spans: readonly { readonly start: number; readonly end: number }[], index: number): boolean {
|
|
76
|
+
for (let i = 0; i < spans.length; i++) {
|
|
77
|
+
const span = spans[i];
|
|
78
|
+
if (span !== undefined && index >= span.start && index < span.end) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
/** String-aware balanced-brace scan from `start` (an index of `{`). Honors string literals and
|
|
59
84
|
* backslash escapes so braces inside JSON strings cannot unbalance the count. Returns the
|
|
60
85
|
* complete object slice, or undefined when braces never balance before EOF. */
|
|
@@ -113,9 +138,13 @@ export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
|
113
138
|
}
|
|
114
139
|
}
|
|
115
140
|
// Tolerant harvest over fence-free remainder (well-formed payloads already taken above).
|
|
141
|
+
// Candidates inside ```json fences are quotes/examples (recall W2) — skipped, so the
|
|
142
|
+
// contract example a model echoes cannot burn session-wide patch retries.
|
|
116
143
|
const rest = sansFences(text);
|
|
144
|
+
const jsonSpans = jsonFenceSpans(rest);
|
|
117
145
|
BARE_PATCH.lastIndex = 0;
|
|
118
146
|
while ((m = BARE_PATCH.exec(rest)) !== null) {
|
|
147
|
+
if (insideSpans(jsonSpans, m.index)) continue;
|
|
119
148
|
const obj = balancedJsonObject(rest, m.index);
|
|
120
149
|
if (obj === undefined) continue;
|
|
121
150
|
try {
|
|
@@ -133,13 +162,16 @@ export function findStatePatches(text: string): readonly StateFenceResult[] {
|
|
|
133
162
|
* bookkeeping). Deterministic scrub on the answer path; parse semantics stay in findStatePatches.
|
|
134
163
|
* Also removes bare {"state_patch"…} objects (mangled-fence leaks), a `state` token glued to
|
|
135
164
|
* preceding prose, and orphan ``` lines left behind by the mangled pair. */
|
|
136
|
-
export function stripStateFences(text: string): string {
|
|
137
|
-
let out = sansFences(text);
|
|
165
|
+
export function stripStateFences(text: string): string { let out = sansFences(text);
|
|
138
166
|
const parts: string[] = [];
|
|
139
167
|
let cursor = 0;
|
|
168
|
+
// A {"state_patch"…} inside a ```json fence is a quotation (recall W2) — must survive in
|
|
169
|
+
// the answer, not be scrubbed as leaked bookkeeping.
|
|
170
|
+
const jsonSpans = jsonFenceSpans(out);
|
|
140
171
|
BARE_PATCH.lastIndex = 0;
|
|
141
172
|
let m: RegExpExecArray | null;
|
|
142
173
|
while ((m = BARE_PATCH.exec(out)) !== null) {
|
|
174
|
+
if (insideSpans(jsonSpans, m.index)) continue;
|
|
143
175
|
const obj = balancedJsonObject(out, m.index);
|
|
144
176
|
if (obj === undefined) continue;
|
|
145
177
|
// Glom any immediately-preceding bare `state`/`.state` token (prose like "...report.state {").
|
|
@@ -156,6 +188,105 @@ export function stripStateFences(text: string): string {
|
|
|
156
188
|
return parts.join("").replace(ORPHAN_FENCE, "").trim();
|
|
157
189
|
}
|
|
158
190
|
|
|
191
|
+
// ── RLM-paper finalize repair (App. A: programmatic template-mistake fixing) ────────────────
|
|
192
|
+
|
|
193
|
+
/** A finalize the model wrote in the paper's surface syntax instead of flipping `answer`. */
|
|
194
|
+
export type FinalTag =
|
|
195
|
+
| { readonly kind: "final"; readonly value: string }
|
|
196
|
+
| { readonly kind: "final_var"; readonly value: string };
|
|
197
|
+
|
|
198
|
+
const FINAL_TAG_RE = /\b(FINAL_VAR|FINAL)\s*\(/g;
|
|
199
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
200
|
+
const FINAL_TAG_VALUE_MAX = 20_000;
|
|
201
|
+
|
|
202
|
+
/** All match spans of a global regex — used to excise fenced regions before prose scans. */
|
|
203
|
+
function collectFenceSpans(text: string, re: RegExp): readonly { readonly start: number; readonly end: number }[] {
|
|
204
|
+
const spans: { start: number; end: number }[] = [];
|
|
205
|
+
let m: RegExpExecArray | null;
|
|
206
|
+
re.lastIndex = 0;
|
|
207
|
+
while ((m = re.exec(text)) !== null) {
|
|
208
|
+
spans.push({ start: m.index, end: m.index + m[0].length });
|
|
209
|
+
}
|
|
210
|
+
return spans;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** String-aware balanced-paren scan from `start` (an index of `(`). Returns the inner slice. */
|
|
214
|
+
function balancedParens(text: string, start: number): string | undefined {
|
|
215
|
+
let depth = 0;
|
|
216
|
+
let inStr: string | undefined;
|
|
217
|
+
let esc = false;
|
|
218
|
+
for (let i = start; i < text.length; i++) {
|
|
219
|
+
const ch = text.charAt(i);
|
|
220
|
+
if (inStr !== undefined) {
|
|
221
|
+
if (esc) esc = false;
|
|
222
|
+
else if (ch === "\\") esc = true;
|
|
223
|
+
else if (ch === inStr) inStr = undefined;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (ch === '"' || ch === "'") inStr = ch;
|
|
227
|
+
else if (ch === "(") depth += 1;
|
|
228
|
+
else if (ch === ")") {
|
|
229
|
+
depth -= 1;
|
|
230
|
+
if (depth === 0) return text.slice(start + 1, i);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Detect `FINAL(x)` / `FINAL_VAR(v)` in a turn's PROSE (the RLM paper's finalize surface).
|
|
238
|
+
* The paper found 16%/13% of small-model turns misuse these tags instead of setting
|
|
239
|
+
* `answer` — and that a programmatic fix measurably improved the post-trained RLM. Fenced
|
|
240
|
+
* code is excluded (those blocks already executed as code this turn); the LAST tag wins.
|
|
241
|
+
* Returns undefined when the value is empty or dump-sized (a plan/dump is not an answer).
|
|
242
|
+
*/
|
|
243
|
+
export function findFinalTag(text: string): FinalTag | undefined {
|
|
244
|
+
const spans: readonly { readonly start: number; readonly end: number }[] = [
|
|
245
|
+
...collectFenceSpans(text, FENCE),
|
|
246
|
+
...collectFenceSpans(text, FALLBACK_FENCE),
|
|
247
|
+
];
|
|
248
|
+
let prose = text;
|
|
249
|
+
if (spans.length > 0) {
|
|
250
|
+
const ordered = [...spans].sort((a, b) => a.start - b.start);
|
|
251
|
+
const parts: string[] = [];
|
|
252
|
+
let cursor = 0;
|
|
253
|
+
for (const s of ordered) {
|
|
254
|
+
parts.push(text.slice(cursor, s.start));
|
|
255
|
+
cursor = s.end;
|
|
256
|
+
}
|
|
257
|
+
parts.push(text.slice(cursor));
|
|
258
|
+
prose = parts.join("");
|
|
259
|
+
}
|
|
260
|
+
let found: FinalTag | undefined;
|
|
261
|
+
FINAL_TAG_RE.lastIndex = 0;
|
|
262
|
+
let m: RegExpExecArray | null;
|
|
263
|
+
while ((m = FINAL_TAG_RE.exec(prose)) !== null) {
|
|
264
|
+
const open = m.index + m[0].length - 1;
|
|
265
|
+
const inner = balancedParens(prose, open);
|
|
266
|
+
if (inner === undefined) continue;
|
|
267
|
+
const value = inner.trim().replace(/^"([\s\S]*)"$|^'([\s\S]*)'$/, "$1$2");
|
|
268
|
+
if (value === "" || value.length > FINAL_TAG_VALUE_MAX) continue;
|
|
269
|
+
found =
|
|
270
|
+
m[1] === "FINAL_VAR" && IDENTIFIER_RE.test(value)
|
|
271
|
+
? { kind: "final_var", value }
|
|
272
|
+
: { kind: "final", value: value };
|
|
273
|
+
}
|
|
274
|
+
return found;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Python snippet that completes a FINAL_VAR(v) repair inside the sandbox — never raises. */
|
|
278
|
+
export function finalVarRepairCode(name: string): string {
|
|
279
|
+
const lit = JSON.stringify(name); // identifier chars only — JSON literal == Python literal
|
|
280
|
+
return [
|
|
281
|
+
`_final_name = ${lit}`,
|
|
282
|
+
`try:`,
|
|
283
|
+
` answer["content"] = str(globals()[_final_name])`,
|
|
284
|
+
`except KeyError:`,
|
|
285
|
+
` answer["content"] = "Error: FINAL_VAR variable " + _final_name + " is not defined in the REPL"`,
|
|
286
|
+
`answer["ready"] = True`,
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
|
|
159
290
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note).
|
|
160
291
|
* `mark` lets callers specialize the wording (root elision cites the session log) while the
|
|
161
292
|
* head/tail math stays the one implementation. */
|