switchroom 0.13.52 → 0.13.54

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 (39) hide show
  1. package/dist/agent-scheduler/index.js +399 -213
  2. package/dist/auth-broker/index.js +576 -237
  3. package/dist/cli/drive-write-pretool.mjs +28 -13
  4. package/dist/cli/ms-365-write-pretool.mjs +259 -0
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +3241 -1382
  7. package/dist/host-control/main.js +396 -276
  8. package/dist/vault/approvals/kernel-server.js +8266 -8142
  9. package/dist/vault/broker/server.js +2894 -2770
  10. package/package.json +1 -1
  11. package/profiles/_base/start.sh.hbs +17 -0
  12. package/profiles/_shared/telegram-style.md.hbs +2 -0
  13. package/skills/switchroom-status/SKILL.md +8 -6
  14. package/telegram-plugin/chat-lock.ts +87 -19
  15. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  16. package/telegram-plugin/dist/gateway/gateway.js +1283 -343
  17. package/telegram-plugin/dist/server.js +160 -160
  18. package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
  19. package/telegram-plugin/gateway/gateway.ts +485 -72
  20. package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
  21. package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
  22. package/telegram-plugin/gateway/ipc-server.ts +59 -0
  23. package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
  24. package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
  25. package/telegram-plugin/stream-reply-handler.ts +10 -8
  26. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
  27. package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
  28. package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
  29. package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
  30. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
  31. package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
  32. package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
  33. package/telegram-plugin/typing-wrap.ts +43 -21
  34. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +164 -4
  36. package/vendor/hindsight-memory/scripts/retain.py +52 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
  39. package/profiles/default/CLAUDE.md +0 -122
@@ -1,26 +1,43 @@
1
1
  // Auto-wrap tool dispatch with a Telegram typing-indicator loop so the user
2
2
  // sees a live "agent is working" signal during the 3–30s gap where the
3
3
  // progress card is deliberately suppressed (its initialDelayMs is 3s).
4
- // The first tool call on a given chat fires the typing loop immediately so
5
- // there's no silent dead window before the progress card appears. Subsequent
6
- // calls on the same chat honour the debounce to avoid churn.
7
- // Surface tools own their own loop — see isSurfaceTool.
4
+ // The first tool call on a given (chat, thread) fires the typing loop
5
+ // immediately so there's no silent dead window before the progress card
6
+ // appears. Subsequent calls on the same lane honour the debounce to avoid
7
+ // churn. Surface tools own their own loop — see isSurfaceTool.
8
+ //
9
+ // Keying changed from `chatId` to `(chatId, threadId)` in PR3 of the
10
+ // supergroup-mode rollout. In supergroup mode one agent owns many topics
11
+ // in one chat; chatId-only keying made topic A's typing indicator die when
12
+ // topic B's tool-call ended (last-stop-wins on a shared key). Per-thread
13
+ // keying preserves independent typing loops across topics — matches the
14
+ // per-(chat,thread) state model the rest of the gateway already uses.
15
+ // Callers that don't yet carry a thread context pass `undefined` and
16
+ // behave exactly as before (null thread collapses to `_` per chatKey()).
17
+
18
+ import { chatKey } from './gateway/chat-key.js'
8
19
 
9
20
  export interface TypingWrapperDeps {
10
- startTypingLoop: (chatId: string) => void
11
- stopTypingLoop: (chatId: string) => void
21
+ startTypingLoop: (chatId: string, threadId?: number | null) => void
22
+ stopTypingLoop: (chatId: string, threadId?: number | null) => void
12
23
  isSurfaceTool: (toolName: string) => boolean
13
24
  debounceMs?: number
14
25
  }
15
26
 
16
27
  export interface TypingWrapper {
17
- onToolUse: (toolUseId: string, chatId: string, toolName: string) => void
28
+ onToolUse: (
29
+ toolUseId: string,
30
+ chatId: string,
31
+ toolName: string,
32
+ threadId?: number | null,
33
+ ) => void
18
34
  onToolResult: (toolUseId: string) => void
19
35
  drainAll: () => void
20
36
  }
21
37
 
22
38
  interface Entry {
23
39
  chatId: string
40
+ threadId: number | null
24
41
  timer: ReturnType<typeof setTimeout>
25
42
  started: boolean
26
43
  }
