switchroom 0.16.46 → 0.17.0
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 +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -230,3 +230,133 @@ class TestHindsightClientSetBankMission:
|
|
|
230
230
|
assert "my-bank" in captured["url"]
|
|
231
231
|
assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
|
|
232
232
|
assert captured["body"]["updates"]["retain_mission"] == "Extract facts"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestHindsightClientRecallTagFilters:
|
|
236
|
+
"""Upstream 962140eef — tag filters are forwarded in the recall body."""
|
|
237
|
+
|
|
238
|
+
def test_sends_tag_filters(self):
|
|
239
|
+
c = HindsightClient("http://localhost:9077")
|
|
240
|
+
captured = {}
|
|
241
|
+
|
|
242
|
+
def fake_open(req, timeout=None):
|
|
243
|
+
captured["body"] = json.loads(req.data.decode())
|
|
244
|
+
return FakeResp({"results": []})
|
|
245
|
+
|
|
246
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
247
|
+
c.recall(
|
|
248
|
+
"bank",
|
|
249
|
+
"query",
|
|
250
|
+
tags=["memory_type:rule"],
|
|
251
|
+
tags_match="any_strict",
|
|
252
|
+
tag_groups=[{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
assert captured["body"]["tags"] == ["memory_type:rule"]
|
|
256
|
+
assert captured["body"]["tags_match"] == "any_strict"
|
|
257
|
+
assert captured["body"]["tag_groups"] == [
|
|
258
|
+
{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
def test_omits_tag_filters_when_unset(self):
|
|
262
|
+
c = HindsightClient("http://localhost:9077")
|
|
263
|
+
captured = {}
|
|
264
|
+
|
|
265
|
+
def fake_open(req, timeout=None):
|
|
266
|
+
captured["body"] = json.loads(req.data.decode())
|
|
267
|
+
return FakeResp({"results": []})
|
|
268
|
+
|
|
269
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
270
|
+
c.recall("bank", "query")
|
|
271
|
+
|
|
272
|
+
assert "tags" not in captured["body"]
|
|
273
|
+
assert "tags_match" not in captured["body"]
|
|
274
|
+
assert "tag_groups" not in captured["body"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TestRequestTimeoutOverride:
|
|
278
|
+
"""Upstream 55ef70679 — the constructor override replaces the per-call
|
|
279
|
+
timeout that recall/retain/_request would otherwise use. When unset,
|
|
280
|
+
the original per-call default is preserved."""
|
|
281
|
+
|
|
282
|
+
def test_override_replaces_recall_default(self):
|
|
283
|
+
c = HindsightClient("http://localhost:9077", request_timeout_override=60)
|
|
284
|
+
captured = {}
|
|
285
|
+
|
|
286
|
+
def fake_open(req, timeout=None):
|
|
287
|
+
captured["timeout"] = timeout
|
|
288
|
+
return FakeResp({"results": []})
|
|
289
|
+
|
|
290
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
291
|
+
c.recall("bank", "query")
|
|
292
|
+
|
|
293
|
+
assert captured["timeout"] == 60
|
|
294
|
+
|
|
295
|
+
def test_override_replaces_retain_default(self):
|
|
296
|
+
c = HindsightClient("http://localhost:9077", request_timeout_override=60)
|
|
297
|
+
captured = {}
|
|
298
|
+
|
|
299
|
+
def fake_open(req, timeout=None):
|
|
300
|
+
captured["timeout"] = timeout
|
|
301
|
+
return FakeResp({})
|
|
302
|
+
|
|
303
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
304
|
+
c.retain("bank", "content")
|
|
305
|
+
|
|
306
|
+
assert captured["timeout"] == 60
|
|
307
|
+
|
|
308
|
+
def test_override_replaces_explicit_recall_timeout(self):
|
|
309
|
+
# Even an explicit per-call timeout (like recall.py's 8s hook
|
|
310
|
+
# budget) is replaced when the override is set — which is exactly
|
|
311
|
+
# why recall.py does NOT construct its client with the override.
|
|
312
|
+
c = HindsightClient("http://localhost:9077", request_timeout_override=60)
|
|
313
|
+
captured = {}
|
|
314
|
+
|
|
315
|
+
def fake_open(req, timeout=None):
|
|
316
|
+
captured["timeout"] = timeout
|
|
317
|
+
return FakeResp({"results": []})
|
|
318
|
+
|
|
319
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
320
|
+
c.recall("bank", "query", timeout=8)
|
|
321
|
+
|
|
322
|
+
assert captured["timeout"] == 60
|
|
323
|
+
|
|
324
|
+
def test_no_override_preserves_recall_default(self):
|
|
325
|
+
c = HindsightClient("http://localhost:9077")
|
|
326
|
+
captured = {}
|
|
327
|
+
|
|
328
|
+
def fake_open(req, timeout=None):
|
|
329
|
+
captured["timeout"] = timeout
|
|
330
|
+
return FakeResp({"results": []})
|
|
331
|
+
|
|
332
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
333
|
+
c.recall("bank", "query")
|
|
334
|
+
|
|
335
|
+
assert captured["timeout"] == 10
|
|
336
|
+
|
|
337
|
+
def test_no_override_preserves_retain_default(self):
|
|
338
|
+
c = HindsightClient("http://localhost:9077")
|
|
339
|
+
captured = {}
|
|
340
|
+
|
|
341
|
+
def fake_open(req, timeout=None):
|
|
342
|
+
captured["timeout"] = timeout
|
|
343
|
+
return FakeResp({})
|
|
344
|
+
|
|
345
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
346
|
+
c.retain("bank", "content")
|
|
347
|
+
|
|
348
|
+
assert captured["timeout"] == 15
|
|
349
|
+
|
|
350
|
+
def test_override_does_not_affect_health_check(self):
|
|
351
|
+
c = HindsightClient("http://localhost:9077", request_timeout_override=60)
|
|
352
|
+
captured = {}
|
|
353
|
+
|
|
354
|
+
def fake_open(req, timeout=None):
|
|
355
|
+
captured["timeout"] = timeout
|
|
356
|
+
return FakeResp({}, status=200)
|
|
357
|
+
|
|
358
|
+
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
359
|
+
with patch("time.sleep"):
|
|
360
|
+
c.health_check()
|
|
361
|
+
|
|
362
|
+
assert captured["timeout"] == 5
|
|
@@ -126,3 +126,50 @@ class TestLoadConfig:
|
|
|
126
126
|
monkeypatch.setenv("HINDSIGHT_RECALL_BUDGET", "high")
|
|
127
127
|
cfg = load_config()
|
|
128
128
|
assert cfg["recallBudget"] == "high"
|
|
129
|
+
|
|
130
|
+
# Upstream 962140eef — recall tag filter env overrides.
|
|
131
|
+
|
|
132
|
+
def test_recall_tags_env_override_accepts_comma_list(self, tmp_path, monkeypatch):
|
|
133
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
134
|
+
monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", "memory_type:rule, tech_stack:supabase")
|
|
135
|
+
cfg = load_config()
|
|
136
|
+
assert cfg["recallTags"] == ["memory_type:rule", "tech_stack:supabase"]
|
|
137
|
+
|
|
138
|
+
def test_recall_tags_env_override_accepts_json(self, tmp_path, monkeypatch):
|
|
139
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
140
|
+
monkeypatch.setenv("HINDSIGHT_RECALL_TAGS", '["memory_type:rule"]')
|
|
141
|
+
cfg = load_config()
|
|
142
|
+
assert cfg["recallTags"] == ["memory_type:rule"]
|
|
143
|
+
|
|
144
|
+
def test_recall_tag_groups_env_override_accepts_json(self, tmp_path, monkeypatch):
|
|
145
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
146
|
+
monkeypatch.setenv(
|
|
147
|
+
"HINDSIGHT_RECALL_TAG_GROUPS",
|
|
148
|
+
'[{"op":"all","tags":["memory_type:rule","tech_stack:supabase"]}]',
|
|
149
|
+
)
|
|
150
|
+
cfg = load_config()
|
|
151
|
+
assert cfg["recallTagGroups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}]
|
|
152
|
+
|
|
153
|
+
def test_recall_additional_bank_filters_env_override_accepts_json(self, tmp_path, monkeypatch):
|
|
154
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
155
|
+
monkeypatch.setenv(
|
|
156
|
+
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS",
|
|
157
|
+
'{"normative":{"recallTags":["memory_type:rule"],"recallTagsMatch":"all"}}',
|
|
158
|
+
)
|
|
159
|
+
cfg = load_config()
|
|
160
|
+
assert cfg["recallAdditionalBankFilters"] == {
|
|
161
|
+
"normative": {"recallTags": ["memory_type:rule"], "recallTagsMatch": "all"}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Upstream 55ef70679 — request timeout override.
|
|
165
|
+
|
|
166
|
+
def test_request_timeout_default_none(self, tmp_path, monkeypatch):
|
|
167
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
168
|
+
cfg = load_config()
|
|
169
|
+
assert cfg["requestTimeoutSeconds"] is None
|
|
170
|
+
|
|
171
|
+
def test_request_timeout_env_override(self, tmp_path, monkeypatch):
|
|
172
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
173
|
+
monkeypatch.setenv("HINDSIGHT_REQUEST_TIMEOUT_SECONDS", "60")
|
|
174
|
+
cfg = load_config()
|
|
175
|
+
assert cfg["requestTimeoutSeconds"] == 60
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Tests for lib/content.py — pure content-processing functions."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
|
|
5
7
|
from lib.content import (
|
|
6
8
|
_extract_text_content,
|
|
7
9
|
_is_channel_message_tool,
|
|
8
10
|
compose_recall_query,
|
|
11
|
+
format_current_time,
|
|
9
12
|
format_memories,
|
|
10
13
|
prepare_retention_transcript,
|
|
11
14
|
slice_last_turns_by_user_boundary,
|
|
@@ -469,3 +472,18 @@ class TestPrepareRetentionTranscript:
|
|
|
469
472
|
transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True, include_tool_calls=False)
|
|
470
473
|
assert "[role: user]" in transcript
|
|
471
474
|
assert "[user:end]" in transcript
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# format_current_time
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class TestFormatCurrentTime:
|
|
483
|
+
def test_includes_utc_suffix(self):
|
|
484
|
+
# The "UTC" suffix prevents client LLMs from misreading the
|
|
485
|
+
# timestamp as local time.
|
|
486
|
+
assert format_current_time().endswith(" UTC")
|
|
487
|
+
|
|
488
|
+
def test_format_shape(self):
|
|
489
|
+
assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC", format_current_time())
|
|
@@ -310,6 +310,68 @@ class TestRecallHook:
|
|
|
310
310
|
cache_path = tmp_path / "plugin_data" / "state" / "recall_cache.json"
|
|
311
311
|
assert not cache_path.exists(), f"Cache should not be written for TTL={bad!r}"
|
|
312
312
|
|
|
313
|
+
def test_passes_tag_filters_to_recall_api(self, monkeypatch, tmp_path):
|
|
314
|
+
# Upstream 962140eef.
|
|
315
|
+
captured = {}
|
|
316
|
+
|
|
317
|
+
def capture_and_respond(req, timeout=None):
|
|
318
|
+
if "/recall" in req.full_url:
|
|
319
|
+
captured["body"] = json.loads(req.data.decode())
|
|
320
|
+
return FakeHTTPResponse({"results": []})
|
|
321
|
+
|
|
322
|
+
hook_input = make_hook_input(prompt="What project rules apply here?")
|
|
323
|
+
_run_hook(
|
|
324
|
+
"recall",
|
|
325
|
+
hook_input,
|
|
326
|
+
monkeypatch,
|
|
327
|
+
tmp_path,
|
|
328
|
+
urlopen_side_effect=capture_and_respond,
|
|
329
|
+
extra_settings={
|
|
330
|
+
"recallTags": ["memory_type:rule"],
|
|
331
|
+
"recallTagsMatch": "any_strict",
|
|
332
|
+
"recallTagGroups": [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}],
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
assert captured["body"]["tags"] == ["memory_type:rule"]
|
|
337
|
+
assert captured["body"]["tags_match"] == "any_strict"
|
|
338
|
+
assert captured["body"]["tag_groups"] == [{"op": "all", "tags": ["memory_type:rule", "tech_stack:supabase"]}]
|
|
339
|
+
|
|
340
|
+
def test_additional_bank_filters_override_global_tags(self, monkeypatch, tmp_path):
|
|
341
|
+
# Upstream 962140eef — per-bank overrides beat the global filters.
|
|
342
|
+
captured = []
|
|
343
|
+
|
|
344
|
+
def capture_and_respond(req, timeout=None):
|
|
345
|
+
if "/recall" in req.full_url:
|
|
346
|
+
captured.append(json.loads(req.data.decode()))
|
|
347
|
+
return FakeHTTPResponse({"results": []})
|
|
348
|
+
|
|
349
|
+
hook_input = make_hook_input(prompt="What project rules apply here?")
|
|
350
|
+
_run_hook(
|
|
351
|
+
"recall",
|
|
352
|
+
hook_input,
|
|
353
|
+
monkeypatch,
|
|
354
|
+
tmp_path,
|
|
355
|
+
urlopen_side_effect=capture_and_respond,
|
|
356
|
+
extra_settings={
|
|
357
|
+
"bankId": "project-bank",
|
|
358
|
+
"recallAdditionalBanks": ["normative-bank"],
|
|
359
|
+
"recallTags": ["tech_stack:supabase"],
|
|
360
|
+
"recallTagsMatch": "any",
|
|
361
|
+
"recallAdditionalBankFilters": {
|
|
362
|
+
"normative-bank": {
|
|
363
|
+
"recallTags": ["memory_type:rule"],
|
|
364
|
+
"recallTagsMatch": "all_strict",
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
assert captured[0]["tags"] == ["tech_stack:supabase"]
|
|
371
|
+
assert captured[0]["tags_match"] == "any"
|
|
372
|
+
assert captured[1]["tags"] == ["memory_type:rule"]
|
|
373
|
+
assert captured[1]["tags_match"] == "all_strict"
|
|
374
|
+
|
|
313
375
|
def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
|
|
314
376
|
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
315
377
|
(tmp_path / "plugin_data").mkdir(exist_ok=True)
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Render a one-tap unlock card for hostd error_envelopes that carry a
|
|
3
|
-
* `flip_yaml_flag` fix (#1758 Phase 1).
|
|
4
|
-
*
|
|
5
|
-
* CRITICAL safety: the `yaml_path` MUST be on the
|
|
6
|
-
* `UNLOCK_CARD_YAML_ALLOWLIST` exported from
|
|
7
|
-
* `src/host-control/config-edit-validator.ts`. A malformed or hostile
|
|
8
|
-
* envelope from any backend could otherwise nudge the operator into
|
|
9
|
-
* one-tap-approving an arbitrary flag flip. Non-allowlisted paths fall
|
|
10
|
-
* back to plain-text rendering (the caller surfaces `resp.error` as
|
|
11
|
-
* today).
|
|
12
|
-
*
|
|
13
|
-
* Phase 1 scope: ONLY `flip_yaml_flag`. `request_vault_grant` is
|
|
14
|
-
* explicitly deferred to a later phase (still plain-text rendered).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { HostdResponse } from "../../src/host-control/protocol.js";
|
|
18
|
-
import { isAllowlistedYamlPath } from "../../src/host-control/config-edit-validator.js";
|
|
19
|
-
import {
|
|
20
|
-
buildApprovalCard,
|
|
21
|
-
type BuiltApprovalCard,
|
|
22
|
-
} from "./approval-card.js";
|
|
23
|
-
|
|
24
|
-
export type UnlockCardOutcome =
|
|
25
|
-
| { kind: "card"; card: BuiltApprovalCard; yaml_path: string; to: unknown }
|
|
26
|
-
| { kind: "plain-text" };
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Decide whether to render a one-tap unlock card for the given
|
|
30
|
-
* response. Returns `{kind: "plain-text"}` whenever the envelope
|
|
31
|
-
* lacks a `flip_yaml_flag` fix OR the path isn't on the allowlist.
|
|
32
|
-
*
|
|
33
|
-
* `approvalRequestId` is the 32-hex nonce minted by the approval
|
|
34
|
-
* kernel; caller is responsible for binding the card to that nonce
|
|
35
|
-
* and recording the apply-on-tap intent.
|
|
36
|
-
*/
|
|
37
|
-
export function renderErrorEnvelopeCard(
|
|
38
|
-
resp: HostdResponse,
|
|
39
|
-
agentName: string,
|
|
40
|
-
approvalRequestId: string,
|
|
41
|
-
): UnlockCardOutcome {
|
|
42
|
-
const env = resp.error_envelope;
|
|
43
|
-
if (!env || !env.fix) return { kind: "plain-text" };
|
|
44
|
-
if (env.fix.kind !== "flip_yaml_flag") {
|
|
45
|
-
// request_vault_grant is Phase-2 work; everything else has no
|
|
46
|
-
// unlock-card UX. Caller falls back to plain-text rendering.
|
|
47
|
-
return { kind: "plain-text" };
|
|
48
|
-
}
|
|
49
|
-
const { yaml_path, to } = env.fix;
|
|
50
|
-
if (!isAllowlistedYamlPath(yaml_path)) {
|
|
51
|
-
// Defense-in-depth: never render a one-tap card for a path the
|
|
52
|
-
// operator hasn't explicitly opted into.
|
|
53
|
-
return { kind: "plain-text" };
|
|
54
|
-
}
|
|
55
|
-
const card = buildApprovalCard({
|
|
56
|
-
request_id: approvalRequestId,
|
|
57
|
-
agent: agentName,
|
|
58
|
-
scope_humanized: `flip ${yaml_path} → ${JSON.stringify(to)}`,
|
|
59
|
-
why: env.human + (env.why ? ` — ${env.why}` : ""),
|
|
60
|
-
offer_always: false,
|
|
61
|
-
offer_ttl: false,
|
|
62
|
-
});
|
|
63
|
-
return { kind: "card", card, yaml_path, to };
|
|
64
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue #305 Option A — resolve which sub-agent (by jsonl_agent_id) is
|
|
3
|
-
* calling progress_update.
|
|
4
|
-
*
|
|
5
|
-
* Three resolution strategies, in priority order:
|
|
6
|
-
* 1. agentIdHint — exact match on subagents.jsonl_agent_id
|
|
7
|
-
* 2. toolUseIdHint — exact match on subagents.id (parent's Agent tool_use_id)
|
|
8
|
-
* 3. Heuristic: most-recently-started running sub-agent in the active turn
|
|
9
|
-
* for this chat. Logs a stderr warning when multiple candidates exist.
|
|
10
|
-
*
|
|
11
|
-
* Returns null if no match (caller falls through to message-send).
|
|
12
|
-
* Never throws; SQL errors return null.
|
|
13
|
-
*
|
|
14
|
-
* Extracted from gateway.ts so the resolver can be unit-tested against an
|
|
15
|
-
* in-memory SQLite DB without spinning up the full grammY bot harness.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Minimal duck-typed interface that matches both bun:sqlite's `Database`
|
|
20
|
-
* and the `SqliteDatabase` shape returned by `openTurnsDb`. We accept the
|
|
21
|
-
* narrowed shape so the resolver can run against any equivalent handle.
|
|
22
|
-
*/
|
|
23
|
-
export interface ResolverDb {
|
|
24
|
-
prepare(sql: string): {
|
|
25
|
-
get(...params: unknown[]): unknown
|
|
26
|
-
all(...params: unknown[]): unknown[]
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface ResolveCallingSubagentOpts {
|
|
31
|
-
db: ResolverDb | null
|
|
32
|
-
chatId: string
|
|
33
|
-
threadId?: number | string
|
|
34
|
-
agentIdHint: string | null
|
|
35
|
-
toolUseIdHint: string | null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type ResolveCallingSubagentResult = { agentId: string } | null
|
|
39
|
-
|
|
40
|
-
export function resolveCallingSubagent(
|
|
41
|
-
opts: ResolveCallingSubagentOpts,
|
|
42
|
-
): ResolveCallingSubagentResult {
|
|
43
|
-
if (opts.db == null) return null
|
|
44
|
-
try {
|
|
45
|
-
if (opts.agentIdHint != null) {
|
|
46
|
-
const row = opts.db.prepare(
|
|
47
|
-
"SELECT jsonl_agent_id FROM subagents WHERE jsonl_agent_id = ? AND status = 'running'",
|
|
48
|
-
).get(opts.agentIdHint) as { jsonl_agent_id: string } | undefined
|
|
49
|
-
if (row?.jsonl_agent_id) return { agentId: row.jsonl_agent_id }
|
|
50
|
-
}
|
|
51
|
-
if (opts.toolUseIdHint != null) {
|
|
52
|
-
const row = opts.db.prepare(
|
|
53
|
-
"SELECT jsonl_agent_id FROM subagents WHERE id = ? AND status = 'running'",
|
|
54
|
-
).get(opts.toolUseIdHint) as { jsonl_agent_id: string | null } | undefined
|
|
55
|
-
if (row?.jsonl_agent_id) return { agentId: row.jsonl_agent_id }
|
|
56
|
-
}
|
|
57
|
-
// Heuristic fallback.
|
|
58
|
-
const turnRow = opts.db.prepare(
|
|
59
|
-
"SELECT turn_key FROM turns WHERE chat_id = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1",
|
|
60
|
-
).get(opts.chatId) as { turn_key: string } | undefined
|
|
61
|
-
if (turnRow?.turn_key == null) return null
|
|
62
|
-
const candidates = opts.db.prepare(
|
|
63
|
-
"SELECT jsonl_agent_id FROM subagents WHERE parent_turn_key = ? AND status = 'running' AND jsonl_agent_id IS NOT NULL ORDER BY started_at DESC",
|
|
64
|
-
).all(turnRow.turn_key) as Array<{ jsonl_agent_id: string }>
|
|
65
|
-
if (candidates.length === 0) return null
|
|
66
|
-
if (candidates.length > 1) {
|
|
67
|
-
// eslint-disable-next-line no-console
|
|
68
|
-
console.warn(
|
|
69
|
-
`progress_update: heuristic resolution selected most-recent of ${candidates.length} running sub-agents (chat=${opts.chatId}); pass agent_id explicitly to avoid mis-attribution`,
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
return { agentId: candidates[0].jsonl_agent_id }
|
|
73
|
-
} catch (err) {
|
|
74
|
-
// eslint-disable-next-line no-console
|
|
75
|
-
console.warn('progress_update: resolveCallingSubagent SQL error', err)
|
|
76
|
-
return null
|
|
77
|
-
}
|
|
78
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Silent-reply markers + allowlist guard.
|
|
3
|
-
*
|
|
4
|
-
* Lives in its own module (separate from server.ts) so that tests and
|
|
5
|
-
* other importers can pull these helpers in without booting the
|
|
6
|
-
* full MCP server — server.ts has top-level side effects (env load,
|
|
7
|
-
* TELEGRAM_BOT_TOKEN check, history.db open, session-tail spawn) that
|
|
8
|
-
* are inappropriate for a unit-test import boundary.
|
|
9
|
-
*
|
|
10
|
-
* Sprint1 review finding #6: an earlier revision of the reply /
|
|
11
|
-
* stream_reply tool handlers returned the silent-reply ack BEFORE
|
|
12
|
-
* calling `assertAllowedChat`, so unauthorised chats could bypass the
|
|
13
|
-
* outbound allowlist by having the agent emit `NO_REPLY`. The ack
|
|
14
|
-
* itself is a cross-chat signal (it confirms to the LLM that the chat
|
|
15
|
-
* exists and is reachable) even though no Telegram message is sent, so
|
|
16
|
-
* we must refuse disallowed chats *before* producing it. The
|
|
17
|
-
* guardSilentReply helper locks that ordering in.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
const SILENT_REPLY_MARKERS = new Set(['NO_REPLY', 'HEARTBEAT_OK'])
|
|
21
|
-
|
|
22
|
-
// Derive the char-length bound from the marker set so adding a new
|
|
23
|
-
// marker doesn't silently desync with a hand-tuned constant.
|
|
24
|
-
const SILENT_REPLY_MAX_LEN = Math.max(
|
|
25
|
-
...Array.from(SILENT_REPLY_MARKERS, (m) => m.length),
|
|
26
|
-
) + 2 // small buffer for trailing punctuation callers might add accidentally
|
|
27
|
-
|
|
28
|
-
export function isSilentReplyMarker(text: string | undefined): boolean {
|
|
29
|
-
if (typeof text !== 'string') return false
|
|
30
|
-
const trimmed = text.trim()
|
|
31
|
-
if (trimmed.length === 0) return false
|
|
32
|
-
if (trimmed.length > SILENT_REPLY_MAX_LEN) return false
|
|
33
|
-
// Case-insensitive match: models occasionally emit `no_reply` or
|
|
34
|
-
// `NoReply`. Require letters/underscores/digits only so legitimate
|
|
35
|
-
// prose that happens to contain "NO_REPLY was suggested" still sends.
|
|
36
|
-
return SILENT_REPLY_MARKERS.has(trimmed.toUpperCase())
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Decide whether a `reply`/`stream_reply` invocation should be short-
|
|
41
|
-
* circuited as a silent-reply ack, enforcing the allowlist FIRST.
|
|
42
|
-
*
|
|
43
|
-
* `assertAllowed` throws when `chat_id` is not on the allowlist; callers
|
|
44
|
-
* let that propagate so the MCP tool call fails loudly.
|
|
45
|
-
*/
|
|
46
|
-
export function guardSilentReply(params: {
|
|
47
|
-
chat_id: string
|
|
48
|
-
text: string | undefined
|
|
49
|
-
hasFiles: boolean
|
|
50
|
-
assertAllowed: (chat_id: string) => void
|
|
51
|
-
}): { kind: 'silent'; markerText: string } | { kind: 'continue' } {
|
|
52
|
-
const { chat_id, text, hasFiles, assertAllowed } = params
|
|
53
|
-
if (hasFiles) return { kind: 'continue' }
|
|
54
|
-
if (!isSilentReplyMarker(text)) return { kind: 'continue' }
|
|
55
|
-
// Allowlist check BEFORE returning the ack — see docblock above.
|
|
56
|
-
assertAllowed(chat_id)
|
|
57
|
-
return { kind: 'silent', markerText: (text as string).trim() }
|
|
58
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram bridge unlock-card safety (#1758 Phase 1).
|
|
3
|
-
*
|
|
4
|
-
* The bridge MUST validate `flip_yaml_flag.yaml_path` against the
|
|
5
|
-
* config-edit-validator allowlist before rendering a one-tap approval
|
|
6
|
-
* card. A malformed or hostile envelope from any backend could
|
|
7
|
-
* otherwise nudge the operator into approving an arbitrary flag flip.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, it, expect } from "vitest";
|
|
11
|
-
import { renderErrorEnvelopeCard } from "../gateway/error-envelope-card.js";
|
|
12
|
-
import type { HostdResponse } from "../../src/host-control/protocol.js";
|
|
13
|
-
|
|
14
|
-
function mkResp(fix: HostdResponse["error_envelope"]["fix"]): HostdResponse {
|
|
15
|
-
return {
|
|
16
|
-
v: 1,
|
|
17
|
-
request_id: "r-1",
|
|
18
|
-
result: "error",
|
|
19
|
-
exit_code: null,
|
|
20
|
-
duration_ms: 0,
|
|
21
|
-
error: "E_FOO: foo",
|
|
22
|
-
error_envelope: {
|
|
23
|
-
v: 1,
|
|
24
|
-
code: "E_FOO",
|
|
25
|
-
human: "foo",
|
|
26
|
-
fix,
|
|
27
|
-
request_id: "r-1",
|
|
28
|
-
},
|
|
29
|
-
} as HostdResponse;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("renderErrorEnvelopeCard — allowlist guard", () => {
|
|
33
|
-
it("renders an approval card for an allowlisted yaml_path", () => {
|
|
34
|
-
const resp = mkResp({
|
|
35
|
-
kind: "flip_yaml_flag",
|
|
36
|
-
yaml_path: "hostd.config_edit_enabled",
|
|
37
|
-
to: true,
|
|
38
|
-
});
|
|
39
|
-
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
40
|
-
expect(out.kind).toBe("card");
|
|
41
|
-
if (out.kind === "card") {
|
|
42
|
-
expect(out.yaml_path).toBe("hostd.config_edit_enabled");
|
|
43
|
-
expect(out.to).toBe(true);
|
|
44
|
-
expect(out.card.text).toContain("klanker");
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("falls back to plain-text for a NON-allowlisted yaml_path", () => {
|
|
49
|
-
const resp = mkResp({
|
|
50
|
-
kind: "flip_yaml_flag",
|
|
51
|
-
yaml_path: "hostd.evil_backdoor_flag",
|
|
52
|
-
to: true,
|
|
53
|
-
});
|
|
54
|
-
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
55
|
-
expect(out).toEqual({ kind: "plain-text" });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("falls back to plain-text for request_vault_grant (Phase 2 scope)", () => {
|
|
59
|
-
const resp = mkResp({
|
|
60
|
-
kind: "request_vault_grant",
|
|
61
|
-
vault_key: "openai/api-key",
|
|
62
|
-
});
|
|
63
|
-
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
64
|
-
expect(out).toEqual({ kind: "plain-text" });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("falls back to plain-text when no envelope is present", () => {
|
|
68
|
-
const resp: HostdResponse = {
|
|
69
|
-
v: 1,
|
|
70
|
-
request_id: "r-1",
|
|
71
|
-
result: "error",
|
|
72
|
-
exit_code: null,
|
|
73
|
-
duration_ms: 0,
|
|
74
|
-
error: "legacy string",
|
|
75
|
-
};
|
|
76
|
-
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
77
|
-
expect(out).toEqual({ kind: "plain-text" });
|
|
78
|
-
});
|
|
79
|
-
});
|