switchroom 0.17.10 → 0.18.3
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +27 -1
- package/dist/auth-broker/index.js +6161 -151
- package/dist/cli/notion-write-pretool.mjs +29 -2
- package/dist/cli/switchroom.js +578 -454
- package/dist/host-control/main.js +6182 -172
- package/dist/vault/approvals/kernel-server.js +5891 -164
- package/dist/vault/broker/server.js +6597 -881
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +2 -2
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +480 -85
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -124,11 +124,19 @@ class HindsightClient:
|
|
|
124
124
|
tags: Optional[list] = None,
|
|
125
125
|
tags_match: Optional[str] = None,
|
|
126
126
|
tag_groups: Optional[object] = None,
|
|
127
|
+
prefer_observations: Optional[bool] = None,
|
|
127
128
|
timeout: int = 10,
|
|
128
129
|
) -> dict:
|
|
129
130
|
"""Recall memories from a bank.
|
|
130
131
|
|
|
131
|
-
Returns the raw API response dict with 'results' list.
|
|
132
|
+
Returns the raw API response dict with 'results' list. Each result
|
|
133
|
+
carries a `scores` object (`RecallScores`) whose `final` field is the
|
|
134
|
+
engine's combined ranking score — callers sort the merged multi-bank
|
|
135
|
+
set by it before applying any count cap.
|
|
136
|
+
|
|
137
|
+
`prefer_observations=True` asks the engine to prefer deduped
|
|
138
|
+
observation statements over the raw facts they supersede, backfilling
|
|
139
|
+
the freed slots — denser coverage inside the same token/count budget.
|
|
132
140
|
"""
|
|
133
141
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
|
|
134
142
|
body = {
|
|
@@ -145,6 +153,8 @@ class HindsightClient:
|
|
|
145
153
|
body["tags_match"] = tags_match
|
|
146
154
|
if tag_groups:
|
|
147
155
|
body["tag_groups"] = tag_groups
|
|
156
|
+
if prefer_observations is not None:
|
|
157
|
+
body["prefer_observations"] = prefer_observations
|
|
148
158
|
return self._request("POST", path, body, timeout=timeout)
|
|
149
159
|
|
|
150
160
|
def retain(
|
|
@@ -33,10 +33,17 @@ DEFAULTS = {
|
|
|
33
33
|
# user's query terms and a memory's text terms. Memories below this
|
|
34
34
|
# threshold are dropped before formatting. 0.0 disables the gate
|
|
35
35
|
# (current behaviour: inject everything Hindsight returns up to the
|
|
36
|
-
# count cap). Hindsight's HTTP API
|
|
37
|
-
# scores
|
|
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 Jaccard gate is a
|
|
40
|
+
# separate lexical-overlap quality filter layered on top — see #475.
|
|
38
41
|
"recallMinOverlap": 0.0,
|
|
39
42
|
"recallTypes": ["world", "experience"],
|
|
43
|
+
# Switchroom-local: when True (default; Ken-approved ON) recall biases
|
|
44
|
+
# toward synthesized `observation`-tier facts. Escape hatch: pin off via
|
|
45
|
+
# `recallPreferObservations: false` in the user config — read in recall.py.
|
|
46
|
+
"recallPreferObservations": True,
|
|
40
47
|
# Switchroom #2848 Stage B/C — deterministic directive capture.
|
|
41
48
|
# When on (switchroom default; pinned true in the copied plugin
|
|
42
49
|
# settings.json by applyHindsightSettingsOverrides), TWO deterministic
|
|
@@ -373,12 +373,18 @@ def _is_demoted_memory(memory) -> bool:
|
|
|
373
373
|
|
|
374
374
|
# Switchroom #475 — lexical-overlap relevance gate.
|
|
375
375
|
#
|
|
376
|
-
# Hindsight's HTTP API
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
376
|
+
# Hindsight's HTTP recall API DOES return per-result relevance scores
|
|
377
|
+
# (`RecallResult.scores.final`, plus `.semantic`/`.keyword`/`.reranker`);
|
|
378
|
+
# the merged multi-bank set is now sorted by `scores.final` before the
|
|
379
|
+
# `recallMaxMemories` cap (see the sort just before the cap in
|
|
380
|
+
# process_recall) so the most relevant memories survive the head-slice
|
|
381
|
+
# regardless of which bank they came from. This gate is a *complementary*,
|
|
382
|
+
# opt-in absolute precision floor: `scores.final` is a relative rank that
|
|
383
|
+
# still orders weakly-matching memories rather than excluding them, so on a
|
|
384
|
+
# low-relevance prompt the top-N could still be low-signal. The Jaccard
|
|
385
|
+
# overlap between the user's query terms and each memory's text terms is a
|
|
386
|
+
# query-independent absolute measure that drops memories below a
|
|
387
|
+
# configurable threshold outright — something the relative sort does not do.
|
|
382
388
|
#
|
|
383
389
|
# Threshold default is 0.0 (disabled) so the gate is opt-in initially.
|
|
384
390
|
# Operators tune via `memory.recall.min_overlap` in switchroom.yaml or
|
|
@@ -469,6 +475,40 @@ def _filter_by_overlap(results, query: str, threshold: float):
|
|
|
469
475
|
return kept, dropped
|
|
470
476
|
|
|
471
477
|
|
|
478
|
+
def _result_final_score(m) -> float:
|
|
479
|
+
"""Return a result's engine relevance score (`scores.final`).
|
|
480
|
+
|
|
481
|
+
Switchroom Phase-1 precision. The Hindsight recall response attaches a
|
|
482
|
+
`scores` object to every result whose required `final` field is the
|
|
483
|
+
engine's combined ranking score (reranker + recency/temporal/proof
|
|
484
|
+
boosts). Results missing a usable score sort last so a malformed or
|
|
485
|
+
score-less entry can never starve a properly-ranked one.
|
|
486
|
+
"""
|
|
487
|
+
if isinstance(m, dict):
|
|
488
|
+
scores = m.get("scores")
|
|
489
|
+
if isinstance(scores, dict):
|
|
490
|
+
val = scores.get("final")
|
|
491
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
492
|
+
return float(val)
|
|
493
|
+
return float("-inf")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _sort_by_final_score(results):
|
|
497
|
+
"""Sort merged multi-bank results by `scores.final` descending, in place.
|
|
498
|
+
|
|
499
|
+
Switchroom Phase-1 bank-starvation fix. The recall path appends
|
|
500
|
+
additional-bank (profile / shared / sender) results after the own-bank
|
|
501
|
+
results, then head-slices at `recallMaxMemories`. Before this sort, a
|
|
502
|
+
full own-bank result set silently dropped every additional-bank memory
|
|
503
|
+
at the cap regardless of relevance. Sorting by the engine's real
|
|
504
|
+
relevance score before the cap means the cap keeps the most relevant
|
|
505
|
+
memories cross-bank. Python's sort is stable, so ties preserve the
|
|
506
|
+
prior own-bank-first insertion order.
|
|
507
|
+
"""
|
|
508
|
+
results.sort(key=_result_final_score, reverse=True)
|
|
509
|
+
return results
|
|
510
|
+
|
|
511
|
+
|
|
472
512
|
def _write_recall_log(entry: dict) -> None:
|
|
473
513
|
"""Append a JSONL line to recall_log.jsonl. Bounded by line count.
|
|
474
514
|
|
|
@@ -986,6 +1026,11 @@ def main():
|
|
|
986
1026
|
tags=recall_tags,
|
|
987
1027
|
tags_match=tags_match,
|
|
988
1028
|
tag_groups=tag_groups,
|
|
1029
|
+
# Switchroom Phase-1 precision — prefer deduped observation
|
|
1030
|
+
# statements over the raw facts they supersede, backfilling freed
|
|
1031
|
+
# slots for denser coverage inside the same budget. On by default;
|
|
1032
|
+
# operators can pin off via `recallPreferObservations: false`.
|
|
1033
|
+
prefer_observations=config.get("recallPreferObservations", True),
|
|
989
1034
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
990
1035
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
991
1036
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -1031,6 +1076,10 @@ def main():
|
|
|
1031
1076
|
tags=extra_tags,
|
|
1032
1077
|
tags_match=extra_tags_match,
|
|
1033
1078
|
tag_groups=extra_tag_groups,
|
|
1079
|
+
# Switchroom Phase-1 precision — prefer deduped observation
|
|
1080
|
+
# statements here too so additional banks contribute their
|
|
1081
|
+
# densest statements to the merged, score-sorted set.
|
|
1082
|
+
prefer_observations=config.get("recallPreferObservations", True),
|
|
1034
1083
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
1035
1084
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
1036
1085
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -1099,6 +1148,15 @@ def main():
|
|
|
1099
1148
|
else:
|
|
1100
1149
|
overlap_dropped = 0
|
|
1101
1150
|
|
|
1151
|
+
# Switchroom Phase-1 precision — sort the merged primary + additional-bank
|
|
1152
|
+
# result set by the engine's relevance score (`scores.final`) descending
|
|
1153
|
+
# BEFORE the head-slice cap below. Previously additional-bank results were
|
|
1154
|
+
# appended after own-bank results and sliced off, silently starving
|
|
1155
|
+
# profile / shared / sender banks whenever own-bank filled the cap. Sorting
|
|
1156
|
+
# by real relevance first means the cap keeps the most relevant memories
|
|
1157
|
+
# regardless of source bank. Stable sort: ties keep own-bank-first order.
|
|
1158
|
+
_sort_by_final_score(results)
|
|
1159
|
+
|
|
1102
1160
|
# Switchroom-local: client-side count cap. Plugin v0.4.0 has no
|
|
1103
1161
|
# `recallTopK` in the Claude Code integration (Openclaw-only), and a
|
|
1104
1162
|
# token budget alone doesn't bound count — a single long memory can
|
|
@@ -274,6 +274,49 @@ class TestHindsightClientRecallTagFilters:
|
|
|
274
274
|
assert "tag_groups" not in captured["body"]
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
class TestHindsightClientPreferObservations:
|
|
278
|
+
"""Switchroom Phase-1 — prefer_observations forwarded in the recall body."""
|
|
279
|
+
|
|
280
|
+
def test_forwards_prefer_observations_true(self):
|
|
281
|
+
c = HindsightClient("http://localhost:9077")
|
|
282
|
+
captured = {}
|
|
283
|
+
|
|
284
|
+
def fake_open(req, timeout=None):
|
|
285
|
+
captured["body"] = json.loads(req.data.decode())
|
|
286
|
+
return FakeResp({"results": []})
|
|
287
|
+
|
|
288
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
289
|
+
c.recall("bank", "query", prefer_observations=True)
|
|
290
|
+
|
|
291
|
+
assert captured["body"]["prefer_observations"] is True
|
|
292
|
+
|
|
293
|
+
def test_forwards_prefer_observations_false(self):
|
|
294
|
+
c = HindsightClient("http://localhost:9077")
|
|
295
|
+
captured = {}
|
|
296
|
+
|
|
297
|
+
def fake_open(req, timeout=None):
|
|
298
|
+
captured["body"] = json.loads(req.data.decode())
|
|
299
|
+
return FakeResp({"results": []})
|
|
300
|
+
|
|
301
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
302
|
+
c.recall("bank", "query", prefer_observations=False)
|
|
303
|
+
|
|
304
|
+
assert captured["body"]["prefer_observations"] is False
|
|
305
|
+
|
|
306
|
+
def test_omits_prefer_observations_when_unset(self):
|
|
307
|
+
c = HindsightClient("http://localhost:9077")
|
|
308
|
+
captured = {}
|
|
309
|
+
|
|
310
|
+
def fake_open(req, timeout=None):
|
|
311
|
+
captured["body"] = json.loads(req.data.decode())
|
|
312
|
+
return FakeResp({"results": []})
|
|
313
|
+
|
|
314
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
315
|
+
c.recall("bank", "query")
|
|
316
|
+
|
|
317
|
+
assert "prefer_observations" not in captured["body"]
|
|
318
|
+
|
|
319
|
+
|
|
277
320
|
class TestRequestTimeoutOverride:
|
|
278
321
|
"""Upstream 55ef70679 — the constructor override replaces the per-call
|
|
279
322
|
timeout that recall/retain/_request would otherwise use. When unset,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Retrieval-precision measurement for the Switchroom Phase-1 memory changes.
|
|
2
|
+
|
|
3
|
+
Per the RFC's outcome-UAT rule (reference/rfcs/hindsight-memory-reimagined.md,
|
|
4
|
+
"Measurement"), the bank-starvation fix ships behind a check that compares
|
|
5
|
+
recalled-set relevance before/after the sort. These tests assert the two
|
|
6
|
+
load-bearing properties of the fix:
|
|
7
|
+
|
|
8
|
+
1. The merged multi-bank result set is ordered by the engine's real
|
|
9
|
+
relevance score (`scores.final`) descending.
|
|
10
|
+
2. A high-relevance memory from an ADDITIONAL bank is not starved by the
|
|
11
|
+
count cap when the own bank supplies enough lower-relevance hits to
|
|
12
|
+
fill it — the pre-fix bug (append-then-slice) would drop it.
|
|
13
|
+
|
|
14
|
+
`_sort_by_final_score` is the in-place sort applied just before the cap in
|
|
15
|
+
`process_recall`; the cap itself is a plain head-slice, reproduced here so the
|
|
16
|
+
before/after relevance of the capped set is directly comparable.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from recall import _result_final_score, _sort_by_final_score
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mem(mem_id, final, bank):
|
|
23
|
+
"""A recall result as the engine returns it: text + a scores object."""
|
|
24
|
+
return {
|
|
25
|
+
"id": mem_id,
|
|
26
|
+
"text": f"memory {mem_id} from {bank}",
|
|
27
|
+
"bank": bank,
|
|
28
|
+
"scores": {"final": final, "semantic": final, "keyword": final},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _capped_relevance(results, cap):
|
|
33
|
+
"""Mirror process_recall: head-slice at the cap, return kept scores."""
|
|
34
|
+
kept = results[:cap] if cap > 0 else results
|
|
35
|
+
return [_result_final_score(m) for m in kept]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestFinalScoreExtraction:
|
|
39
|
+
def test_reads_final_score(self):
|
|
40
|
+
assert _result_final_score(_mem("a", 0.9, "own")) == 0.9
|
|
41
|
+
|
|
42
|
+
def test_missing_scores_sorts_last(self):
|
|
43
|
+
assert _result_final_score({"id": "x", "text": "t"}) == float("-inf")
|
|
44
|
+
|
|
45
|
+
def test_null_scores_sorts_last(self):
|
|
46
|
+
assert _result_final_score({"id": "x", "scores": None}) == float("-inf")
|
|
47
|
+
|
|
48
|
+
def test_missing_final_sorts_last(self):
|
|
49
|
+
assert _result_final_score({"id": "x", "scores": {"semantic": 0.5}}) == float("-inf")
|
|
50
|
+
|
|
51
|
+
def test_bool_is_not_a_score(self):
|
|
52
|
+
# True is an int subclass; it must not be mistaken for a 1.0 score.
|
|
53
|
+
assert _result_final_score({"id": "x", "scores": {"final": True}}) == float("-inf")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestSortByFinalScore:
|
|
57
|
+
def test_orders_descending(self):
|
|
58
|
+
results = [_mem("a", 0.2, "own"), _mem("b", 0.9, "own"), _mem("c", 0.5, "own")]
|
|
59
|
+
_sort_by_final_score(results)
|
|
60
|
+
assert [m["id"] for m in results] == ["b", "c", "a"]
|
|
61
|
+
|
|
62
|
+
def test_stable_on_ties_preserves_own_bank_first(self):
|
|
63
|
+
# Own-bank appears before additional-bank in insertion order; on a
|
|
64
|
+
# score tie the stable sort must keep own-bank ahead.
|
|
65
|
+
results = [_mem("own1", 0.5, "own"), _mem("extra1", 0.5, "profile")]
|
|
66
|
+
_sort_by_final_score(results)
|
|
67
|
+
assert [m["id"] for m in results] == ["own1", "extra1"]
|
|
68
|
+
|
|
69
|
+
def test_scoreless_entries_sink_to_the_bottom(self):
|
|
70
|
+
results = [{"id": "noscore", "text": "t"}, _mem("scored", 0.1, "own")]
|
|
71
|
+
_sort_by_final_score(results)
|
|
72
|
+
assert results[0]["id"] == "scored"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestCrossBankStarvationRegression:
|
|
76
|
+
"""The bug the fix targets: a relevant additional-bank memory dropped at
|
|
77
|
+
the cap because own-bank hits were appended first."""
|
|
78
|
+
|
|
79
|
+
def test_high_relevance_additional_bank_memory_survives_cap(self):
|
|
80
|
+
# Own bank fills the cap with mediocre hits; the profile bank has the
|
|
81
|
+
# single most relevant memory. Pre-fix (append own, then extra, then
|
|
82
|
+
# head-slice) the profile hit lands at index 2 and is sliced off.
|
|
83
|
+
cap = 2
|
|
84
|
+
own = [_mem("own_lo1", 0.30, "own"), _mem("own_lo2", 0.25, "own")]
|
|
85
|
+
extra = [_mem("profile_hi", 0.95, "profile")]
|
|
86
|
+
merged = own + extra # exactly the pre-fix append order
|
|
87
|
+
|
|
88
|
+
# Before: append-then-slice starves the profile bank.
|
|
89
|
+
pre_fix_ids = [m["id"] for m in merged[:cap]]
|
|
90
|
+
assert "profile_hi" not in pre_fix_ids
|
|
91
|
+
|
|
92
|
+
# After: sort by scores.final before the slice keeps the best memory.
|
|
93
|
+
_sort_by_final_score(merged)
|
|
94
|
+
post_fix_ids = [m["id"] for m in merged[:cap]]
|
|
95
|
+
assert "profile_hi" in post_fix_ids
|
|
96
|
+
assert post_fix_ids[0] == "profile_hi"
|
|
97
|
+
|
|
98
|
+
def test_capped_set_relevance_is_no_worse_after_sort(self):
|
|
99
|
+
# Measurement: summed relevance of the capped set must not decrease.
|
|
100
|
+
cap = 3
|
|
101
|
+
merged = [
|
|
102
|
+
_mem("own1", 0.4, "own"),
|
|
103
|
+
_mem("own2", 0.35, "own"),
|
|
104
|
+
_mem("own3", 0.3, "own"),
|
|
105
|
+
_mem("profile1", 0.9, "profile"),
|
|
106
|
+
_mem("shared1", 0.8, "shared"),
|
|
107
|
+
]
|
|
108
|
+
before = sum(_capped_relevance(list(merged), cap))
|
|
109
|
+
_sort_by_final_score(merged)
|
|
110
|
+
after = sum(_capped_relevance(merged, cap))
|
|
111
|
+
assert after >= before
|
|
112
|
+
# And concretely, the two top additional-bank hits are now retained.
|
|
113
|
+
kept_ids = [m["id"] for m in merged[:cap]]
|
|
114
|
+
assert "profile1" in kept_ids and "shared1" in kept_ids
|