@@ -28,29 +45,33 @@ interface Entry {
28
45
  export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
29
46
  const debounceMs = deps.debounceMs ?? 500
30
47
  const pending = new Map<string, Entry>()
31
- // Track chats that already have an active typing loop so the first
32
- // tool call fires immediately while subsequent calls use the debounce.
33
- const activeChats = new Set<string>()
48
+ // Track per-(chat,thread) lanes that already have an active typing loop
49
+ // so the first tool call on a lane fires immediately while subsequent
50
+ // calls on the same lane use the debounce.
51
+ const activeLanes = new Set<string>()
34
52
 
35
53
  return {
36
- onToolUse(toolUseId, chatId, toolName) {
54
+ onToolUse(toolUseId, chatId, toolName, threadId) {
37
55
  if (!toolUseId) return
38
56
  if (deps.isSurfaceTool(toolName)) return
57
+ const tid = threadId ?? null
58
+ const lane = chatKey(chatId, tid) as string
39
59
  // Replace any pre-existing entry for the same id defensively.
40
60
  const prior = pending.get(toolUseId)
41
61
  if (prior) {
42
62
  clearTimeout(prior.timer)
43
- if (prior.started) deps.stopTypingLoop(prior.chatId)
63
+ if (prior.started) deps.stopTypingLoop(prior.chatId, prior.threadId)
44
64
  pending.delete(toolUseId)
45
65
  }
46
- // First tool on this chat: fire immediately rather than waiting for
66
+ // First tool on this lane: fire immediately rather than waiting for
47
67
  // the debounce — this closes the silent dead window before the first
48
68
  // progress card appears.
49
- if (!activeChats.has(chatId)) {
50
- deps.startTypingLoop(chatId)
51
- activeChats.add(chatId)
69
+ if (!activeLanes.has(lane)) {
70
+ deps.startTypingLoop(chatId, tid)
71
+ activeLanes.add(lane)
52
72
  const entry: Entry = {
53
73
  chatId,
74
+ threadId: tid,
54
75
  started: true,
55
76
  timer: setTimeout(() => {}, 0), // no-op sentinel
56
77
  }
@@ -59,9 +80,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
59
80
  }
60
81
  const entry: Entry = {
61
82
  chatId,
83
+ threadId: tid,
62
84
  started: false,
63
85
  timer: setTimeout(() => {
64
- deps.startTypingLoop(chatId)
86
+ deps.startTypingLoop(chatId, tid)
65
87
  entry.started = true
66
88
  }, debounceMs),
67
89
  }
@@ -74,8 +96,8 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
74
96
  if (!entry) return
75
97
  clearTimeout(entry.timer)
76
98
  if (entry.started) {
77
- deps.stopTypingLoop(entry.chatId)
78
- activeChats.delete(entry.chatId)
99
+ deps.stopTypingLoop(entry.chatId, entry.threadId)
100
+ activeLanes.delete(chatKey(entry.chatId, entry.threadId) as string)
79
101
  }
80
102
  pending.delete(toolUseId)
81
103
  },
@@ -83,10 +105,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
83
105
  drainAll() {
84
106
  for (const entry of pending.values()) {
85
107
  clearTimeout(entry.timer)
86
- if (entry.started) deps.stopTypingLoop(entry.chatId)
108
+ if (entry.started) deps.stopTypingLoop(entry.chatId, entry.threadId)
87
109
  }
88
110
  pending.clear()
89
- activeChats.clear()
111
+ activeLanes.clear()
90
112
  },
91
113
  }
92
114
  }
@@ -54,6 +54,41 @@ def extract_chat_id_from_prompt(prompt: str) -> Optional[str]:
54
54
  return chat_id or None
55
55
 
56
56
 
57
+ # Switchroom PR6a — extract topic context (chat_id + message_thread_id)
58
+ # from the `<channel ...>` envelope. message_thread_id is present only
59
+ # when the inbound came from a forum topic in a supergroup; for DMs and
60
+ # fleet-shared groups it's absent. Topic alias resolution is the
61
+ # caller's responsibility (env-injected JSON map of thread_id → alias).
62
+ _THREAD_ID_RE = re.compile(
63
+ r"<channel\b[^>]*\bmessage_thread_id=[\"']([^\"']+)[\"']",
64
+ re.IGNORECASE,
65
+ )
66
+
67
+
68
+ def extract_topic_from_prompt(
69
+ prompt: str,
70
+ ) -> tuple[Optional[str], Optional[str]]:
71
+ """Pull (chat_id, message_thread_id) out of the channel envelope.
72
+
73
+ Returns ``(None, None)`` when the prompt isn't channel-wrapped.
74
+ Returns ``(chat_id, None)`` for DMs / non-forum chats where
75
+ `message_thread_id` is absent.
76
+
77
+ Both values are strings (mirroring the wire format — Telegram
78
+ thread_ids are numeric but we keep them as strings for cache-key
79
+ stability and config-map lookups).
80
+ """
81
+ chat_id = extract_chat_id_from_prompt(prompt)
82
+ if chat_id is None:
83
+ return None, None
84
+ head = prompt[:1024] if isinstance(prompt, str) else ""
85
+ tmatch = _THREAD_ID_RE.search(head)
86
+ thread_id = tmatch.group(1).strip() if tmatch else None
87
+ if thread_id == "":
88
+ thread_id = None
89
+ return chat_id, thread_id
90
+
91
+
57
92
  def gateway_socket_path() -> Optional[str]:
58
93
  """Resolve the gateway socket path for the current agent.
59
94
 
@@ -58,7 +58,7 @@ from lib.content import (
58
58
  )
59
59
  from lib.daemon import get_api_url
60
60
  from lib.directives import fetch_active_directives, format_active_directives_block
61
- from lib.gateway_ipc import extract_chat_id_from_prompt, update_placeholder
61
+ from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, update_placeholder
62
62
  from lib.state import read_state, write_state
63
63
 
64
64
  LAST_RECALL_STATE = "last_recall.json"
@@ -99,6 +99,70 @@ DEMOTE_TAG_VARIANTS = (
99
99
  "no-recall",
100
100
  )
101
101
 
102
+ # Switchroom PR6 — supergroup-mode topic filter mode.
103
+ #
104
+ # Controls how memories from OTHER topics are surfaced to the model
105
+ # during recall. Default is "soft-preamble": all topic-tagged memories
106
+ # are returned (the model decides relevance via the preamble that names
107
+ # the active topic). "hard-filter" drops any memory whose stored
108
+ # `metadata.thread_id` doesn't match the active prompt's thread_id —
109
+ # the escape hatch if instrumentation shows binding failures (model
110
+ # applying the right memory to the wrong topic).
111
+ #
112
+ # The mode is process-wide via env var. Memories with no thread_id
113
+ # tag (legacy retains pre-PR6, or fleet-shared/DM agents) are NEVER
114
+ # dropped — they pass through both modes regardless of active topic.
115
+ TOPIC_FILTER_MODE_ENV = "HINDSIGHT_TOPIC_FILTER_MODE"
116
+ TOPIC_FILTER_MODES = ("soft-preamble", "hard-filter")
117
+
118
+
119
+ def _topic_filter_mode() -> str:
120
+ raw = os.environ.get(TOPIC_FILTER_MODE_ENV, "").strip().lower()
121
+ if raw in TOPIC_FILTER_MODES:
122
+ return raw
123
+ return "soft-preamble"
124
+
125
+
126
+ def _filter_by_active_topic(results: list, active_thread_id: str | None) -> tuple[list, int]:
127
+ """When hard-filter mode is on AND we know the active thread, drop
128
+ any memory whose stored metadata.thread_id is set to a different
129
+ value. Untagged memories pass through unconditionally.
130
+
131
+ Returns (filtered_results, dropped_count).
132
+ """
133
+ if active_thread_id is None:
134
+ return results, 0
135
+ kept: list = []
136
+ dropped = 0
137
+ for m in results:
138
+ meta = m.get("metadata") if isinstance(m, dict) else None
139
+ if not isinstance(meta, dict):
140
+ kept.append(m)
141
+ continue
142
+ source_thread = meta.get("thread_id")
143
+ if source_thread is None or str(source_thread) == str(active_thread_id):
144
+ kept.append(m)
145
+ else:
146
+ dropped += 1
147
+ return kept, dropped
148
+
149
+
150
+ def _summarise_source_topics(results: list) -> dict:
151
+ """Build a {thread_id: count} summary of recalled memories'
152
+ source topics. Used for instrumented binding-failure analysis
153
+ in the recall log.
154
+ """
155
+ summary: dict = {}
156
+ for m in results:
157
+ meta = m.get("metadata") if isinstance(m, dict) else None
158
+ if not isinstance(meta, dict):
159
+ summary["__untagged__"] = summary.get("__untagged__", 0) + 1
160
+ continue
161
+ tid = meta.get("thread_id")
162
+ key = str(tid) if tid is not None else "__no_thread__"
163
+ summary[key] = summary.get(key, 0) + 1
164
+ return summary
165
+
102
166
  # Switchroom #432 phase 4.3 — recall telemetry log.
103
167
  #
104
168
  # Every recall (cache hit or miss) appends a JSONL record to
@@ -123,15 +187,29 @@ def _cache_ttl_secs() -> int:
123
187
  return 0
124
188
 
125
189
 
126
- def _cache_key(session_id: str, prompt: str, bank_id: str, extra_banks: list) -> str:
190
+ def _cache_key(
191
+ session_id: str,
192
+ prompt: str,
193
+ bank_id: str,
194
+ extra_banks: list,
195
+ active_thread_id: str | None = None,
196
+ ) -> str:
127
197
  """Stable hash for cache keying. Session_id is included so a new
128
198
  session always misses, regardless of the TTL setting. Extra banks
129
- are sorted so list-order doesn't change the key."""
199
+ are sorted so list-order doesn't change the key.
200
+
201
+ PR6a: `active_thread_id` is included so cross-topic prompts in
202
+ supergroup mode (same session, same model, same prompt verbatim
203
+ but different topic) don't collide on the cache. Empty/None
204
+ collapses to the empty string — backward-compatible for
205
+ fleet-shared / DM agents where no thread_id is present.
206
+ """
130
207
  parts = [
131
208
  session_id or "",
132
209
  prompt or "",
133
210
  bank_id or "",
134
211
  ",".join(sorted(extra_banks or [])),
212
+ active_thread_id or "",
135
213
  ]
136
214
  payload = "\x1f".join(parts)
137
215
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -458,6 +536,25 @@ def main():
458
536
  if placeholder_chat_id:
459
537
  update_placeholder(placeholder_chat_id, "📚 recalling memories")
460
538
 
539
+ # PR6a — supergroup-mode topic context for the current turn.
540
+ # active_thread_id is the message_thread_id from the inbound
541
+ # envelope, used to (a) key the cache so cross-topic prompts
542
+ # don't collide, (b) optionally hard-filter memories by source
543
+ # topic, and (c) log source-vs-active distribution for
544
+ # binding-failure instrumentation.
545
+ active_chat_id, active_thread_id = extract_topic_from_prompt(prompt)
546
+ active_topic_alias = None
547
+ if active_thread_id is not None:
548
+ aliases_json = os.environ.get("HINDSIGHT_TOPIC_ALIASES_JSON", "")
549
+ if aliases_json:
550
+ try:
551
+ aliases = json.loads(aliases_json)
552
+ if isinstance(aliases, dict):
553
+ inverse = {str(v): k for k, v in aliases.items()}
554
+ active_topic_alias = inverse.get(str(active_thread_id))
555
+ except (json.JSONDecodeError, ValueError, TypeError):
556
+ pass
557
+
461
558
  # Resolve API URL (handles all three connection modes)
462
559
  def _dbg(*a):
463
560
  debug_log(config, *a)
@@ -483,7 +580,7 @@ def main():
483
580
  # Whole-session-scoped, opt-in via HINDSIGHT_RECALL_CACHE_TTL_SECS.
484
581
  cache_ttl = _cache_ttl_secs()
485
582
  cache_key = (
486
- _cache_key(session_id, prompt, bank_id, additional_banks)
583
+ _cache_key(session_id, prompt, bank_id, additional_banks, active_thread_id)
487
584
  if cache_ttl > 0
488
585
  else ""
489
586
  )
@@ -507,6 +604,13 @@ def main():
507
604
  "demoted_count": 0,
508
605
  "capped": False,
509
606
  "cache_hit": True,
607
+ # PR6 — record the active topic on cache hits too so the
608
+ # log is uniformly queryable (cache_key now includes
609
+ # active_thread_id, so a hit means the prior recall was
610
+ # for the same topic — no source_topics inferable here).
611
+ "active_thread_id": active_thread_id,
612
+ "active_topic_alias": active_topic_alias,
613
+ "topic_filter_mode": _topic_filter_mode(),
510
614
  })
511
615
  return
512
616
  debug_log(config, f"Recall cache MISS (key={cache_key[:12]}…)")
@@ -612,6 +716,28 @@ def main():
612
716
  if demoted_count > 0:
613
717
  debug_log(config, f"Filtered {demoted_count} demote-from-recall memories")
614
718
 
719
+ # PR6 — capture source-topic distribution BEFORE optional
720
+ # hard-filter so we can log the would-have-leaked count for
721
+ # binding-failure analysis. Computed unconditionally so the
722
+ # log row carries this for soft-preamble mode too (the
723
+ # whole point is to instrument binding rate over time).
724
+ source_topic_summary = _summarise_source_topics(results)
725
+
726
+ # PR6b — optional hard topic filter. Default soft-preamble (no-op);
727
+ # operator flips HINDSIGHT_TOPIC_FILTER_MODE=hard-filter when
728
+ # binding failures are observed. See _filter_by_active_topic and
729
+ # the TOPIC_FILTER_MODE_ENV comment block above for design notes.
730
+ topic_filter_mode = _topic_filter_mode()
731
+ topic_dropped = 0
732
+ if topic_filter_mode == "hard-filter":
733
+ results, topic_dropped = _filter_by_active_topic(results, active_thread_id)
734
+ if topic_dropped > 0:
735
+ debug_log(
736
+ config,
737
+ f"Topic hard-filter dropped {topic_dropped} cross-topic "
738
+ f"memories (active_thread_id={active_thread_id})",
739
+ )
740
+
615
741
  # Switchroom #475 — lexical-overlap relevance gate. Drops memories
616
742
  # whose Jaccard overlap with the query is below
617
743
  # `recallMinOverlap` (default 0.0 = disabled). Runs after the
@@ -660,9 +786,29 @@ def main():
660
786
  memories_formatted = format_memories(results)
661
787
  preamble = config.get("recallPromptPreamble", "")
662
788
  current_time = format_current_time()
789
+
790
+ # PR6 — supergroup-mode topic preamble (neutral tone per
791
+ # 2026-05-27 product decision). Only added when we know the
792
+ # active topic AND any of the recalled memories carries a
793
+ # thread_id tag — i.e. we have something for the model to
794
+ # be "topic-aware" about. Fleet-shared / DM agents never
795
+ # see this line.
796
+ topic_line = ""
797
+ if active_thread_id is not None and any(
798
+ isinstance(m.get("metadata"), dict)
799
+ and m["metadata"].get("thread_id") is not None
800
+ for m in results
801
+ ):
802
+ topic_label = active_topic_alias or f"thread {active_thread_id}"
803
+ topic_line = (
804
+ f"Current topic: {topic_label}. Recalled memories are "
805
+ f"tagged with their source topic.\n"
806
+ )
807
+
663
808
  memories_block = (
664
809
  f"<hindsight_memories>\n"
665
810
  f"{preamble}\n"
811
+ f"{topic_line}"
666
812
  f"Current time - {current_time}\n\n"
667
813
  f"{memories_formatted}\n"
668
814
  f"</hindsight_memories>"
@@ -732,6 +878,20 @@ def main():
732
878
  if isinstance(m, dict) and m.get("id")
733
879
  ],
734
880
  "cache_hit": False,
881
+ # PR6 — instrumentation for binding-failure analysis.
882
+ # `active_thread_id`: the current prompt's topic (null on
883
+ # DM / fleet-shared). `source_topics`: distribution of
884
+ # source thread_ids in the recall set (before optional
885
+ # hard-filter). `topic_filter_mode`: "soft-preamble" or
886
+ # "hard-filter". `topic_dropped`: count dropped by hard
887
+ # filter. From these fields we can derive the cross-topic
888
+ # recall rate over time and decide whether to flip to
889
+ # hard-filter mode based on real data.
890
+ "active_thread_id": active_thread_id,
891
+ "active_topic_alias": active_topic_alias,
892
+ "source_topics": source_topic_summary,
893
+ "topic_filter_mode": topic_filter_mode,
894
+ "topic_dropped": topic_dropped,
735
895
  })
736
896
 
737
897
  # Output JSON for Claude Code hook system
@@ -225,6 +225,58 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
225
225
  for k, v in config.get("retainMetadata", {}).items():
226
226
  metadata[k] = _resolve_template(str(v))
227
227
 
228
+ # Switchroom PR6a — topic tagging for supergroup-mode agents.
229
+ # Scan the messages we're retaining for the latest `<channel
230
+ # chat_id=... message_thread_id=...>` envelope and stamp the
231
+ # tuple into metadata. Downstream (recall.py) uses this to log
232
+ # active-vs-source topic for binding-failure analysis and to
233
+ # support hard-filter mode when an operator opts in.
234
+ #
235
+ # No-op for fleet-shared / DM topology where every inbound from
236
+ # this agent carries the same chat_id (or no chat envelope at all
237
+ # for interactive / cron-only sessions) — the metadata is added
238
+ # but doesn't change behaviour.
239
+ try:
240
+ from lib.gateway_ipc import extract_topic_from_prompt
241
+ topic_chat_id = None
242
+ topic_thread_id = None
243
+ # Walk in reverse — most recent user message is the authoritative
244
+ # "active topic" at retain time.
245
+ for m in reversed(messages_to_retain):
246
+ if not isinstance(m, dict) or m.get("role") != "user":
247
+ continue
248
+ content = m.get("content")
249
+ text = content if isinstance(content, str) else (
250
+ # Claude Code list-content shape: [{type:"text", text:"..."}, ...]
251
+ next((p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"), "")
252
+ if isinstance(content, list) else ""
253
+ )
254
+ c_id, t_id = extract_topic_from_prompt(text)
255
+ if c_id is not None:
256
+ topic_chat_id, topic_thread_id = c_id, t_id
257
+ break
258
+ if topic_chat_id is not None:
259
+ metadata["chat_id"] = topic_chat_id
260
+ if topic_thread_id is not None:
261
+ metadata["thread_id"] = topic_thread_id
262
+ # Resolve alias from operator-injected env map.
263
+ aliases_json = os.environ.get("HINDSIGHT_TOPIC_ALIASES_JSON", "")
264
+ if aliases_json:
265
+ try:
266
+ aliases = json.loads(aliases_json)
267
+ # aliases is {alias_name: thread_id_int_or_str}; build
268
+ # the inverse lookup once.
269
+ if isinstance(aliases, dict):
270
+ inverse = {str(v): k for k, v in aliases.items()}
271
+ alias = inverse.get(str(topic_thread_id))
272
+ if alias:
273
+ metadata["topic_alias"] = alias
274
+ except (json.JSONDecodeError, ValueError, TypeError):
275
+ pass # malformed env is non-fatal
276
+ except Exception as e:
277
+ # Topic tagging is best-effort — never fail a retain over it.
278
+ debug_log(config, f"Topic tagging skipped: {e}")
279
+
228
280
  debug_log(
229
281
  config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars"
230
282
  )
@@ -25,11 +25,53 @@ if SCRIPTS_DIR not in sys.path:
25
25
 
26
26
  from lib.gateway_ipc import ( # noqa: E402
27
27
  extract_chat_id_from_prompt,
28
+ extract_topic_from_prompt,
28
29
  gateway_socket_path,
29
30
  update_placeholder,
30
31
  )
31
32
 
32
33
 
34
+ class ExtractTopicTests(unittest.TestCase):
35
+ """PR6a — (chat_id, message_thread_id) extraction for supergroup mode."""
36
+
37
+ def test_dm_returns_chat_id_thread_none(self):
38
+ # DM and fleet-shared envelopes carry chat_id only.
39
+ prompt = '<channel source="switchroom-telegram" chat_id="12345">hi</channel>'
40
+ self.assertEqual(extract_topic_from_prompt(prompt), ("12345", None))
41
+
42
+ def test_supergroup_topic_returns_both(self):
43
+ prompt = (
44
+ '<channel source="switchroom-telegram" '
45
+ 'chat_id="-1001234" message_thread_id="17">hi</channel>'
46
+ )
47
+ self.assertEqual(extract_topic_from_prompt(prompt), ("-1001234", "17"))
48
+
49
+ def test_attribute_order_independent(self):
50
+ prompt = (
51
+ '<channel message_thread_id="42" chat_id="999" '
52
+ 'source="x">hi</channel>'
53
+ )
54
+ self.assertEqual(extract_topic_from_prompt(prompt), ("999", "42"))
55
+
56
+ def test_single_quoted_thread_id(self):
57
+ prompt = "<channel chat_id='1' message_thread_id='7'>hi</channel>"
58
+ self.assertEqual(extract_topic_from_prompt(prompt), ("1", "7"))
59
+
60
+ def test_no_channel_envelope_returns_none_pair(self):
61
+ self.assertEqual(extract_topic_from_prompt("plain prompt"), (None, None))
62
+
63
+ def test_empty_thread_id_collapses_to_none(self):
64
+ # Defensive against malformed envelopes that include the attribute
65
+ # but with no value.
66
+ prompt = '<channel chat_id="1" message_thread_id="">hi</channel>'
67
+ self.assertEqual(extract_topic_from_prompt(prompt), ("1", None))
68
+
69
+ def test_only_inspects_first_kb(self):
70
+ # Pad BEFORE the envelope; both chat_id AND thread_id should be lost.
71
+ prompt = ("x" * 2000) + '<channel chat_id="1" message_thread_id="7">hi</channel>'
72
+ self.assertEqual(extract_topic_from_prompt(prompt), (None, None))
73
+
74
+
33
75
  class ExtractChatIdTests(unittest.TestCase):
34
76
  def test_double_quoted_attribute(self):
35
77
  prompt = '<channel source="switchroom-telegram" chat_id="12345" thread_id="-">\nhi\n</channel>'
@@ -0,0 +1,139 @@
1
+ """PR6 — unit tests for recall.py's topic-aware helpers.
2
+
3
+ Covers:
4
+ - _filter_by_active_topic — drops memories whose metadata.thread_id
5
+ differs from the active prompt's thread_id; passes through untagged
6
+ legacy memories.
7
+ - _summarise_source_topics — distribution counts used in the recall
8
+ log for binding-failure instrumentation.
9
+ - _topic_filter_mode — env-var parsing with safe default.
10
+ - _cache_key — active_thread_id participates in hash (cross-topic
11
+ prompts in supergroup mode mustn't collide on the cache).
12
+
13
+ Stdlib-only.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import unittest
19
+
20
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
21
+ if SCRIPTS_DIR not in sys.path:
22
+ sys.path.insert(0, SCRIPTS_DIR)
23
+
24
+ from recall import ( # noqa: E402
25
+ _cache_key,
26
+ _filter_by_active_topic,
27
+ _summarise_source_topics,
28
+ _topic_filter_mode,
29
+ )
30
+
31
+
32
+ def _mem(thread_id):
33
+ """Fake memory record with the metadata shape recall sees."""
34
+ return {"id": f"m{thread_id}", "metadata": {"thread_id": thread_id}}
35
+
36
+
37
+ def _untagged_mem(suffix=""):
38
+ return {"id": f"u{suffix}", "metadata": {"retained_at": "2026-01-01"}}
39
+
40
+
41
+ class FilterByActiveTopicTests(unittest.TestCase):
42
+ def test_drops_cross_topic_when_active_set(self):
43
+ results = [_mem("17"), _mem("31"), _mem("17")]
44
+ kept, dropped = _filter_by_active_topic(results, "17")
45
+ self.assertEqual(len(kept), 2)
46
+ self.assertEqual(dropped, 1)
47
+ self.assertTrue(all(m["metadata"]["thread_id"] == "17" for m in kept))
48
+
49
+ def test_passes_through_untagged_memories(self):
50
+ # Legacy memories (pre-PR6 retain) have no thread_id — must
51
+ # never be dropped, regardless of active topic.
52
+ results = [_mem("17"), _untagged_mem(), _mem("31"), _untagged_mem("b")]
53
+ kept, dropped = _filter_by_active_topic(results, "17")
54
+ # Kept: the 17 tagged + both untagged.
55
+ self.assertEqual(len(kept), 3)
56
+ self.assertEqual(dropped, 1)
57
+
58
+ def test_no_active_thread_is_passthrough(self):
59
+ # DM / fleet-shared agents have no active_thread_id; the
60
+ # filter is a no-op regardless of mode.
61
+ results = [_mem("17"), _mem("31"), _untagged_mem()]
62
+ kept, dropped = _filter_by_active_topic(results, None)
63
+ self.assertEqual(len(kept), 3)
64
+ self.assertEqual(dropped, 0)
65
+
66
+ def test_str_int_equivalence(self):
67
+ # Metadata can carry thread_ids as either string or int
68
+ # depending on how retain serialized them. The active_thread_id
69
+ # is always string (envelope is parsed as text). Compare as
70
+ # strings.
71
+ results = [{"id": "m", "metadata": {"thread_id": 17}}] # numeric
72
+ kept, dropped = _filter_by_active_topic(results, "17")
73
+ self.assertEqual(len(kept), 1)
74
+ self.assertEqual(dropped, 0)
75
+
76
+
77
+ class SummariseSourceTopicsTests(unittest.TestCase):
78
+ def test_counts_by_thread(self):
79
+ results = [_mem("17"), _mem("17"), _mem("31")]
80
+ self.assertEqual(_summarise_source_topics(results), {"17": 2, "31": 1})
81
+
82
+ def test_untagged_bucket(self):
83
+ results = [_mem("17"), _untagged_mem(), _untagged_mem()]
84
+ summary = _summarise_source_topics(results)
85
+ self.assertEqual(summary["17"], 1)
86
+ self.assertEqual(summary["__no_thread__"], 2)
87
+
88
+ def test_missing_metadata_bucket(self):
89
+ results = [{"id": "x"}] # no metadata key at all
90
+ self.assertEqual(_summarise_source_topics(results), {"__untagged__": 1})
91
+
92
+
93
+ class TopicFilterModeTests(unittest.TestCase):
94
+ def setUp(self):
95
+ self._saved = os.environ.get("HINDSIGHT_TOPIC_FILTER_MODE")
96
+ os.environ.pop("HINDSIGHT_TOPIC_FILTER_MODE", None)
97
+
98
+ def tearDown(self):
99
+ if self._saved is None:
100
+ os.environ.pop("HINDSIGHT_TOPIC_FILTER_MODE", None)
101
+ else:
102
+ os.environ["HINDSIGHT_TOPIC_FILTER_MODE"] = self._saved
103
+
104
+ def test_default_is_soft_preamble(self):
105
+ self.assertEqual(_topic_filter_mode(), "soft-preamble")
106
+
107
+ def test_hard_filter_env(self):
108
+ os.environ["HINDSIGHT_TOPIC_FILTER_MODE"] = "hard-filter"
109
+ self.assertEqual(_topic_filter_mode(), "hard-filter")
110
+
111
+ def test_unknown_value_falls_back_to_default(self):
112
+ # Operator typos shouldn't silently enable a strict mode.
113
+ os.environ["HINDSIGHT_TOPIC_FILTER_MODE"] = "strict"
114
+ self.assertEqual(_topic_filter_mode(), "soft-preamble")
115
+
116
+ def test_case_insensitive(self):
117
+ os.environ["HINDSIGHT_TOPIC_FILTER_MODE"] = "Hard-Filter"
118
+ self.assertEqual(_topic_filter_mode(), "hard-filter")
119
+
120
+
121
+ class CacheKeyIncludesActiveTopicTests(unittest.TestCase):
122
+ def test_same_prompt_different_topic_misses(self):
123
+ k1 = _cache_key("sess", "what's up?", "bank", [], "17")
124
+ k2 = _cache_key("sess", "what's up?", "bank", [], "31")
125
+ self.assertNotEqual(k1, k2)
126
+
127
+ def test_backward_compat_no_topic(self):
128
+ # Pre-PR6 callers (none after this PR, but the param is
129
+ # optional so they couldn't break) get a stable key.
130
+ k1 = _cache_key("sess", "p", "bank", [])
131
+ k2 = _cache_key("sess", "p", "bank", [], None)
132
+ k3 = _cache_key("sess", "p", "bank", [], "")
133
+ # All three collapse to the empty-thread case → same hash.
134
+ self.assertEqual(k1, k2)
135
+ self.assertEqual(k1, k3)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ unittest.main()