@hicaru/pi-rlm 0.3.20 → 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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/commands/rlm.ts +14 -7
  3. package/src/config/defaults.ts +33 -10
  4. package/src/config/settings.ts +6 -0
  5. package/src/config/skillstate.ts +236 -44
  6. package/src/core/budget.ts +7 -3
  7. package/src/core/compaction.ts +2 -2
  8. package/src/core/engine.ts +87 -19
  9. package/src/core/root-context.ts +74 -21
  10. package/src/core/root-digest.ts +48 -11
  11. package/src/core/root-state.ts +39 -12
  12. package/src/core/run-state.ts +86 -14
  13. package/src/core/session-archive.ts +174 -0
  14. package/src/core/types.ts +6 -0
  15. package/src/index.ts +142 -12
  16. package/src/mode/rlm-mode.ts +2 -2
  17. package/src/prompts/glossary.ts +34 -5
  18. package/src/prompts/native.ts +8 -2
  19. package/src/prompts/user.ts +4 -3
  20. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  21. package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
  22. package/src/sandbox/py/retrieval.py +202 -36
  23. package/src/sandbox/py/scaffold.py +20 -5
  24. package/src/sandbox/py/worker.py +1 -1
  25. package/src/sandbox/sandbox-manager.ts +19 -0
  26. package/src/text/parsing.ts +133 -2
  27. package/src/text/tokens.ts +39 -4
  28. package/src/tool/repl-render.ts +38 -2
  29. package/src/tool/repl-tool.ts +34 -18
  30. package/src/tool/subcall-render.ts +7 -4
  31. package/src/ui/config-panel.ts +2 -2
  32. package/src/ui/intro.ts +1 -1
  33. package/src/ui/python-highlight.ts +49 -0
  34. package/src/ui/stage-cards.ts +192 -0
  35. package/src/ui/tree/tree-model.ts +69 -19
  36. package/src/ui/tree/tree-rows.ts +2 -1
  37. package/src/util/abort.ts +34 -0
  38. package/src/util/bm25.ts +170 -21
  39. package/src/util/errors.ts +1 -1
@@ -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
- """Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
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, discarded on change."""
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), _INDEX_WINDOW_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(lines[start:start + _INDEX_WINDOW_LINES])
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 query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
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 set(terms):
260
+ for term, weight in weights.items():
179
261
  posting = self.postings.get(term)
180
262
  if not posting:
181
263
  continue
182
- df = len(posting)
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
- if path_glob:
188
- scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
189
- if not scores:
190
- return []
191
- top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
192
- out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
193
- term_set = set(terms)
194
- for i, (idx, score) in enumerate(top):
195
- text = self.texts[idx]
196
- snip = _snippet_window(text, term_set)
197
- # Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
198
- out[i] = {
199
- "path": self.paths[idx],
200
- "line": self.starts[idx],
201
- "score": round(score, 3),
202
- "snippet": snip,
203
- "text": snip,
204
- }
205
- return out
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
- Returns [{path, line, score, snippet, text}] pointers, not bodies.
212
- `text` is an alias of `snippet` (same as grep_context hits).
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
- return index.query(terms, limit, path_glob)
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` was replaced or resized.
281
+ """Build the BM25 index on first use; rebuild when `context` changed in any detectable way.
266
282
 
267
- Identity+length is a cheap stamp that catches the two ways context actually changes:
268
- add_context() extending the list, and the model re-binding the name. In-place edits
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), len(ctx) if isinstance(ctx, (list, str)) else 0)
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
@@ -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), len(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.
@@ -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. */
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  const CHARS_PER_TOKEN = 4;
10
+ // CJK ideographs/kana/hangul encode ~1.5 chars per token — the flat /4 heuristic under-counts
11
+ // CJK-heavy content ~2.7x, delaying compaction and budget walls until near overflow.
12
+ const CJK_CHARS_PER_TOKEN = 1.5;
10
13
 
11
14
  /** Rough token count for a character length (≈4 chars/token). Always ≥ 1 for non-empty text. */
12
15
  export function estimateTokens(charCount: number): number {
@@ -14,11 +17,43 @@ export function estimateTokens(charCount: number): number {
14
17
  return Math.ceil(charCount / CHARS_PER_TOKEN);
15
18
  }
16
19
 
17
- /** Rough token count for a list of role/content messages. */
20
+ /** CJK codepoints (kana, ideographs, hangul, compatibility forms) in `text`. */
21
+ function cjkChars(text: string): number {
22
+ let n = 0;
23
+ for (let i = 0; i < text.length; i++) {
24
+ const c = text.charCodeAt(i);
25
+ if (
26
+ (c >= 0x3040 && c <= 0x30ff) || // kana
27
+ (c >= 0x3400 && c <= 0x9fff) || // ideograph extensions + unified ideographs
28
+ (c >= 0xac00 && c <= 0xd7af) || // hangul syllables
29
+ (c >= 0xf900 && c <= 0xfaff) // compatibility ideographs
30
+ ) {
31
+ n += 1;
32
+ }
33
+ }
34
+ return n;
35
+ }
36
+
37
+ /** Blended token estimate for actual TEXT: ASCII at 4 chars/token, CJK at 1.5. Pure-ASCII
38
+ * input is byte-identical to estimateTokens(length) — only non-English content moves. */
39
+ export function estimateTextTokens(text: string): number {
40
+ const cjk = cjkChars(text);
41
+ if (cjk === 0) return estimateTokens(text.length);
42
+ const ascii = text.length - cjk;
43
+ return Math.ceil(ascii / CHARS_PER_TOKEN + cjk / CJK_CHARS_PER_TOKEN);
44
+ }
45
+
46
+ /** Rough token count for a list of role/content messages (script-aware — see estimateTextTokens). */
18
47
  export function estimateMessageTokens(messages: { content: string }[]): number {
19
- let chars = 0;
20
- for (const m of messages) chars += m.content.length + 8; // small per-message overhead
21
- return estimateTokens(chars);
48
+ let ascii = 0;
49
+ let cjk = 0;
50
+ for (const m of messages) {
51
+ ascii += m.content.length + 8; // small per-message overhead
52
+ cjk += cjkChars(m.content);
53
+ }
54
+ if (cjk === 0) return estimateTokens(ascii);
55
+ const nonCjk = ascii - cjk;
56
+ return nonCjk <= 0 ? 0 : Math.ceil(nonCjk / CHARS_PER_TOKEN + cjk / CJK_CHARS_PER_TOKEN);
22
57
  }
23
58
 
24
59
  /**