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.
- package/dist/agent-scheduler/index.js +20 -7
- package/dist/auth-broker/index.js +93 -28
- 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 +20 -6
- package/dist/cli/switchroom.js +3091 -1435
- package/dist/host-control/main.js +92 -29
- package/dist/vault/approvals/kernel-server.js +92 -28
- package/dist/vault/broker/server.js +258 -71
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +92 -17
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- 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_request_timeout.py +241 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- 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:
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
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:
|
|
286
|
-
#
|
|
287
|
-
# (cascading through defaults). 0
|
|
288
|
-
"
|
|
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
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|