switchroom 0.19.23 → 0.19.25
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/dist/agent-scheduler/index.js +18 -7
- package/dist/auth-broker/index.js +117 -33
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +18 -6
- package/dist/cli/switchroom.js +2916 -1481
- package/dist/host-control/main.js +116 -34
- package/dist/vault/approvals/kernel-server.js +115 -33
- package/dist/vault/broker/server.js +281 -76
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +52 -13
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-release/SKILL.md +2 -1
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -115,7 +115,18 @@ def compose_recall_query(
|
|
|
115
115
|
if role == "user" and content == latest:
|
|
116
116
|
continue
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
# Switchroom recall-latency fix (#3757): NO ``{role}: `` prefix.
|
|
119
|
+
# These labels were formatting scaffolding, never search terms, and
|
|
120
|
+
# they were the single most expensive thing this hook put on the wire.
|
|
121
|
+
# Hindsight's BM25 arm OR-joins every query token, so ``user`` and
|
|
122
|
+
# ``assistant`` widened the match set by the two highest-document-
|
|
123
|
+
# frequency terms in a mature bank (measured on bank ``overlord``,
|
|
124
|
+
# 135,443 rows: ``user`` = 67,363 rows = 50% of the bank,
|
|
125
|
+
# ``assistant`` = 29,942 = 22%). Dropping just these two labels cut a
|
|
126
|
+
# production-shaped 3-arm BM25 UNION from 119,510 ranked rows / 14.0s
|
|
127
|
+
# to 86,653 / 11.8s. Turn separation is preserved by the newline join,
|
|
128
|
+
# which is all the embedding arm needs.
|
|
129
|
+
context_lines.append(content)
|
|
119
130
|
|
|
120
131
|
if not context_lines:
|
|
121
132
|
return latest
|
|
@@ -181,6 +192,370 @@ def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
|
|
|
181
192
|
return latest_only
|
|
182
193
|
|
|
183
194
|
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Recall: search-query shaping (BM25 token budget)
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
#
|
|
199
|
+
# Switchroom recall-latency fix (#3757). Hindsight's keyword arm OR-joins
|
|
200
|
+
# EVERY token of the query into one `to_tsquery` disjunction, and Postgres
|
|
201
|
+
# native FTS cannot top-k from the GIN index — it computes `ts_rank_cd` on
|
|
202
|
+
# every matching row before the top-60 heapsort. So BM25 cost grows with the
|
|
203
|
+
# size of the matched set, which grows with query length. Measured on the live
|
|
204
|
+
# `overlord` bank (135,565 memory_units, 3 fact-type arms, LIMIT 60 each,
|
|
205
|
+
# production-shaped composed query):
|
|
206
|
+
#
|
|
207
|
+
# as shipped (96 distinct terms) 119,510 rows ranked 14.0 s
|
|
208
|
+
# role labels + header stripped 86,653 rows ranked 11.8 s
|
|
209
|
+
# + capped to 24 BM25 terms 48,433 rows ranked 2.7 s
|
|
210
|
+
#
|
|
211
|
+
# The 8s client timeout then fired on 96.8% of overlord's own-bank recalls
|
|
212
|
+
# over the 7 days to 2026-07-27, so the agent got zero memories.
|
|
213
|
+
#
|
|
214
|
+
# We therefore shape the query into a bounded set of the most selective terms
|
|
215
|
+
# before it goes on the wire. Only the SERVER-BOUND query is shaped; the
|
|
216
|
+
# client-side lexical surfaces (the `recallMinOverlap` containment gate and the
|
|
217
|
+
# transcript-grep fallback) keep the unshaped text, so this change cannot move
|
|
218
|
+
# their thresholds.
|
|
219
|
+
|
|
220
|
+
# Common English function words.
|
|
221
|
+
#
|
|
222
|
+
# MEASURED CAVEAT, so nobody over-credits this list: on the CURRENT backend
|
|
223
|
+
# (Postgres native tsvector with the `english` configuration) these are already
|
|
224
|
+
# stopped by Postgres itself — `to_tsquery('english', 'the | user | worktree')`
|
|
225
|
+
# returns `'user' | 'worktre'`. Removing them client-side moved the matched set
|
|
226
|
+
# by only 86,653 → ~86,300 rows on the `overlord` bank. Their real job here is
|
|
227
|
+
# BUDGET: every function word we send would otherwise consume one of the
|
|
228
|
+
# `max_tokens` slots that a content word needs. They also matter directly on
|
|
229
|
+
# the non-native backends Hindsight supports (vchord / pgroonga / pg_search /
|
|
230
|
+
# pg_textsearch), none of which strip English stopwords for us.
|
|
231
|
+
#
|
|
232
|
+
# Deliberately conservative: only closed-class words (determiners, pronouns,
|
|
233
|
+
# prepositions, auxiliaries, conjunctions), never content words.
|
|
234
|
+
BM25_STOPWORDS = frozenset(
|
|
235
|
+
"""
|
|
236
|
+
a about above after again against all am an and any are aren as at
|
|
237
|
+
be because been before being below between both but by
|
|
238
|
+
can cannot could couldn
|
|
239
|
+
did didn do does doesn doing don down during
|
|
240
|
+
each few for from further
|
|
241
|
+
had hadn has hasn have haven having he her here hers herself him himself his
|
|
242
|
+
how
|
|
243
|
+
i if in into is isn it its itself
|
|
244
|
+
just
|
|
245
|
+
ll
|
|
246
|
+
me more most mustn my myself
|
|
247
|
+
no nor not now
|
|
248
|
+
of off on once only or other ought our ours ourselves out over own
|
|
249
|
+
re
|
|
250
|
+
s same shan she should shouldn so some such
|
|
251
|
+
t than that the their theirs them themselves then there these they this
|
|
252
|
+
those through to too
|
|
253
|
+
under until up
|
|
254
|
+
ve very
|
|
255
|
+
was wasn we were weren what when where which while who whom why will with
|
|
256
|
+
won would wouldn
|
|
257
|
+
you your yours yourself yourselves
|
|
258
|
+
""".split()
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Role labels this hook used to prefix onto context turns, plus the composed
|
|
262
|
+
# query's own section header. Structural scaffolding, never search terms.
|
|
263
|
+
_ROLE_LABEL_RE = re.compile(r"(?mi)^\s*(?:user|assistant|system|tool)\s*:[ \t]*")
|
|
264
|
+
_CONTEXT_HEADER_RE = re.compile(r"(?mi)^\s*prior context\s*:[ \t]*$")
|
|
265
|
+
|
|
266
|
+
# Mirrors the server's own BM25 tokenizer
|
|
267
|
+
# (hindsight_api/engine/search/retrieval.py::tokenize_query): lowercase, strip
|
|
268
|
+
# punctuation, split on whitespace, then APPEND intact "compound" tokens —
|
|
269
|
+
# word-char runs joined by . / - (semvers, paths, hyphenated identifiers) —
|
|
270
|
+
# because the server emits those alongside their fragments. Keeping the two in
|
|
271
|
+
# sync is what makes the cap mean "N terms in the tsquery", not "N words we
|
|
272
|
+
# happened to send".
|
|
273
|
+
_COMPOUND_TOKEN_RE = re.compile(r"\w+(?:[./-]\w+)+")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def tokenize_for_bm25(text: str) -> list:
|
|
277
|
+
"""Tokenize ``text`` the way Hindsight's BM25 arm will.
|
|
278
|
+
|
|
279
|
+
Port of ``tokenize_query`` in the Hindsight engine. Returns the token list
|
|
280
|
+
(with duplicates, in order); the tsquery ORs the distinct values.
|
|
281
|
+
"""
|
|
282
|
+
lowered = text.lower()
|
|
283
|
+
tokens = re.sub(r"[^\w\s]", " ", lowered).split()
|
|
284
|
+
if not tokens:
|
|
285
|
+
return []
|
|
286
|
+
for match in _COMPOUND_TOKEN_RE.finditer(lowered):
|
|
287
|
+
compound = match.group(0)
|
|
288
|
+
if compound not in tokens:
|
|
289
|
+
tokens.append(compound)
|
|
290
|
+
return tokens
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def strip_query_scaffolding(text: str) -> str:
|
|
294
|
+
"""Remove role labels and the "Prior context:" header from a query.
|
|
295
|
+
|
|
296
|
+
Defence in depth for #3757: ``compose_recall_query`` no longer emits the
|
|
297
|
+
``user:`` / ``assistant:`` prefixes, but a transcript turn can legitimately
|
|
298
|
+
*contain* such a line, and older composed strings may still reach here.
|
|
299
|
+
"""
|
|
300
|
+
text = _CONTEXT_HEADER_RE.sub("", text)
|
|
301
|
+
return _ROLE_LABEL_RE.sub("", text)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_ENGLISH_WORDS_FILE = os.path.join(os.path.dirname(__file__), "english_words.txt")
|
|
305
|
+
_ENGLISH_WORDS_CACHE = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def common_english_words() -> frozenset:
|
|
309
|
+
"""Lazily load the common-English demotion list (see english_words.txt).
|
|
310
|
+
|
|
311
|
+
Read once per process and cached. A missing or unreadable file degrades to
|
|
312
|
+
an empty set — shaping still works, it just loses the demotion signal — so
|
|
313
|
+
a packaging slip can never break recall on the critical path.
|
|
314
|
+
"""
|
|
315
|
+
global _ENGLISH_WORDS_CACHE
|
|
316
|
+
if _ENGLISH_WORDS_CACHE is None:
|
|
317
|
+
words = set()
|
|
318
|
+
try:
|
|
319
|
+
with open(_ENGLISH_WORDS_FILE, encoding="utf-8") as handle:
|
|
320
|
+
for line in handle:
|
|
321
|
+
word = line.strip()
|
|
322
|
+
if word and not word.startswith("#"):
|
|
323
|
+
words.add(word)
|
|
324
|
+
except OSError:
|
|
325
|
+
words = set()
|
|
326
|
+
_ENGLISH_WORDS_CACHE = frozenset(words)
|
|
327
|
+
return _ENGLISH_WORDS_CACHE
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# How much each signal is worth. Digit and compound are equal because both are
|
|
331
|
+
# measured to predict the same thing (an identifier); the English demotion is
|
|
332
|
+
# deliberately smaller than either, so a digit-bearing English word still wins.
|
|
333
|
+
_SCORE_DIGIT = 3.0
|
|
334
|
+
_SCORE_COMPOUND = 3.0
|
|
335
|
+
_SCORE_NOT_COMMON_ENGLISH = 2.0
|
|
336
|
+
# Recency is a WEIGHT, not a tier (#3760 review, Blocker 2). Sized below the
|
|
337
|
+
# shape signals on purpose: a distinctive prior-context identifier (`nginx`,
|
|
338
|
+
# score 2.0) must outrank a generic latest-turn English word (score 0.0 + 1.5),
|
|
339
|
+
# but between two terms of equal merit the one the user just typed wins.
|
|
340
|
+
_SCORE_RECENCY = 1.5
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _selectivity_score(token: str) -> float:
|
|
344
|
+
"""Deterministic proxy for how discriminating ``token`` is.
|
|
345
|
+
|
|
346
|
+
True per-token document frequency is not available client-side: Hindsight
|
|
347
|
+
exposes no term-stats endpoint, and one ``count(*)`` probe per token would
|
|
348
|
+
cost more than the query it is trying to speed up. Every signal below was
|
|
349
|
+
checked against real df, measured with ``ts_stat`` over the live `overlord`
|
|
350
|
+
bank (135,565 units) and five other topically-unrelated banks:
|
|
351
|
+
|
|
352
|
+
* carries a digit (+3.0) — versions, issue numbers, ids, dates. The one
|
|
353
|
+
shape that reliably predicts a low df: median df 0.000, and only 6.8%
|
|
354
|
+
of digit-bearing tokens exceed 1% of the bank, against 33-50% for every
|
|
355
|
+
other shape class.
|
|
356
|
+
* is a compound token (+3.0) — ``v0.19.17``, ``src/agents/scaffold.ts``.
|
|
357
|
+
* is NOT a common English word (+2.0) — see ``english_words.txt``. df
|
|
358
|
+
cannot separate contentless English from domain identifiers (both are
|
|
359
|
+
rare: `particularly` 0.022%, `situation` 0.018%, vs `npm` 1.38%,
|
|
360
|
+
`worktree` 1.85%), but English-word membership can, and it is exactly
|
|
361
|
+
the separation the budget needs.
|
|
362
|
+
|
|
363
|
+
LENGTH IS DELIBERATELY ABSENT. The previous revision added
|
|
364
|
+
``min(len(token), 12) / 4.0``. Measured median `overlord` df by token
|
|
365
|
+
length is flat-to-RISING — len2 0.0022, len3 0.0032, len4 0.0042,
|
|
366
|
+
len5 0.0070, len6 0.0103, len7 0.0102, len9 0.0068, len11 0.0055,
|
|
367
|
+
len13 0.0093 — so length predicts nothing in either direction. Inverting
|
|
368
|
+
it would have been as unjustified as the original; it is dropped instead.
|
|
369
|
+
Case was checked too and also rejected: `Capitalised` tokens have the
|
|
370
|
+
HIGHEST rate of df > 1% (50.6%), and ALLCAPS the highest mean df (0.034).
|
|
371
|
+
|
|
372
|
+
Known limit: bank-specific high-df content words (`agent` and `switchroom`
|
|
373
|
+
each match ~20% of the `overlord` bank) still score normally. They are not
|
|
374
|
+
a cost problem — ``max_tokens`` bounds tsquery cost regardless of which
|
|
375
|
+
terms are chosen — only a slot-allocation one, and they remain the job of
|
|
376
|
+
the operator-set ``recallQueryStopTerms``.
|
|
377
|
+
"""
|
|
378
|
+
score = 0.0
|
|
379
|
+
if any(ch.isdigit() for ch in token):
|
|
380
|
+
score += _SCORE_DIGIT
|
|
381
|
+
if _COMPOUND_TOKEN_RE.fullmatch(token):
|
|
382
|
+
score += _SCORE_COMPOUND
|
|
383
|
+
if token not in common_english_words():
|
|
384
|
+
score += _SCORE_NOT_COMMON_ENGLISH
|
|
385
|
+
return score
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def shape_recall_query(
|
|
389
|
+
query: str,
|
|
390
|
+
latest_query: str = "",
|
|
391
|
+
max_tokens: int = 24,
|
|
392
|
+
stop_terms=None,
|
|
393
|
+
) -> str:
|
|
394
|
+
"""Bound the BM25 cost of ``query`` by capping its distinct tsquery terms.
|
|
395
|
+
|
|
396
|
+
Returns a space-joined term string in the query's original word order (so
|
|
397
|
+
the embedding arm still sees the text's natural sequence, just with the
|
|
398
|
+
scaffolding and function words removed).
|
|
399
|
+
|
|
400
|
+
Selection is RESERVE-then-FILL, in three parts:
|
|
401
|
+
|
|
402
|
+
1. ``max_tokens // 3`` slots go to the highest-:func:`_selectivity_score`
|
|
403
|
+
terms in the whole window, recency deliberately excluded from that
|
|
404
|
+
score. The most discriminating terms present survive regardless of
|
|
405
|
+
which turn they came from.
|
|
406
|
+
2. ``max_tokens // 3`` slots go to the best terms of the latest turn, so
|
|
407
|
+
a prior turn dense in high-merit tokens (a pasted stack trace, a list
|
|
408
|
+
of ids) cannot cost the user the question they just asked.
|
|
409
|
+
3. Everything left is filled by ``_selectivity_score`` plus
|
|
410
|
+
``_SCORE_RECENCY`` for terms appearing in ``latest_query``, ties broken
|
|
411
|
+
by first appearance.
|
|
412
|
+
|
|
413
|
+
Both reserves are CEILINGS, not allocations: a side with fewer terms simply
|
|
414
|
+
leaves its unused slots to the fill, and a window under ``max_tokens`` terms
|
|
415
|
+
is unaffected entirely.
|
|
416
|
+
|
|
417
|
+
Recency is therefore a WEIGHT, not an absolute tier (#3760 review, Blocker
|
|
418
|
+
2). It has to be preferred at all, because the composed query is
|
|
419
|
+
``Prior context: <older turns> … <latest>`` and a naive truncation keeps
|
|
420
|
+
whichever turn comes FIRST — i.e. throws away the actual question and keeps
|
|
421
|
+
the stalest context. But making it absolute silently defeated
|
|
422
|
+
``recallContextTurns``: any latest turn with ``>= max_tokens`` surviving
|
|
423
|
+
terms took EVERY slot, so a conversational follow-up whose subject lives
|
|
424
|
+
only in the prior turn ("is the thing we were discussing still broken?")
|
|
425
|
+
produced a query with no subject in it at all. Step 1 is the structural
|
|
426
|
+
guarantee against that; step 3 is where recency actually decides anything,
|
|
427
|
+
and in ordinary conversational text — where nearly every candidate scores
|
|
428
|
+
0.0 on shape — that is most of the budget.
|
|
429
|
+
|
|
430
|
+
A kept compound token costs its own slot PLUS a slot for each fragment the
|
|
431
|
+
server will shred it into, so the emitted string never expands past
|
|
432
|
+
``max_tokens`` distinct tsquery terms.
|
|
433
|
+
|
|
434
|
+
``max_tokens <= 0`` disables shaping and returns ``query`` unchanged (the
|
|
435
|
+
operator rollback lever, ``memory.recall.query_max_tokens: 0``).
|
|
436
|
+
"""
|
|
437
|
+
# Defensive coercion: `max_tokens` reaches here from settings.json /
|
|
438
|
+
# env, so a string or None is a config error, not a crash on the recall
|
|
439
|
+
# critical path. Anything uninterpretable falls back to "do not shape".
|
|
440
|
+
try:
|
|
441
|
+
max_tokens = int(max_tokens)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return query
|
|
444
|
+
if max_tokens <= 0:
|
|
445
|
+
return query
|
|
446
|
+
|
|
447
|
+
cleaned = strip_query_scaffolding(query)
|
|
448
|
+
tokens = tokenize_for_bm25(cleaned)
|
|
449
|
+
if not tokens:
|
|
450
|
+
# Nothing tokenizable (e.g. a pure-punctuation prompt) — send the
|
|
451
|
+
# original so we never turn a real query into an empty one.
|
|
452
|
+
return query
|
|
453
|
+
|
|
454
|
+
stop = set(BM25_STOPWORDS)
|
|
455
|
+
# A bare string here (`"switchroom,agent"` mis-set in settings.json) would
|
|
456
|
+
# otherwise iterate CHARACTERS and stop-list half the alphabet.
|
|
457
|
+
if isinstance(stop_terms, str):
|
|
458
|
+
stop_terms = [t for t in re.split(r"[,\s]+", stop_terms) if t]
|
|
459
|
+
for term in stop_terms or ():
|
|
460
|
+
if isinstance(term, str) and term.strip():
|
|
461
|
+
stop.add(term.strip().lower())
|
|
462
|
+
|
|
463
|
+
latest_tokens = set(tokenize_for_bm25(strip_query_scaffolding(latest_query or "")))
|
|
464
|
+
|
|
465
|
+
first_seen = {}
|
|
466
|
+
for index, token in enumerate(tokens):
|
|
467
|
+
first_seen.setdefault(token, index)
|
|
468
|
+
|
|
469
|
+
candidates = [t for t in first_seen if len(t) > 1 and t not in stop]
|
|
470
|
+
if not candidates:
|
|
471
|
+
# Every term was a stopword. Fall back to the unfiltered token set so a
|
|
472
|
+
# short conversational prompt ("what did you say about it?") still
|
|
473
|
+
# searches for something.
|
|
474
|
+
candidates = [t for t in first_seen if len(t) > 1] or list(first_seen)
|
|
475
|
+
|
|
476
|
+
def weight(token):
|
|
477
|
+
score = _selectivity_score(token)
|
|
478
|
+
if token in latest_tokens:
|
|
479
|
+
score += _SCORE_RECENCY
|
|
480
|
+
return score
|
|
481
|
+
|
|
482
|
+
# MERIT RESERVE, then RECENCY FILL.
|
|
483
|
+
#
|
|
484
|
+
# `max_tokens // 3` slots are reserved for the highest-merit terms in the
|
|
485
|
+
# WHOLE window, scored with recency deliberately excluded, so the most
|
|
486
|
+
# discriminating terms present cannot be displaced by sheer latest-turn
|
|
487
|
+
# volume no matter which turn they came from. That is the Blocker 2
|
|
488
|
+
# guarantee, and it is structural: a conversational follow-up whose subject
|
|
489
|
+
# lives only in the prior turn ("is the thing we were discussing still
|
|
490
|
+
# broken?") keeps its subject.
|
|
491
|
+
#
|
|
492
|
+
# The remaining two thirds are filled by the recency-WEIGHTED score, which
|
|
493
|
+
# is where `_SCORE_RECENCY` earns its keep: between terms of equal merit —
|
|
494
|
+
# and after stopword removal most conversational terms are equal on merit —
|
|
495
|
+
# the one the user just typed wins, instead of the composed string's leading
|
|
496
|
+
# (i.e. STALEST) turn winning on `first_seen` alone.
|
|
497
|
+
#
|
|
498
|
+
# The reserve is a ceiling, not an allocation: any term it reserves that the
|
|
499
|
+
# recency fill would have chosen anyway costs nothing, and a window with
|
|
500
|
+
# fewer than `max_tokens` terms is unaffected entirely.
|
|
501
|
+
reserve_size = max(1, max_tokens // 3)
|
|
502
|
+
merit_ordered = sorted(candidates, key=lambda t: (-_selectivity_score(t), first_seen[t]))
|
|
503
|
+
reserved = merit_ordered[:reserve_size]
|
|
504
|
+
reserved_set = set(reserved)
|
|
505
|
+
|
|
506
|
+
# ...and a mirror-image LATEST-TURN reserve of the same size, because a
|
|
507
|
+
# merit reserve alone is one-sided. A prior turn dense in high-merit tokens
|
|
508
|
+
# (a pasted stack trace, a list of ids) outscores an ordinary question on
|
|
509
|
+
# every slot, which would cost the user the thing they actually just asked.
|
|
510
|
+
# Same shape as above: a ceiling of `max_tokens // 3`, never an allocation.
|
|
511
|
+
latest_reserve = sorted(
|
|
512
|
+
(t for t in candidates if t in latest_tokens and t not in reserved_set),
|
|
513
|
+
key=lambda t: (-weight(t), first_seen[t]),
|
|
514
|
+
)[:reserve_size]
|
|
515
|
+
reserved_set.update(latest_reserve)
|
|
516
|
+
|
|
517
|
+
filled = sorted(
|
|
518
|
+
(t for t in candidates if t not in reserved_set),
|
|
519
|
+
key=lambda t: (-weight(t), first_seen[t]),
|
|
520
|
+
)
|
|
521
|
+
candidates = reserved + latest_reserve + filled
|
|
522
|
+
|
|
523
|
+
kept = []
|
|
524
|
+
emitted = set()
|
|
525
|
+
for token in candidates:
|
|
526
|
+
# A compound token is emitted by the server ALONGSIDE its fragments;
|
|
527
|
+
# charge the budget for both so `max_tokens` is a true tsquery bound.
|
|
528
|
+
expansion = {token}
|
|
529
|
+
if _COMPOUND_TOKEN_RE.fullmatch(token):
|
|
530
|
+
expansion |= set(re.sub(r"[^\w\s]", " ", token).split())
|
|
531
|
+
new_terms = expansion - emitted
|
|
532
|
+
if len(emitted) + len(new_terms) > max_tokens:
|
|
533
|
+
continue
|
|
534
|
+
emitted |= new_terms
|
|
535
|
+
kept.append(token)
|
|
536
|
+
|
|
537
|
+
if not kept:
|
|
538
|
+
return query
|
|
539
|
+
|
|
540
|
+
kept.sort(key=lambda t: first_seen[t])
|
|
541
|
+
# Emit the ORIGINAL surface form of each survivor, not the lowercased
|
|
542
|
+
# token. BM25 lowercases and stems on the server either way, but the SAME
|
|
543
|
+
# string also feeds the embedding arm, and `Python` / `Coolify` / `PR`
|
|
544
|
+
# carry case a sentence-transformer legitimately uses.
|
|
545
|
+
surfaces = _surface_forms(cleaned)
|
|
546
|
+
return " ".join(surfaces.get(t, t) for t in kept)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _surface_forms(text: str) -> dict:
|
|
550
|
+
"""Map each BM25 token to its first original-case spelling in ``text``."""
|
|
551
|
+
forms = {}
|
|
552
|
+
for word in re.sub(r"[^\w\s]", " ", text).split():
|
|
553
|
+
forms.setdefault(word.lower(), word)
|
|
554
|
+
for match in _COMPOUND_TOKEN_RE.finditer(text):
|
|
555
|
+
forms.setdefault(match.group(0).lower(), match.group(0))
|
|
556
|
+
return forms
|
|
557
|
+
|
|
558
|
+
|
|
184
559
|
# ---------------------------------------------------------------------------
|
|
185
560
|
# Turn slicing
|
|
186
561
|
# ---------------------------------------------------------------------------
|