switchroom 0.12.27 → 0.12.28
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/cli/switchroom.js +4 -2
- package/package.json +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +49 -5
- package/telegram-plugin/gateway/gateway.ts +5 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- package/vendor/hindsight-memory/tests/test_state.py +125 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Content processing utilities.
|
|
2
|
+
|
|
3
|
+
Faithful port of Openclaw plugin's content processing: memory tag stripping,
|
|
4
|
+
query composition/truncation, transcript formatting, and memory formatting.
|
|
5
|
+
|
|
6
|
+
Source: reference/openclaw-source/index.js — stripMemoryTags, composeRecallQuery,
|
|
7
|
+
truncateRecallQuery, sliceLastTurnsByUserBoundary, prepareRetentionTranscript,
|
|
8
|
+
formatMemories.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Memory tag stripping (anti-feedback-loop)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def strip_channel_envelope(content: str) -> str:
|
|
20
|
+
"""Strip Claude Code channel XML wrappers from user messages.
|
|
21
|
+
|
|
22
|
+
Claude Code wraps incoming channel messages in XML:
|
|
23
|
+
<channel source="plugin:telegram:telegram" chat_id="..." ...>
|
|
24
|
+
actual message text
|
|
25
|
+
</channel>
|
|
26
|
+
|
|
27
|
+
This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes().
|
|
28
|
+
Extracts the inner text, preserving the actual user message while removing
|
|
29
|
+
transport metadata that Hindsight doesn't need.
|
|
30
|
+
"""
|
|
31
|
+
# Match <channel ...>content</channel> — extract inner text
|
|
32
|
+
match = re.search(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
|
|
33
|
+
if match:
|
|
34
|
+
return match.group(1).strip()
|
|
35
|
+
return content
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_memory_tags(content: str) -> str:
|
|
39
|
+
"""Remove <hindsight_memories> and <relevant_memories> blocks.
|
|
40
|
+
|
|
41
|
+
Prevents retain feedback loop — these were injected during recall and
|
|
42
|
+
should not be re-stored.
|
|
43
|
+
|
|
44
|
+
Port of: stripMemoryTags() in index.js
|
|
45
|
+
"""
|
|
46
|
+
content = re.sub(r"<hindsight_memories>[\s\S]*?</hindsight_memories>", "", content)
|
|
47
|
+
content = re.sub(r"<relevant_memories>[\s\S]*?</relevant_memories>", "", content)
|
|
48
|
+
return content
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Recall: query composition and truncation
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compose_recall_query(
|
|
57
|
+
latest_query: str,
|
|
58
|
+
messages: list,
|
|
59
|
+
recall_context_turns: int,
|
|
60
|
+
recall_roles: list = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Compose a multi-turn recall query from conversation history.
|
|
63
|
+
|
|
64
|
+
Port of: composeRecallQuery() in index.js
|
|
65
|
+
|
|
66
|
+
When recallContextTurns > 1, includes prior context from the transcript
|
|
67
|
+
above the latest user query. Format:
|
|
68
|
+
|
|
69
|
+
Prior context:
|
|
70
|
+
|
|
71
|
+
user: ...
|
|
72
|
+
assistant: ...
|
|
73
|
+
|
|
74
|
+
<latest query>
|
|
75
|
+
"""
|
|
76
|
+
latest = latest_query.strip()
|
|
77
|
+
if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
|
|
78
|
+
return latest
|
|
79
|
+
|
|
80
|
+
allowed_roles = set(recall_roles or ["user", "assistant"])
|
|
81
|
+
contextual_messages = slice_last_turns_by_user_boundary(messages, recall_context_turns)
|
|
82
|
+
|
|
83
|
+
context_lines = []
|
|
84
|
+
for msg in contextual_messages:
|
|
85
|
+
role = msg.get("role")
|
|
86
|
+
if role not in allowed_roles:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
content = _extract_text_content(msg.get("content", ""), role=role)
|
|
90
|
+
content = strip_channel_envelope(content)
|
|
91
|
+
content = strip_memory_tags(content).strip()
|
|
92
|
+
if not content:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Skip if this is the same as the latest query (avoid duplication)
|
|
96
|
+
if role == "user" and content == latest:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
context_lines.append(f"{role}: {content}")
|
|
100
|
+
|
|
101
|
+
if not context_lines:
|
|
102
|
+
return latest
|
|
103
|
+
|
|
104
|
+
return "\n\n".join(
|
|
105
|
+
[
|
|
106
|
+
"Prior context:",
|
|
107
|
+
"\n".join(context_lines),
|
|
108
|
+
latest,
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
|
|
114
|
+
"""Truncate a composed recall query to max_chars.
|
|
115
|
+
|
|
116
|
+
Port of: truncateRecallQuery() in index.js
|
|
117
|
+
|
|
118
|
+
Preserves the latest user message. When the query contains "Prior context:",
|
|
119
|
+
drops oldest context lines first (from the top) to fit within the limit.
|
|
120
|
+
"""
|
|
121
|
+
if max_chars <= 0:
|
|
122
|
+
return query
|
|
123
|
+
|
|
124
|
+
latest = latest_query.strip()
|
|
125
|
+
if len(query) <= max_chars:
|
|
126
|
+
return query
|
|
127
|
+
|
|
128
|
+
# If even the latest alone is too long, hard-truncate it
|
|
129
|
+
latest_only = latest[:max_chars] if len(latest) > max_chars else latest
|
|
130
|
+
|
|
131
|
+
if "Prior context:" not in query:
|
|
132
|
+
return latest_only
|
|
133
|
+
|
|
134
|
+
context_marker = "Prior context:\n\n"
|
|
135
|
+
marker_index = query.find(context_marker)
|
|
136
|
+
if marker_index == -1:
|
|
137
|
+
return latest_only
|
|
138
|
+
|
|
139
|
+
suffix_marker = "\n\n" + latest
|
|
140
|
+
suffix_index = query.rfind(suffix_marker)
|
|
141
|
+
if suffix_index == -1:
|
|
142
|
+
return latest_only
|
|
143
|
+
|
|
144
|
+
suffix = query[suffix_index:] # \n\n<latest>
|
|
145
|
+
if len(suffix) >= max_chars:
|
|
146
|
+
return latest_only
|
|
147
|
+
|
|
148
|
+
context_body = query[marker_index + len(context_marker) : suffix_index]
|
|
149
|
+
context_lines = [line for line in context_body.split("\n") if line]
|
|
150
|
+
|
|
151
|
+
# Add context lines from newest (bottom) to oldest (top), stop when exceeding
|
|
152
|
+
kept = []
|
|
153
|
+
for i in range(len(context_lines) - 1, -1, -1):
|
|
154
|
+
kept.insert(0, context_lines[i])
|
|
155
|
+
candidate = f"{context_marker}{chr(10).join(kept)}{suffix}"
|
|
156
|
+
if len(candidate) > max_chars:
|
|
157
|
+
kept.pop(0)
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
if kept:
|
|
161
|
+
return f"{context_marker}{chr(10).join(kept)}{suffix}"
|
|
162
|
+
return latest_only
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Turn slicing
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
|
|
171
|
+
"""Slice messages to the last N turns, where a turn starts at a user message.
|
|
172
|
+
|
|
173
|
+
Port of: sliceLastTurnsByUserBoundary() in index.js
|
|
174
|
+
|
|
175
|
+
Walks backward counting user messages as turn boundaries. Returns
|
|
176
|
+
messages from the Nth user boundary to the end.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(messages, list) or not messages or turns <= 0:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
user_turns_seen = 0
|
|
182
|
+
start_index = -1
|
|
183
|
+
|
|
184
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
185
|
+
if messages[i].get("role") == "user":
|
|
186
|
+
user_turns_seen += 1
|
|
187
|
+
if user_turns_seen >= turns:
|
|
188
|
+
start_index = i
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if start_index == -1:
|
|
192
|
+
return list(messages)
|
|
193
|
+
|
|
194
|
+
return messages[start_index:]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Memory formatting (recall results → context string)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_memories(results: list) -> str:
|
|
203
|
+
"""Format recall results into human-readable text.
|
|
204
|
+
|
|
205
|
+
Port of: formatMemories() in index.js
|
|
206
|
+
Format: - <text> [<type>] (<mentioned_at>)
|
|
207
|
+
"""
|
|
208
|
+
if not results:
|
|
209
|
+
return ""
|
|
210
|
+
lines = []
|
|
211
|
+
for r in results:
|
|
212
|
+
text = r.get("text", "")
|
|
213
|
+
mem_type = r.get("type", "")
|
|
214
|
+
mentioned_at = r.get("mentioned_at", "")
|
|
215
|
+
type_str = f" [{mem_type}]" if mem_type else ""
|
|
216
|
+
date_str = f" ({mentioned_at})" if mentioned_at else ""
|
|
217
|
+
lines.append(f"- {text}{type_str}{date_str}")
|
|
218
|
+
return "\n\n".join(lines)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def format_current_time() -> str:
|
|
222
|
+
"""Format current UTC time for recall context.
|
|
223
|
+
|
|
224
|
+
Port of: formatCurrentTimeForRecall() in index.js
|
|
225
|
+
"""
|
|
226
|
+
now = datetime.now(timezone.utc)
|
|
227
|
+
return now.strftime("%Y-%m-%d %H:%M")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Retention transcript formatting
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_message_blocks(content, role: str = "") -> list:
|
|
236
|
+
"""Extract structured content blocks from a message for JSON retention.
|
|
237
|
+
|
|
238
|
+
Returns a list of dicts, each representing a content block:
|
|
239
|
+
- {"type": "text", "text": "..."} for text blocks
|
|
240
|
+
- {"type": "tool_use", "name": "...", "input": {...}} for tool calls
|
|
241
|
+
- Channel message tool_use blocks get their text extracted inline.
|
|
242
|
+
"""
|
|
243
|
+
if isinstance(content, str):
|
|
244
|
+
cleaned = strip_channel_envelope(strip_memory_tags(content)).strip()
|
|
245
|
+
return [{"type": "text", "text": cleaned}] if cleaned else []
|
|
246
|
+
|
|
247
|
+
if not isinstance(content, list):
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
blocks = []
|
|
251
|
+
for block in content:
|
|
252
|
+
if not isinstance(block, dict):
|
|
253
|
+
continue
|
|
254
|
+
block_type = block.get("type", "")
|
|
255
|
+
|
|
256
|
+
if block_type == "text":
|
|
257
|
+
text = strip_channel_envelope(strip_memory_tags(block.get("text", ""))).strip()
|
|
258
|
+
if text:
|
|
259
|
+
blocks.append({"type": "text", "text": text})
|
|
260
|
+
|
|
261
|
+
elif block_type == "tool_use" and role == "assistant":
|
|
262
|
+
if _is_channel_message_tool(block):
|
|
263
|
+
# Channel messages: extract the outgoing text
|
|
264
|
+
tool_input = block.get("input", {})
|
|
265
|
+
for field in _MESSAGE_TEXT_FIELDS:
|
|
266
|
+
val = tool_input.get(field)
|
|
267
|
+
if isinstance(val, str) and val.strip():
|
|
268
|
+
blocks.append({"type": "text", "text": val.strip()})
|
|
269
|
+
break
|
|
270
|
+
else:
|
|
271
|
+
name = block.get("name", "unknown")
|
|
272
|
+
inp = block.get("input", {})
|
|
273
|
+
# Skip Hindsight MCP tools to avoid feedback loops
|
|
274
|
+
if name.startswith("mcp__") and _OPERATIONAL_TOOL_PATTERN.search(name.split("__")[-1]):
|
|
275
|
+
continue
|
|
276
|
+
blocks.append({"type": "tool_use", "name": name, "input": inp})
|
|
277
|
+
|
|
278
|
+
elif block_type == "tool_result":
|
|
279
|
+
# Include tool results for context.
|
|
280
|
+
# content can be a plain string or a list of content blocks
|
|
281
|
+
# (e.g. [{"type": "text", "text": "..."}] for Agent results).
|
|
282
|
+
result_content = block.get("content", "")
|
|
283
|
+
if isinstance(result_content, list):
|
|
284
|
+
# Extract text from content blocks
|
|
285
|
+
parts = []
|
|
286
|
+
for item in result_content:
|
|
287
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
288
|
+
t = item.get("text", "").strip()
|
|
289
|
+
if t:
|
|
290
|
+
parts.append(t)
|
|
291
|
+
result_content = "\n".join(parts)
|
|
292
|
+
if isinstance(result_content, str) and result_content.strip():
|
|
293
|
+
text = result_content.strip()
|
|
294
|
+
# Truncate very long results
|
|
295
|
+
if len(text) > 2000:
|
|
296
|
+
text = text[:2000] + "... (truncated)"
|
|
297
|
+
blocks.append({"type": "tool_result", "tool_use_id": block.get("tool_use_id", ""), "content": text})
|
|
298
|
+
|
|
299
|
+
return blocks
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def prepare_retention_transcript(
|
|
303
|
+
messages: list,
|
|
304
|
+
retain_roles: list = None,
|
|
305
|
+
retain_full_window: bool = False,
|
|
306
|
+
include_tool_calls: bool = False,
|
|
307
|
+
) -> tuple:
|
|
308
|
+
"""Format messages into a retention transcript.
|
|
309
|
+
|
|
310
|
+
When include_tool_calls is True, outputs JSON with full message structure
|
|
311
|
+
including tool calls and their inputs. Otherwise outputs the legacy
|
|
312
|
+
text format with [role: ...]...[role:end] markers.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
messages: List of message dicts with 'role' and 'content'.
|
|
316
|
+
retain_roles: Roles to include (default: ['user', 'assistant']).
|
|
317
|
+
retain_full_window: If True, retain all messages (chunked mode).
|
|
318
|
+
If False, retain only the last turn (last user msg + responses).
|
|
319
|
+
include_tool_calls: If True, output JSON format with full tool call data.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
(transcript_text, message_count) or (None, 0) if nothing to retain.
|
|
323
|
+
"""
|
|
324
|
+
if not messages:
|
|
325
|
+
return None, 0
|
|
326
|
+
|
|
327
|
+
if retain_full_window:
|
|
328
|
+
target_messages = messages
|
|
329
|
+
else:
|
|
330
|
+
# Default: retain only the last turn
|
|
331
|
+
last_user_idx = -1
|
|
332
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
333
|
+
if messages[i].get("role") == "user":
|
|
334
|
+
last_user_idx = i
|
|
335
|
+
break
|
|
336
|
+
if last_user_idx == -1:
|
|
337
|
+
return None, 0
|
|
338
|
+
target_messages = messages[last_user_idx:]
|
|
339
|
+
|
|
340
|
+
allowed_roles = set(retain_roles or ["user", "assistant"])
|
|
341
|
+
|
|
342
|
+
if include_tool_calls:
|
|
343
|
+
return _prepare_json_transcript(target_messages, allowed_roles)
|
|
344
|
+
return _prepare_text_transcript(target_messages, allowed_roles)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _prepare_json_transcript(messages: list, allowed_roles: set) -> tuple:
|
|
348
|
+
"""Format messages as JSON with full tool call data."""
|
|
349
|
+
import json
|
|
350
|
+
|
|
351
|
+
structured_messages = []
|
|
352
|
+
for msg in messages:
|
|
353
|
+
role = msg.get("role", "unknown")
|
|
354
|
+
if role not in allowed_roles:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
blocks = _extract_message_blocks(msg.get("content", ""), role=role)
|
|
358
|
+
if not blocks:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
structured_messages.append({"role": role, "content": blocks})
|
|
362
|
+
|
|
363
|
+
if not structured_messages:
|
|
364
|
+
return None, 0
|
|
365
|
+
|
|
366
|
+
transcript = json.dumps(structured_messages, indent=None, ensure_ascii=False)
|
|
367
|
+
if len(transcript.strip()) < 10:
|
|
368
|
+
return None, 0
|
|
369
|
+
|
|
370
|
+
return transcript, len(structured_messages)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _prepare_text_transcript(messages: list, allowed_roles: set) -> tuple:
|
|
374
|
+
"""Format messages as legacy text with [role:]...[role:end] markers."""
|
|
375
|
+
parts = []
|
|
376
|
+
|
|
377
|
+
for msg in messages:
|
|
378
|
+
role = msg.get("role", "unknown")
|
|
379
|
+
if role not in allowed_roles:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
content = _extract_text_content(msg.get("content", ""), role=role)
|
|
383
|
+
content = strip_channel_envelope(content)
|
|
384
|
+
content = strip_memory_tags(content).strip()
|
|
385
|
+
|
|
386
|
+
if not content:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
parts.append(f"[role: {role}]\n{content}\n[{role}:end]")
|
|
390
|
+
|
|
391
|
+
if not parts:
|
|
392
|
+
return None, 0
|
|
393
|
+
|
|
394
|
+
transcript = "\n\n".join(parts)
|
|
395
|
+
if len(transcript.strip()) < 10:
|
|
396
|
+
return None, 0
|
|
397
|
+
|
|
398
|
+
return transcript, len(parts)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Helpers
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# Fields in tool_use input that carry the outgoing message text.
|
|
406
|
+
# Ordered by likelihood — first match wins.
|
|
407
|
+
_MESSAGE_TEXT_FIELDS = ("text", "body", "message", "content")
|
|
408
|
+
|
|
409
|
+
# MCP tool name suffixes that are operational, not conversational.
|
|
410
|
+
# Checked against the last segment of the tool name (after the last __).
|
|
411
|
+
import re as _re
|
|
412
|
+
|
|
413
|
+
_OPERATIONAL_TOOL_PATTERN = _re.compile(
|
|
414
|
+
r"(?:recall|retain|reflect|search|extract|create_|delete_|update_|get_|list_)",
|
|
415
|
+
_re.IGNORECASE,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _is_channel_message_tool(block: dict) -> bool:
|
|
420
|
+
"""Detect if a tool_use block is a channel message (reply/send).
|
|
421
|
+
|
|
422
|
+
Uses a structural approach rather than name-matching for robustness:
|
|
423
|
+
1. Must be an MCP tool (name starts with "mcp__")
|
|
424
|
+
2. Must NOT match known operational patterns (recall, search, CRUD)
|
|
425
|
+
3. Must have a text-like field in input (text, body, message, content)
|
|
426
|
+
|
|
427
|
+
This catches any channel plugin (Telegram, Slack, Discord, Matrix,
|
|
428
|
+
future channels) without hardcoding tool names. Built-in tools (Bash,
|
|
429
|
+
Read, Write) don't start with mcp__. MCP tools for non-messaging
|
|
430
|
+
purposes (hindsight recall, search) are excluded by pattern and by
|
|
431
|
+
lacking text/body fields.
|
|
432
|
+
"""
|
|
433
|
+
name = block.get("name", "")
|
|
434
|
+
if not name.startswith("mcp__"):
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
# Exclude operational MCP tools (check only the tool suffix, not server name)
|
|
438
|
+
tool_suffix = name.split("__")[-1]
|
|
439
|
+
if _OPERATIONAL_TOOL_PATTERN.search(tool_suffix):
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
tool_input = block.get("input", {})
|
|
443
|
+
if not isinstance(tool_input, dict):
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
# Must have a text-carrying field with actual content
|
|
447
|
+
return any(isinstance(tool_input.get(f), str) and tool_input[f].strip() for f in _MESSAGE_TEXT_FIELDS)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _extract_text_content(content, role: str = "") -> str:
|
|
451
|
+
"""Extract text from message content (string or content blocks array).
|
|
452
|
+
|
|
453
|
+
For user messages: extracts from plain strings (channel XML wrappers
|
|
454
|
+
are stripped separately by strip_channel_envelope).
|
|
455
|
+
|
|
456
|
+
For assistant messages: extracts from:
|
|
457
|
+
- {type: "text"} blocks — terminal output/narration
|
|
458
|
+
- {type: "tool_use"} blocks detected as channel messages — the agent's
|
|
459
|
+
actual responses to the user. Detection is structural (MCP tool with
|
|
460
|
+
text-like input field), not name-based, for channel-agnosticism.
|
|
461
|
+
|
|
462
|
+
Excludes:
|
|
463
|
+
- {type: "thinking"} — internal reasoning
|
|
464
|
+
- {type: "tool_use"} for operational tools — Bash, Read, Write, recall, etc.
|
|
465
|
+
- {type: "tool_result"} — operational results, not conversation
|
|
466
|
+
"""
|
|
467
|
+
if isinstance(content, str):
|
|
468
|
+
return content
|
|
469
|
+
if isinstance(content, list):
|
|
470
|
+
texts = []
|
|
471
|
+
for block in content:
|
|
472
|
+
if not isinstance(block, dict):
|
|
473
|
+
continue
|
|
474
|
+
block_type = block.get("type", "")
|
|
475
|
+
|
|
476
|
+
# Text blocks: terminal output / narration
|
|
477
|
+
if block_type == "text":
|
|
478
|
+
text = block.get("text", "").strip()
|
|
479
|
+
if text:
|
|
480
|
+
texts.append(text)
|
|
481
|
+
|
|
482
|
+
# Tool use blocks: extract channel messages
|
|
483
|
+
elif block_type == "tool_use" and role == "assistant":
|
|
484
|
+
if _is_channel_message_tool(block):
|
|
485
|
+
tool_input = block.get("input", {})
|
|
486
|
+
for field in _MESSAGE_TEXT_FIELDS:
|
|
487
|
+
val = tool_input.get(field)
|
|
488
|
+
if isinstance(val, str) and val.strip():
|
|
489
|
+
texts.append(val.strip())
|
|
490
|
+
break
|
|
491
|
+
|
|
492
|
+
return "\n".join(texts)
|
|
493
|
+
return ""
|