switchroom 0.19.24 → 0.19.26

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +20 -7
  2. package/dist/auth-broker/index.js +93 -28
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +20 -6
  7. package/dist/cli/switchroom.js +3091 -1435
  8. package/dist/host-control/main.js +92 -29
  9. package/dist/vault/approvals/kernel-server.js +92 -28
  10. package/dist/vault/broker/server.js +258 -71
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/cron-session.sh.hbs +6 -0
  14. package/profiles/_base/start.sh.hbs +92 -17
  15. package/skills/switchroom-health/SKILL.md +19 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  18. package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
  19. package/telegram-plugin/gateway/gateway.ts +53 -52
  20. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  21. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  22. package/telegram-plugin/quota-bar-format.ts +4 -1
  23. package/telegram-plugin/status-no-truncate.ts +49 -0
  24. package/telegram-plugin/status-pin-driver.ts +28 -0
  25. package/telegram-plugin/status-pin.ts +33 -4
  26. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  27. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  28. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  29. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  30. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  31. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  32. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  33. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  34. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  35. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  36. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  37. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  38. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  39. package/telegram-plugin/tier-downgrade.ts +3 -2
  40. package/telegram-plugin/tool-activity-summary.ts +61 -18
  41. package/telegram-plugin/uat/assertions.ts +21 -2
  42. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  43. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  44. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  45. package/telegram-plugin/worker-activity-feed.ts +38 -17
  46. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  47. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  48. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  49. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  59. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -29,21 +29,23 @@ DEFAULTS = {
29
29
  # formatting. Set to 0 (or any non-positive value) to disable the cap
30
30
  # and inject everything Hindsight returns.
31
31
  "recallMaxMemories": 12,
32
- # Switchroom-local: minimum lexical (containment) overlap between the
33
- # user's query terms and a memory's text terms. Memories below this
34
- # threshold are dropped before formatting. 0.0 disables the gate
35
- # (current behaviour: inject everything Hindsight returns up to the
36
- # count cap). NOTE: Hindsight's HTTP recall API DOES return per-result
37
- # relevance scores (`scores.final`, plus `.semantic`/`.keyword`/
38
- # `.reranker`) — verified at runtime — and recall.py now reads and
39
- # sorts the merged set by `scores.final`. This lexical gate is a
40
- # separate quality filter layered on top — see #475. The metric is
41
- # containment, `|Q n M| / |M|`, not Jaccard: dividing by the union made
42
- # the score a function of prompt length rather than relevance — see
43
- # #3541 and recall.py's design note. At the 0.10 fleet default this is
44
- # close to a passthrough (a <=10-token memory clears it on one shared
45
- # word); precision is the engine rerank's job, not this gate's.
46
- "recallMinOverlap": 0.0,
32
+ # Switchroom-local: per-bank slot FLOORS inside `recallMaxMemories`. The
33
+ # merged multi-bank set is sorted globally by `scores.final` and then
34
+ # head-sliced, which is winner-take-all across banks: when both banks return
35
+ # more candidates than the cap, one bank's score distribution can fill every
36
+ # slot and the agent gets a dossier about its operator with none of its own
37
+ # working memory. These are FLOORS, not quotas: each side gets at most this
38
+ # many slots, only if it has that many results, and only up to HALF the cap
39
+ # between them — the other half is always awarded on pure global relevance,
40
+ # so composition still moves with the scores. 0 disables reservation for
41
+ # that side (the pure pre-fix head-slice). Env:
42
+ # HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS /
43
+ # HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS. See recall.py's
44
+ # `_reserve_bank_slots` and `_reservable_slots`. Vendor default is 0/0
45
+ # (off); switchroom's scaffold opts in with 2 own / 1 additional against the
46
+ # cap of 6 its fleet actually deploys.
47
+ "recallOwnBankMinSlots": 0,
48
+ "recallAdditionalBankMinSlots": 0,
47
49
  "recallTypes": ["world", "experience"],
48
50
  # Switchroom-local: when True (default; Ken-approved ON) recall biases
49
51
  # toward synthesized `observation`-tier facts. Escape hatch: pin off via
@@ -91,6 +93,29 @@ DEFAULTS = {
91
93
  # by recallTranscriptTailBytes so the added per-turn read stays O(1).
92
94
  "recallContextTurns": 2,
93
95
  "recallMaxQueryChars": 800,
96
+ # Switchroom #3757 — BM25 term budget for the query put on the wire.
97
+ # `recallMaxQueryChars` bounds CHARACTERS; the server's keyword arm costs
98
+ # per DISTINCT TERM, because it OR-joins every token into one tsquery and
99
+ # Postgres native FTS ranks the entire matched set before the top-60
100
+ # heapsort. An 800-char composed query is ~96 distinct terms and matched
101
+ # 119,510 rows on the live `overlord` bank — 14.0s for the 3-arm UNION,
102
+ # and up to 94s under load, past the
103
+ # per-bank client timeout, so the agent got NOTHING on 96.8% of its
104
+ # own-bank recalls in the 7 days to 2026-07-27. 24 terms measures at
105
+ # 48,433 rows / 2.7s on the same bank while keeping the high-signal terms
106
+ # of the latest turn. Selection is recency-first (latest turn beats prior
107
+ # context), then by a selectivity proxy — see lib/content.shape_recall_query.
108
+ # 0 disables shaping entirely (rollback lever).
109
+ # Operator knob: `memory.recall.query_max_tokens` in switchroom.yaml.
110
+ "recallQueryMaxTokens": 24,
111
+ # Switchroom #3757 — extra terms to drop from the BM25 query on top of the
112
+ # built-in English stopword list. For BANK-SPECIFIC high-document-frequency
113
+ # words a generic stoplist cannot know about: on `overlord`, `switchroom`
114
+ # matches 27,090 of 135,443 rows (20%) and `agent` 26,496 (20%), purely
115
+ # because that is what the corpus is about. Empty by default — an operator
116
+ # sets it per-agent after reading `switchroom memory recall-log <agent>`.
117
+ # Operator knob: `memory.recall.query_stop_terms` in switchroom.yaml.
118
+ "recallQueryStopTerms": [],
94
119
  # Switchroom hindsight-leverage A2 (PR2) — latency bound for the multi-turn
95
120
  # composition. With recallContextTurns>1 now the default, EVERY recall reads
96
121
  # the transcript to slice the last N human turns. A long session's .jsonl can
@@ -204,6 +229,18 @@ DEFAULTS = {
204
229
  # can never push the hook past its ceiling. Slots still unfinished when the
205
230
  # deadline elapses are abandoned (daemon threads) and marked timed_out.
206
231
  "recallParallelDeadlineSeconds": 10,
232
+ # Switchroom #3757 — per-bank HTTP read timeout (seconds) for one recall
233
+ # request. Was a hardcoded `timeout=8` in recall.py, which made it BOTH the
234
+ # binding constraint on a slow bank AND un-tunable without hand-editing the
235
+ # installed plugin — and a hand-edit does not survive `switchroom apply`,
236
+ # which re-copies the plugin from `vendor/hindsight-memory` (that revert is
237
+ # exactly what put the 8s literal back on 2026-07-27). 12s matches the
238
+ # UserPromptSubmit hook ceiling in hooks.json; the shared
239
+ # `recallParallelDeadlineSeconds` (10s) is the tighter outer guard in the
240
+ # default configuration, so this is a per-request safety net rather than
241
+ # the primary bound. Non-positive values fall back to the default.
242
+ # Operator knob: `memory.recall.request_timeout_seconds` in switchroom.yaml.
243
+ "recallRequestTimeoutSeconds": 12,
207
244
  # Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
208
245
  # fallback. Boot reconciliation (reconcile_tail.py) closes the crash-loss
209
246
  # window at the NEXT SessionStart, but between an abrupt kill and that boot,
@@ -282,10 +319,11 @@ ENV_OVERRIDES = {
282
319
  # agents.<name>.memory.recall.max_memories (cascading through
283
320
  # defaults.memory.recall.max_memories) when present in switchroom.yaml.
284
321
  "HINDSIGHT_RECALL_MAX_MEMORIES": ("recallMaxMemories", int),
285
- # Switchroom-local: lexical-overlap threshold (#475). Float in
286
- # [0.0, 1.0]. Set by start.sh from agents.<name>.memory.recall.min_overlap
287
- # (cascading through defaults). 0.0 = off (current behaviour).
288
- "HINDSIGHT_RECALL_MIN_OVERLAP": ("recallMinOverlap", float),
322
+ # Switchroom-local: per-bank slot floors inside the count cap. Set by
323
+ # start.sh from agents.<name>.memory.recall.own_bank_min_slots /
324
+ # .additional_bank_min_slots (cascading through defaults). 0 = off.
325
+ "HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS": ("recallOwnBankMinSlots", int),
326
+ "HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS": ("recallAdditionalBankMinSlots", int),
289
327
  # Switchroom-local: recall fact types (comma-separated). Set by start.sh
290
328
  # from agents.<name>.memory.recall.types only when the operator overrode
291
329
  # the switchroom default (world,experience,observation) — i.e. the
@@ -322,6 +360,10 @@ ENV_OVERRIDES = {
322
360
  "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_CHARS": ("recallTranscriptFallbackMaxChars", int),
323
361
  "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_DEADLINE_MS": ("recallTranscriptFallbackDeadlineMs", int),
324
362
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
363
+ # Switchroom #3757 — BM25 query shaping + per-request timeout.
364
+ "HINDSIGHT_RECALL_QUERY_MAX_TOKENS": ("recallQueryMaxTokens", int),
365
+ "HINDSIGHT_RECALL_QUERY_STOP_TERMS": ("recallQueryStopTerms", list),
366
+ "HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS": ("recallRequestTimeoutSeconds", int),
325
367
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
326
368
  # Switchroom hindsight-leverage A2 — byte-tail bound for the multi-turn
327
369
  # transcript read (0 = read whole file / rollback lever).
@@ -115,7 +115,18 @@ def compose_recall_query(
115
115
  if role == "user" and content == latest:
116
116
  continue
117
117
 
118
- context_lines.append(f"{role}: {content}")
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
  # ---------------------------------------------------------------------------