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.
- package/dist/agent-scheduler/index.js +399 -213
- package/dist/auth-broker/index.js +576 -237
- package/dist/cli/drive-write-pretool.mjs +28 -13
- package/dist/cli/ms-365-write-pretool.mjs +259 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3241 -1382
- package/dist/host-control/main.js +396 -276
- package/dist/vault/approvals/kernel-server.js +8266 -8142
- package/dist/vault/broker/server.js +2894 -2770
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -0
- package/profiles/_shared/telegram-style.md.hbs +2 -0
- package/skills/switchroom-status/SKILL.md +8 -6
- package/telegram-plugin/chat-lock.ts +87 -19
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1283 -343
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +485 -72
- package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
- package/telegram-plugin/gateway/ipc-server.ts +59 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
- package/telegram-plugin/stream-reply-handler.ts +10 -8
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
- package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
- package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
- package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
- package/telegram-plugin/typing-wrap.ts +43 -21
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
- package/vendor/hindsight-memory/scripts/recall.py +164 -4
- package/vendor/hindsight-memory/scripts/retain.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
- 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
|
|
5
|
-
// there's no silent dead window before the progress card
|
|
6
|
-
// calls on the same
|
|
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: (
|
|
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
|
|
32
|
-
// tool call fires immediately while subsequent
|
|
33
|
-
|
|
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
|
|
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 (!
|
|
50
|
-
deps.startTypingLoop(chatId)
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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()
|