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.
Files changed (61) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. 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 does not expose similarity
37
- # scores, so this is the switchroom-side quality filter — see #475.
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 does not return similarity scores. Without a
377
- # score the existing `recallMaxMemories` cap acts as a *floor* on
378
- # low-relevance prompts: weak matches still fill the slot up to N,
379
- # mis-steering the model. This gate computes Jaccard overlap between
380
- # the user's query terms and each memory's text terms, and drops
381
- # memories below a configurable threshold.
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
@@ -74,6 +74,7 @@ class _FakeClient:
74
74
  tags=None,
75
75
  tags_match=None,
76
76
  tag_groups=None,
77
+ prefer_observations=None,
77
78
  timeout=10,
78
79
  ):
79
80
  self.recall_calls.append(
@@ -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