switchroom 0.20.11 → 0.20.13

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -1037,6 +1037,116 @@ def _write_recall_log(entry: dict) -> None:
1037
1037
  pass
1038
1038
 
1039
1039
 
1040
+ # Switchroom recall-latency instrumentation — full-hook wall-time.
1041
+ #
1042
+ # recall.py is the UserPromptSubmit hook that sits in front of EVERY reply
1043
+ # (pre-first-token), yet it was the one hook with no wall-time record: it is a
1044
+ # DIRECT Claude Code plugin hook (hooks/hooks.json), NOT wrapped by
1045
+ # bin/run-hook.sh, so it never emitted a `hook-timings-<Ddd>.log` row the way
1046
+ # every wrapped hook does, and `recall_log.jsonl` measured only the recall
1047
+ # critical path (`total_elapsed_ms`, from `recall_start_monotonic`) — never the
1048
+ # hook's own import + stdin + cache-check + gate overhead.
1049
+ #
1050
+ # Routing it through run-hook.sh was rejected as the mechanism: the vendored
1051
+ # hooks.json is re-copied verbatim into every agent's plugin dir on `switchroom
1052
+ # apply`, and run-hook.sh lives in the switchroom repo's bin/, not under
1053
+ # CLAUDE_PLUGIN_ROOT — coupling the vendor snapshot to switchroom's bin layout is
1054
+ # fragile, and the os._exit(0) fast-path (which skips atexit / thread-join to
1055
+ # return control the instant stdout is flushed) would have to be reconciled with
1056
+ # the wrapper. Instead the hook emits the SAME JSON line, into the SAME weekday-
1057
+ # ring file, honouring the SAME env knobs, from inside the process at exit — so
1058
+ # `grep duration_ms hook-timings-*.log` sees this hook next to every other one.
1059
+ HOOK_TIMING_SOURCE = "hook:hindsight-recall"
1060
+ HOOK_TIMING_CODE = "recall.py"
1061
+
1062
+
1063
+ def _hook_duration_ms() -> int:
1064
+ """Milliseconds since this hook process began doing work.
1065
+
1066
+ Anchored at import start (`_IMPORT_START_MONOTONIC`, taken before any of this
1067
+ hook's own imports ran — recall.py line ~48), so the number spans the WHOLE
1068
+ hook: dependency import, the stdin read, the cache check, the gate
1069
+ short-circuits, and — when it got that far — the recall round-trips. That is
1070
+ deliberately WIDER than `total_elapsed_ms` (the recall critical path only,
1071
+ measured from `recall_start_monotonic`): `duration_ms - total_elapsed_ms` is
1072
+ therefore the pre-recall LOCAL overhead, and the per-bank
1073
+ `bank_timings[].elapsed_ms` remain the Hindsight server round-trips — so the
1074
+ log already separates server time from local overhead without a new field.
1075
+ """
1076
+ return int((time.monotonic() - _IMPORT_START_MONOTONIC) * 1000)
1077
+
1078
+
1079
+ def _emit_hook_timing_log(duration_ms: int, status: int) -> None:
1080
+ """Append one timing line to `hook-timings-<Ddd>.log`, matching run-hook.sh.
1081
+
1082
+ STDOUT-SAFE by construction: writes only to a log file, NEVER to the hook's
1083
+ stdout contract that Claude Code consumes. Failure-tolerant — any error is
1084
+ swallowed so instrumentation can never take the hook down. Honours the same
1085
+ env knobs as bin/run-hook.sh (`SWITCHROOM_HOOK_TIMING`,
1086
+ `SWITCHROOM_HOOK_TIMING_DIR`, `SWITCHROOM_HOOK_TIMING_MIN_MS`) and reproduces
1087
+ its 7-day self-truncating weekday ring and JSON line shape exactly, so a
1088
+ consumer cannot tell this row apart from a wrapped hook's.
1089
+
1090
+ NOTE on the 12s ceiling: if Claude Code kills this hook at the
1091
+ UserPromptSubmit timeout, the process is terminated before any exit path runs
1092
+ and NO timing line (nor recall_log row) is written — the MISSING line is the
1093
+ breach signal, consistent with the two-signal baseline documented at the
1094
+ recall_log write. This records every invocation that returns under budget,
1095
+ including a fully-degraded/timed-out recall that still exits cleanly.
1096
+ """
1097
+ try:
1098
+ if os.environ.get("SWITCHROOM_HOOK_TIMING", "1") == "0":
1099
+ return
1100
+ timing_dir = os.environ.get("SWITCHROOM_HOOK_TIMING_DIR") or os.environ.get(
1101
+ "TELEGRAM_STATE_DIR", ""
1102
+ )
1103
+ if not timing_dir or not os.path.isdir(timing_dir):
1104
+ return
1105
+ try:
1106
+ duration_ms = int(duration_ms)
1107
+ except (TypeError, ValueError):
1108
+ return
1109
+ if duration_ms < 0:
1110
+ duration_ms = 0
1111
+ try:
1112
+ min_ms = int(os.environ.get("SWITCHROOM_HOOK_TIMING_MIN_MS", "0"))
1113
+ except (TypeError, ValueError):
1114
+ min_ms = 0
1115
+ if duration_ms < min_ms:
1116
+ return
1117
+ # Local wall clock, matching run-hook.sh's builtin `%(...)T` formatting so
1118
+ # both writers agree on which weekday-ring file today lands in.
1119
+ now = time.localtime()
1120
+ today = time.strftime("%Y-%m-%d", now)
1121
+ dow = time.strftime("%a", now)
1122
+ ts = time.strftime("%Y-%m-%dT%H:%M:%S%z", now)
1123
+ logfile = os.path.join(timing_dir, f"hook-timings-{dow}.log")
1124
+ # 7-day self-truncating ring: the weekday-named file is either today's or
1125
+ # exactly a week stale. If its first line does not carry today's date,
1126
+ # reset it before appending (same rule as run-hook.sh).
1127
+ try:
1128
+ if os.path.getsize(logfile) > 0:
1129
+ with open(logfile, encoding="utf-8") as f:
1130
+ first = f.readline()
1131
+ if f'"date":"{today}"' not in first:
1132
+ open(logfile, "w", encoding="utf-8").close()
1133
+ except OSError:
1134
+ pass
1135
+ # HOOK_TIMING_SOURCE / HOOK_TIMING_CODE are fixed constants with no JSON
1136
+ # metacharacters, so no escape pass is needed (unlike run-hook.sh, whose
1137
+ # source/code are caller-supplied).
1138
+ line = (
1139
+ '{"ts":"%s","date":"%s","source":"%s","code":"%s",'
1140
+ '"duration_ms":%d,"status":%d}\n'
1141
+ % (ts, today, HOOK_TIMING_SOURCE, HOOK_TIMING_CODE, duration_ms, status)
1142
+ )
1143
+ with open(logfile, "a", encoding="utf-8") as f:
1144
+ f.write(line)
1145
+ except Exception:
1146
+ # Instrumentation is never load-bearing — swallow everything.
1147
+ pass
1148
+
1149
+
1040
1150
  def _read_transcript_lines(transcript_path: str, tail_bytes: int):
1041
1151
  """Yield the transcript's trailing lines, byte-bounded.
1042
1152
 
@@ -1824,6 +1934,12 @@ def main():
1824
1934
  # timed out"; `deadline_hit is None` means "no banks ran"
1825
1935
  # (review finding 3).
1826
1936
  "total_elapsed_ms": None,
1937
+ # Switchroom recall-latency instrumentation — full-hook wall time
1938
+ # (import + stdin + cache check), measured to this log write. A
1939
+ # cache hit issues no bank HTTP, so `total_elapsed_ms` is None and
1940
+ # this is pure local overhead — the cheap path this cache exists
1941
+ # to create, now visible per-row.
1942
+ "duration_ms": _hook_duration_ms(),
1827
1943
  "directives_elapsed_ms": None,
1828
1944
  "bank_timings": [],
1829
1945
  "deadline_hit": None,
@@ -2542,6 +2658,16 @@ def main():
2542
2658
  # parallelism change measures against; the 17-26% figure it replaces is
2543
2659
  # the stale 2026-05-24 pre-fix audit.
2544
2660
  "total_elapsed_ms": int((time.monotonic() - recall_start_monotonic) * 1000),
2661
+ # Switchroom recall-latency instrumentation — FULL-HOOK wall time to this
2662
+ # log write: dependency import + stdin read + cache check + the gate
2663
+ # short-circuits + the whole recall critical path. `total_elapsed_ms`
2664
+ # above is the recall critical path ONLY, so `duration_ms -
2665
+ # total_elapsed_ms` is the pre-recall LOCAL overhead this row could not
2666
+ # see before, while `bank_timings[].elapsed_ms` stay the server round-
2667
+ # trips — the log now separates server time from local overhead. Written
2668
+ # here (with the rest of the row, before the empty-block return) so even a
2669
+ # fully-timed-out / degraded recall that reaches this line gets a duration.
2670
+ "duration_ms": _hook_duration_ms(),
2545
2671
  "directives_elapsed_ms": directives_elapsed_ms,
2546
2672
  "bank_timings": bank_timings,
2547
2673
  # Switchroom hindsight-leverage A3 — FINALIZED `deadline_hit` semantics
@@ -2795,6 +2921,12 @@ if __name__ == "__main__":
2795
2921
  sys.stdout.flush()
2796
2922
  except Exception:
2797
2923
  pass
2924
+ # Switchroom recall-latency instrumentation — emit the full-hook timing
2925
+ # line AFTER stdout is flushed (Claude Code already has the bytes) and
2926
+ # BEFORE os._exit, which skips atexit and would otherwise drop it. Adds
2927
+ # ~0.5ms (one file append) before the process exits — the same budget
2928
+ # run-hook.sh spends per wrapped hook. Stdout is untouched.
2929
+ _emit_hook_timing_log(_hook_duration_ms(), 0)
2798
2930
  os._exit(0)
2799
2931
  except Exception as e:
2800
2932
  # Switchroom #1070 (redo per #1085 review).
@@ -2844,6 +2976,10 @@ if __name__ == "__main__":
2844
2976
  import traceback
2845
2977
 
2846
2978
  traceback.print_exc(file=sys.stderr)
2979
+ # Instrumentation: record the failed invocation's wall time too
2980
+ # (status 2, the debug-mode block behaviour) so a crash-looping
2981
+ # hook is visible in the timing log, not just a silent gap.
2982
+ _emit_hook_timing_log(_hook_duration_ms(), 2)
2847
2983
  # Debug-mode exit 2 is intentional and unchanged —
2848
2984
  # operators with HINDSIGHT_DEBUG=1 are chasing a broken
2849
2985
  # recall and want the hook to surface its failure.
@@ -2853,4 +2989,8 @@ if __name__ == "__main__":
2853
2989
  # 0 with no stdout (agent's prompt assembly treats absent
2854
2990
  # additionalContext as "no recall this turn").
2855
2991
  _record_issue_safely(_detail, _class)
2992
+ # Instrumentation: the non-debug exit code is 0 (the safe-empty stdout
2993
+ # posture), so log status 0 — the accompanying issue-sink record is where
2994
+ # the failure detail lives; this row just makes the latency observable.
2995
+ _emit_hook_timing_log(_hook_duration_ms(), 0)
2856
2996
  sys.exit(0)
@@ -0,0 +1,277 @@
1
+ """Switchroom recall-latency instrumentation tests.
2
+
3
+ recall.py is the UserPromptSubmit hook in front of every reply, and until now
4
+ its wall time was UNMEASURED: it is a direct plugin hook (not wrapped by
5
+ bin/run-hook.sh, so no hook-timings row) and recall_log.jsonl carried no
6
+ duration field. These tests lock in the two things this PR added:
7
+
8
+ 1. Every recall_log row (cache hit AND cache miss) carries a numeric
9
+ `duration_ms` — including a degraded/failed recall.
10
+ 2. The hook emits a `hook-timings-<Ddd>.log` line matching bin/run-hook.sh's
11
+ schema, into the same weekday ring, honouring the same env knobs.
12
+ 3. The instrumentation NEVER pollutes the hook's stdout contract (the
13
+ recall-context JSON Claude Code consumes is byte-identical).
14
+
15
+ Stdlib-only (unittest + mock). Reuses the integration harness's fakes.
16
+ """
17
+
18
+ import io
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import unittest
25
+ from unittest.mock import patch
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+ TESTS_DIR = os.path.abspath(os.path.dirname(__file__))
31
+ if TESTS_DIR not in sys.path:
32
+ sys.path.insert(0, TESTS_DIR)
33
+
34
+ import recall # noqa: E402
35
+ from test_recall_integration import _FakeClient, _memory, _run_main_with # noqa: E402
36
+
37
+
38
+ class DurationMsInRecallLogTests(unittest.TestCase):
39
+ """recall_log.jsonl rows must carry a numeric `duration_ms`."""
40
+
41
+ def setUp(self):
42
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-latency-test-")
43
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
44
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
45
+
46
+ def tearDown(self):
47
+ import shutil
48
+
49
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
50
+ if self._prev is None:
51
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
52
+ else:
53
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
54
+
55
+ def _read_log(self):
56
+ path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
57
+ if not os.path.isfile(path):
58
+ return []
59
+ with open(path, encoding="utf-8") as f:
60
+ return [json.loads(line) for line in f if line.strip()]
61
+
62
+ def test_cache_miss_row_has_numeric_duration_ms(self):
63
+ client = _FakeClient(directives=[], memories=[_memory("x", mem_id="x1")])
64
+ _run_main_with(client)
65
+ e = self._read_log()[0]
66
+ self.assertFalse(e["cache_hit"])
67
+ self.assertIn("duration_ms", e)
68
+ self.assertIsInstance(e["duration_ms"], int)
69
+ self.assertGreaterEqual(e["duration_ms"], 0)
70
+ # duration_ms (full hook) must be >= total_elapsed_ms (recall path only):
71
+ # it strictly contains it, so the derived local overhead is never negative.
72
+ self.assertGreaterEqual(e["duration_ms"], e["total_elapsed_ms"])
73
+
74
+ def test_degraded_recall_still_records_a_duration(self):
75
+ # The common case right now: the own bank is unreachable. The row must
76
+ # still carry a numeric duration so a slow/failed recall is attributable.
77
+ client = _FakeClient(
78
+ directives=[],
79
+ memories=[],
80
+ recall_exc=RuntimeError("HTTP 503 from http://localhost:18888"),
81
+ )
82
+ _run_main_with(client)
83
+ e = self._read_log()[0]
84
+ self.assertTrue(e["bank_errored"])
85
+ self.assertEqual(e["result_count"], 0)
86
+ self.assertIsInstance(e["duration_ms"], int)
87
+ self.assertGreaterEqual(e["duration_ms"], 0)
88
+
89
+ def _run_with_real_state(self, client, prompt):
90
+ """Invoke recall.main WITHOUT stubbing write_state/read_state, so the
91
+ per-session recall cache actually persists to CLAUDE_PLUGIN_DATA and a
92
+ second identical prompt takes the cache-hit path. (The shared
93
+ _run_main_with harness stubs write_state, which disables the cache.)"""
94
+ hook_input = {
95
+ "prompt": prompt,
96
+ "session_id": "cache-session",
97
+ "transcript_path": "",
98
+ "cwd": "/tmp",
99
+ }
100
+ config = {
101
+ "autoRecall": True,
102
+ "bankId": "test-bank",
103
+ "recallMaxTokens": 1024,
104
+ "recallBudget": "mid",
105
+ "recallContextTurns": 1,
106
+ "recallMaxQueryChars": 800,
107
+ "recallPromptPreamble": "",
108
+ "directivesCacheTtlSeconds": 0,
109
+ }
110
+ with patch.object(recall, "load_config", return_value=config), patch.object(
111
+ recall, "get_api_url", return_value="http://localhost:18888"
112
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
113
+ recall, "ensure_bank_mission", return_value=None
114
+ ), patch("sys.stdin", new=io.StringIO(json.dumps(hook_input))), patch(
115
+ "sys.stdout", new=io.StringIO()
116
+ ), patch("sys.stderr", new=io.StringIO()):
117
+ recall.main()
118
+
119
+ def test_cache_hit_row_has_numeric_duration_ms(self):
120
+ # Populate the cache, then hit it on a second identical prompt. The
121
+ # cache-hit row must also carry duration_ms (total_elapsed_ms is None
122
+ # there, so duration_ms is the only latency number on a cache hit).
123
+ client = _FakeClient(directives=[], memories=[_memory("x", mem_id="x1")])
124
+ with patch.dict(os.environ, {"HINDSIGHT_RECALL_CACHE_TTL_SECS": "60"}):
125
+ self._run_with_real_state(client, "What did we decide about auth?")
126
+ self._run_with_real_state(client, "What did we decide about auth?")
127
+ rows = self._read_log()
128
+ hit_rows = [r for r in rows if r["cache_hit"]]
129
+ self.assertTrue(hit_rows, "expected at least one cache-hit row")
130
+ e = hit_rows[0]
131
+ self.assertIsNone(e["total_elapsed_ms"])
132
+ self.assertIsInstance(e["duration_ms"], int)
133
+ self.assertGreaterEqual(e["duration_ms"], 0)
134
+
135
+
136
+ class StdoutUnchangedTests(unittest.TestCase):
137
+ """The instrumentation writes only to log files — the hook's stdout
138
+ (the recall-context JSON Claude Code consumes) must be byte-identical."""
139
+
140
+ def test_stdout_is_exact_and_carries_no_instrumentation(self):
141
+ client = _FakeClient(directives=[], memories=[_memory("a stored fact")])
142
+ # Pin the clock-derived preamble so the emitted block is deterministic,
143
+ # letting us assert the stdout bytes EXACTLY.
144
+ with patch.object(recall, "format_current_time", return_value="FIXED-TIME"):
145
+ ctx, raw = _run_main_with(client)
146
+
147
+ expected_context = (
148
+ "<hindsight_memories>\n"
149
+ "\n" # empty recallPromptPreamble
150
+ "Current time - FIXED-TIME\n\n"
151
+ + recall.format_memories([_memory("a stored fact")])
152
+ + "\n</hindsight_memories>"
153
+ )
154
+ expected_raw = json.dumps(
155
+ {
156
+ "hookSpecificOutput": {
157
+ "hookEventName": "UserPromptSubmit",
158
+ "additionalContext": expected_context,
159
+ }
160
+ }
161
+ )
162
+ self.assertEqual(raw, expected_raw)
163
+ # Belt-and-braces: no instrumentation artifact leaked into the contract.
164
+ self.assertNotIn("duration_ms", raw)
165
+ self.assertNotIn("hook-timings", raw)
166
+ self.assertNotIn("total_elapsed_ms", raw)
167
+
168
+ def test_empty_recall_emits_empty_stdout(self):
169
+ # A no-directive, no-memory turn still emits nothing on stdout — the
170
+ # timing line lands in a log file, never here.
171
+ client = _FakeClient(directives=[], memories=[])
172
+ _ctx, raw = _run_main_with(client)
173
+ self.assertEqual(raw.strip(), "")
174
+
175
+
176
+ class HookTimingLogEmissionTests(unittest.TestCase):
177
+ """_emit_hook_timing_log must reproduce bin/run-hook.sh's line schema,
178
+ weekday ring, and env-knob behaviour — the hook-timings row that lets this
179
+ UserPromptSubmit hook show up alongside every wrapped hook."""
180
+
181
+ def setUp(self):
182
+ self._tmpdir = tempfile.mkdtemp(prefix="hook-timings-test-")
183
+
184
+ def tearDown(self):
185
+ import shutil
186
+
187
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
188
+
189
+ def _logfile(self):
190
+ dow = time.strftime("%a", time.localtime())
191
+ return os.path.join(self._tmpdir, f"hook-timings-{dow}.log")
192
+
193
+ def test_emits_line_with_run_hook_schema(self):
194
+ with patch.dict(os.environ, {"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir}):
195
+ recall._emit_hook_timing_log(142, 0)
196
+ path = self._logfile()
197
+ self.assertTrue(os.path.isfile(path))
198
+ with open(path, encoding="utf-8") as f:
199
+ lines = f.read().splitlines()
200
+ self.assertEqual(len(lines), 1)
201
+ row = json.loads(lines[0])
202
+ # Exact key set + types run-hook.sh writes.
203
+ self.assertEqual(
204
+ set(row.keys()), {"ts", "date", "source", "code", "duration_ms", "status"}
205
+ )
206
+ self.assertEqual(row["source"], "hook:hindsight-recall")
207
+ self.assertEqual(row["code"], "recall.py")
208
+ self.assertEqual(row["duration_ms"], 142)
209
+ self.assertEqual(row["status"], 0)
210
+ self.assertEqual(row["date"], time.strftime("%Y-%m-%d", time.localtime()))
211
+
212
+ def test_falls_back_to_telegram_state_dir(self):
213
+ with patch.dict(
214
+ os.environ,
215
+ {"TELEGRAM_STATE_DIR": self._tmpdir},
216
+ clear=False,
217
+ ), patch.dict(os.environ, {}, clear=False):
218
+ os.environ.pop("SWITCHROOM_HOOK_TIMING_DIR", None)
219
+ recall._emit_hook_timing_log(5, 0)
220
+ self.assertTrue(os.path.isfile(self._logfile()))
221
+
222
+ def test_disabled_by_env(self):
223
+ with patch.dict(
224
+ os.environ,
225
+ {
226
+ "SWITCHROOM_HOOK_TIMING": "0",
227
+ "SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir,
228
+ },
229
+ ):
230
+ recall._emit_hook_timing_log(999, 0)
231
+ self.assertFalse(os.path.isfile(self._logfile()))
232
+
233
+ def test_min_ms_filters_fast_invocations(self):
234
+ with patch.dict(
235
+ os.environ,
236
+ {
237
+ "SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir,
238
+ "SWITCHROOM_HOOK_TIMING_MIN_MS": "100",
239
+ },
240
+ ):
241
+ recall._emit_hook_timing_log(50, 0) # below floor → dropped
242
+ recall._emit_hook_timing_log(150, 0) # at/above → kept
243
+ with open(self._logfile(), encoding="utf-8") as f:
244
+ rows = [json.loads(x) for x in f if x.strip()]
245
+ self.assertEqual([r["duration_ms"] for r in rows], [150])
246
+
247
+ def test_no_dir_is_silent_noop(self):
248
+ # No timing dir and no TELEGRAM_STATE_DIR → nothing written, no raise.
249
+ with patch.dict(os.environ, {}, clear=False):
250
+ os.environ.pop("SWITCHROOM_HOOK_TIMING_DIR", None)
251
+ os.environ.pop("TELEGRAM_STATE_DIR", None)
252
+ recall._emit_hook_timing_log(10, 0) # must not raise
253
+ self.assertFalse(os.path.isfile(self._logfile()))
254
+
255
+ def test_stale_weekday_file_is_reset(self):
256
+ path = self._logfile()
257
+ # Seed the ring file with a line dated a week ago (different date, same
258
+ # weekday) — the emitter must truncate it before appending today's row.
259
+ stale = (
260
+ '{"ts":"2000-01-01T00:00:00+0000","date":"2000-01-01",'
261
+ '"source":"hook:hindsight-recall","code":"recall.py",'
262
+ '"duration_ms":1,"status":0}\n'
263
+ )
264
+ with open(path, "w", encoding="utf-8") as f:
265
+ f.write(stale)
266
+ with patch.dict(os.environ, {"SWITCHROOM_HOOK_TIMING_DIR": self._tmpdir}):
267
+ recall._emit_hook_timing_log(7, 0)
268
+ with open(path, encoding="utf-8") as f:
269
+ rows = [json.loads(x) for x in f if x.strip()]
270
+ # Stale line gone; exactly today's row remains.
271
+ self.assertEqual(len(rows), 1)
272
+ self.assertEqual(rows[0]["duration_ms"], 7)
273
+ self.assertEqual(rows[0]["date"], time.strftime("%Y-%m-%d", time.localtime()))
274
+
275
+
276
+ if __name__ == "__main__":
277
+ unittest.main()