switchroom 0.16.47 → 0.17.1
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 +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1359 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- 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 +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +842 -832
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/format.ts +119 -17
- package/telegram-plugin/gateway/approvals-commands.ts +6 -2
- 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 +535 -627
- 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/ms365-write-approval.test.ts +13 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
- 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/vault-request-access-card.ts +5 -1
- 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/format-consistency.test.ts +79 -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-card.test.ts +17 -0
- package/telegram-plugin/tests/welcome-text.test.ts +64 -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/welcome-text.ts +13 -9
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +66 -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 +43 -4
- 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 +79 -11
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -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/scripts/tests/test_retain_window.py +261 -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 +123 -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
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Changed (switchroom divergence)
|
|
6
|
+
|
|
7
|
+
- **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
|
|
8
|
+
throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
|
|
9
|
+
applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
|
|
10
|
+
sets this in `scaffold.ts` for every-turn crash durability) chunked mode fell
|
|
11
|
+
through to full-session and re-consolidated the entire accumulated transcript
|
|
12
|
+
on every Stop fire. Window selection is now extracted into a pure
|
|
13
|
+
`select_retain_window()` helper and slices a window of
|
|
14
|
+
`max(retainEveryNTurns, 1) + retainOverlapTurns` turns whenever
|
|
15
|
+
`retainMode == "chunked"`, independent of the throttle. The throttle-skip
|
|
16
|
+
logic (`retain_every_n > 1` firing cadence) is unchanged, so `> 1` behaviour
|
|
17
|
+
and the full-session default are equivalent. This is a deliberate switchroom
|
|
18
|
+
divergence from pristine vendor and is a **candidate to upstream to
|
|
19
|
+
vectorize-io/hindsight** — decoupling *what* to retain from *whether* to fire
|
|
20
|
+
this turn is a general improvement, not switchroom-specific.
|
|
21
|
+
|
|
22
|
+
- **content.py: `slice_last_turns_by_user_boundary()` counts genuine HUMAN
|
|
23
|
+
turns only** (switchroom Phase 6b, adversarial-review fix). Claude Code emits
|
|
24
|
+
tool results as `role="user"` messages whose content is a list of
|
|
25
|
+
`tool_result` blocks. The boundary counter treated every `role="user"`
|
|
26
|
+
message as a turn, so on a tool-heavy turn (≥N sequential tool rounds) a
|
|
27
|
+
fixed-size retain window filled with `tool_result` messages and pushed the
|
|
28
|
+
actual human message OUTSIDE the window — silently dropping the fact from
|
|
29
|
+
that fire and every later fire (whose window starts even further away), so it
|
|
30
|
+
was never retained; on restart the fact was gone. A message whose content is
|
|
31
|
+
entirely `tool_result` blocks is now skipped as a boundary
|
|
32
|
+
(`_is_tool_result_only_user_message`), so "window = N turns" means N *human*
|
|
33
|
+
turns regardless of tool volume. Affects both the retain window-slice and the
|
|
34
|
+
recall context-slice (both want N human turns). **Candidate to upstream** —
|
|
35
|
+
the same silent-loss bug exists in vendor's own `retainEveryNTurns > 1`
|
|
36
|
+
chunked path. NOTE: switchroom never ran chunked before Phase 6b, so this
|
|
37
|
+
changes no previously-exercised switchroom behaviour.
|
|
38
|
+
|
|
39
|
+
- **retain.py: SessionEnd `force=True` widens chunked mode to a full-session
|
|
40
|
+
sweep** (switchroom Phase 6b, belt-and-braces). Per-turn fires still slice
|
|
41
|
+
the window; the single forced retain at SessionEnd
|
|
42
|
+
(`session_end.py` → `run_retain(force=True)`) now retains the whole session
|
|
43
|
+
in chunked mode, guaranteeing a graceful shutdown always flushes everything
|
|
44
|
+
even if per-turn windowing had an edge. Costs one full sweep per session (at
|
|
45
|
+
end), not per turn.
|
|
46
|
+
|
|
47
|
+
### Ported from upstream (vectorize-io/hindsight, `hindsight-integrations/claude-code/`)
|
|
48
|
+
|
|
49
|
+
- `c5a61db2b` — raise `_check_health` default timeout 2s→10s in
|
|
50
|
+
`scripts/lib/daemon.py` to stop the busy-daemon restart/kill loop
|
|
51
|
+
(applied clean; codex-integration hunk not applicable).
|
|
52
|
+
- `3d6c2ba8b` — label "Current time" as UTC in the recall context block
|
|
53
|
+
(`lib/content.py:format_current_time`), so client LLMs in non-UTC
|
|
54
|
+
timezones don't misread the timestamp as local time.
|
|
55
|
+
- `962140eef` — recall tag filters: `recallTags`, `recallTagsMatch`,
|
|
56
|
+
`recallTagGroups`, plus per-additional-bank overrides via
|
|
57
|
+
`recallAdditionalBankFilters`. Hand-ported into the switchroom recall.py
|
|
58
|
+
rewrite: filters compose with sender-bank routing (per-bank overrides
|
|
59
|
+
apply to sender banks too) and are part of the recall cache key
|
|
60
|
+
(`_tag_filter_sig`) so a filter change can't serve stale cached results.
|
|
61
|
+
Note: because the key now joins an extra part (empty string when filters
|
|
62
|
+
are unused), every cache key rotates ONCE across this upgrade boundary —
|
|
63
|
+
the first recall per session after upgrading is a cache miss. Within a
|
|
64
|
+
version, keys are unchanged as long as filters stay unused.
|
|
65
|
+
- `55ef70679` — optional `requestTimeoutSeconds` /
|
|
66
|
+
`HINDSIGHT_REQUEST_TIMEOUT_SECONDS` global request-timeout override in
|
|
67
|
+
`HindsightClient` (adapted to our `_request`). Wired into retain.py only;
|
|
68
|
+
recall.py deliberately keeps its own 8s hook-budget timeout. Upstream's
|
|
69
|
+
mcp_server.py hunks skipped (not vendored).
|
|
70
|
+
|
|
5
71
|
### Added
|
|
6
72
|
|
|
7
73
|
- `{user_id}` template variable for `retainTags` and `retainMetadata`, resolved
|
|
@@ -186,6 +186,11 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
|
|
|
186
186
|
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
|
|
187
187
|
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
|
|
188
188
|
| `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
|
|
189
|
+
| `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
|
|
190
|
+
| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
|
|
191
|
+
| `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. |
|
|
192
|
+
| `recallAdditionalBankFilters` | `HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS` | `{}` | Optional per-bank tag filter overrides for banks listed in `recallAdditionalBanks`, keyed by bank ID. Each value may set `recallTags`, `recallTagsMatch`, and `recallTagGroups`. The env var must be JSON. |
|
|
193
|
+
| `requestTimeoutSeconds` | `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` | unset | Optional global HTTP request timeout override (seconds) for retain and API calls. The recall hook keeps its own 8s hook-budget timeout regardless. |
|
|
189
194
|
| `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
|
|
190
195
|
|
|
191
196
|
---
|
|
@@ -44,9 +44,29 @@ def _validate_api_url(url: str) -> str:
|
|
|
44
44
|
class HindsightClient:
|
|
45
45
|
"""HTTP client for the Hindsight API."""
|
|
46
46
|
|
|
47
|
-
def __init__(
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
api_url: str,
|
|
50
|
+
api_token: Optional[str] = None,
|
|
51
|
+
request_timeout_override: Optional[int] = None,
|
|
52
|
+
):
|
|
48
53
|
self.api_url = _validate_api_url(api_url)
|
|
49
54
|
self.api_token = api_token
|
|
55
|
+
self.request_timeout_override = request_timeout_override
|
|
56
|
+
|
|
57
|
+
def _resolve_timeout(self, timeout: int) -> int:
|
|
58
|
+
"""Return the override if configured, otherwise the caller's timeout.
|
|
59
|
+
|
|
60
|
+
Upstream 55ef70679. NOTE: recall.py deliberately does not pass the
|
|
61
|
+
override — its 8s timeout is a hook-budget invariant.
|
|
62
|
+
|
|
63
|
+
The override is clamped to >= 1: a zero/negative env value would
|
|
64
|
+
otherwise reach urlopen as a nonsensical timeout (0 fails every
|
|
65
|
+
request immediately), turning a config typo into a dead client.
|
|
66
|
+
"""
|
|
67
|
+
if self.request_timeout_override is None:
|
|
68
|
+
return timeout
|
|
69
|
+
return max(1, self.request_timeout_override)
|
|
50
70
|
|
|
51
71
|
def _headers(self) -> dict:
|
|
52
72
|
headers = {
|
|
@@ -58,6 +78,7 @@ class HindsightClient:
|
|
|
58
78
|
return headers
|
|
59
79
|
|
|
60
80
|
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
81
|
+
timeout = self._resolve_timeout(timeout)
|
|
61
82
|
url = f"{self.api_url}{path}"
|
|
62
83
|
data = json.dumps(body).encode() if body else None
|
|
63
84
|
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
@@ -100,6 +121,9 @@ class HindsightClient:
|
|
|
100
121
|
max_tokens: int = 1024,
|
|
101
122
|
budget: str = "mid",
|
|
102
123
|
types: Optional[list] = None,
|
|
124
|
+
tags: Optional[list] = None,
|
|
125
|
+
tags_match: Optional[str] = None,
|
|
126
|
+
tag_groups: Optional[object] = None,
|
|
103
127
|
timeout: int = 10,
|
|
104
128
|
) -> dict:
|
|
105
129
|
"""Recall memories from a bank.
|
|
@@ -115,6 +139,12 @@ class HindsightClient:
|
|
|
115
139
|
body["budget"] = budget
|
|
116
140
|
if types:
|
|
117
141
|
body["types"] = types
|
|
142
|
+
if tags:
|
|
143
|
+
body["tags"] = tags
|
|
144
|
+
if tags_match:
|
|
145
|
+
body["tags_match"] = tags_match
|
|
146
|
+
if tag_groups:
|
|
147
|
+
body["tag_groups"] = tag_groups
|
|
118
148
|
return self._request("POST", path, body, timeout=timeout)
|
|
119
149
|
|
|
120
150
|
def retain(
|
|
@@ -40,6 +40,12 @@ DEFAULTS = {
|
|
|
40
40
|
"recallContextTurns": 1,
|
|
41
41
|
"recallMaxQueryChars": 800,
|
|
42
42
|
"recallRoles": ["user", "assistant"],
|
|
43
|
+
# Upstream 962140eef — optional recall tag filters passed through to the
|
|
44
|
+
# recall API, plus per-additional-bank overrides keyed by bank ID.
|
|
45
|
+
"recallTags": [],
|
|
46
|
+
"recallTagsMatch": "any",
|
|
47
|
+
"recallTagGroups": None,
|
|
48
|
+
"recallAdditionalBankFilters": {},
|
|
43
49
|
"recallPromptPreamble": (
|
|
44
50
|
"Relevant memories from past conversations (prioritize recent when "
|
|
45
51
|
"conflicting). Only use memories that are directly useful to continue "
|
|
@@ -63,6 +69,12 @@ DEFAULTS = {
|
|
|
63
69
|
"daemonIdleTimeout": 0,
|
|
64
70
|
"embedVersion": "latest",
|
|
65
71
|
"embedPackagePath": None,
|
|
72
|
+
# Upstream 55ef70679 — optional global HTTP request timeout override
|
|
73
|
+
# (seconds). None = keep each call's own default. NOTE: switchroom's
|
|
74
|
+
# recall.py deliberately does NOT wire this override into its client —
|
|
75
|
+
# recall carries its own 8s hook-budget timeout (see recall.py). This
|
|
76
|
+
# mainly benefits retain's 15s timeout on slow/loaded servers.
|
|
77
|
+
"requestTimeoutSeconds": None,
|
|
66
78
|
# Bank
|
|
67
79
|
"bankId": None,
|
|
68
80
|
"bankIdPrefix": "",
|
|
@@ -109,8 +121,16 @@ ENV_OVERRIDES = {
|
|
|
109
121
|
"HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
|
|
110
122
|
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
|
|
111
123
|
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
|
|
124
|
+
# Upstream 962140eef — recall tag filters. The tags env var accepts JSON
|
|
125
|
+
# or a comma-separated list; the others must be JSON.
|
|
126
|
+
"HINDSIGHT_RECALL_TAGS": ("recallTags", list),
|
|
127
|
+
"HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
|
|
128
|
+
"HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
|
|
129
|
+
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
|
|
112
130
|
"HINDSIGHT_API_PORT": ("apiPort", int),
|
|
113
131
|
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
|
|
132
|
+
# Upstream 55ef70679 — global request timeout override.
|
|
133
|
+
"HINDSIGHT_REQUEST_TIMEOUT_SECONDS": ("requestTimeoutSeconds", int),
|
|
114
134
|
"HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
|
|
115
135
|
"HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
|
|
116
136
|
"HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
|
|
@@ -131,8 +151,27 @@ def _cast_env(value: str, typ):
|
|
|
131
151
|
if typ is float:
|
|
132
152
|
return float(value)
|
|
133
153
|
if typ is list:
|
|
134
|
-
#
|
|
135
|
-
|
|
154
|
+
# JSON list first (upstream 962140eef). A value that parses as
|
|
155
|
+
# JSON but is NOT a list (e.g. `42`, `"x"`, `{}`) is a config
|
|
156
|
+
# mistake, not a comma-separated string — return None so the
|
|
157
|
+
# default is kept (matches upstream; fail-open). Only values
|
|
158
|
+
# that don't parse as JSON at all take the comma-split path.
|
|
159
|
+
try:
|
|
160
|
+
parsed = json.loads(value)
|
|
161
|
+
except ValueError:
|
|
162
|
+
if value.lstrip().startswith(("[", "{")):
|
|
163
|
+
# Looks like intended JSON but doesn't parse —
|
|
164
|
+
# malformed config, not a comma list. Keep default.
|
|
165
|
+
return None
|
|
166
|
+
# Comma-separated → list of trimmed, non-empty strings.
|
|
167
|
+
return [t.strip() for t in value.split(",") if t.strip()]
|
|
168
|
+
return parsed if isinstance(parsed, list) else None
|
|
169
|
+
if typ is dict:
|
|
170
|
+
# JSON only (dict or list accepted — tag_groups may be a list).
|
|
171
|
+
parsed = json.loads(value)
|
|
172
|
+
if isinstance(parsed, (dict, list)):
|
|
173
|
+
return parsed
|
|
174
|
+
return None
|
|
136
175
|
return value
|
|
137
176
|
except (ValueError, AttributeError):
|
|
138
177
|
return None
|
|
@@ -167,13 +167,48 @@ def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
|
|
|
167
167
|
# ---------------------------------------------------------------------------
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
def _is_tool_result_only_user_message(message: dict) -> bool:
|
|
171
|
+
"""True when a ``role="user"`` message carries ONLY tool_result blocks.
|
|
172
|
+
|
|
173
|
+
SWITCHROOM DIVERGENCE (candidate to upstream to vectorize-io/hindsight):
|
|
174
|
+
Claude Code emits tool results as ``role="user"`` messages whose content
|
|
175
|
+
is a list of ``{"type": "tool_result", ...}`` blocks — they are NOT
|
|
176
|
+
human turns. A genuine human turn has text (a string, or a content list
|
|
177
|
+
with at least one non-tool_result block, e.g. ``{"type": "text"}`` or an
|
|
178
|
+
image). Treating tool_result messages as turn boundaries lets a tool-heavy
|
|
179
|
+
turn (≥N sequential tool rounds) fill a fixed-size retain window with
|
|
180
|
+
tool_result messages and push the actual human message OUTSIDE the window
|
|
181
|
+
— silently dropping the fact from that fire, and from every later fire
|
|
182
|
+
(whose window starts even further from the human message). On restart the
|
|
183
|
+
fact is gone. This helper lets the boundary counter skip those messages so
|
|
184
|
+
"window = N turns" means N *human* turns regardless of tool volume.
|
|
185
|
+
"""
|
|
186
|
+
if message.get("role") != "user":
|
|
187
|
+
return False
|
|
188
|
+
content = message.get("content")
|
|
189
|
+
if isinstance(content, list):
|
|
190
|
+
blocks = [b for b in content if isinstance(b, dict)]
|
|
191
|
+
# A non-empty content list that is ENTIRELY tool_result blocks.
|
|
192
|
+
if blocks and all(b.get("type") == "tool_result" for b in blocks):
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
170
197
|
def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
|
|
171
198
|
"""Slice messages to the last N turns, where a turn starts at a user message.
|
|
172
199
|
|
|
173
200
|
Port of: sliceLastTurnsByUserBoundary() in index.js
|
|
174
201
|
|
|
175
|
-
Walks backward counting user messages as turn boundaries.
|
|
176
|
-
messages from the Nth
|
|
202
|
+
Walks backward counting GENUINE HUMAN user messages as turn boundaries.
|
|
203
|
+
Returns messages from the Nth human boundary to the end.
|
|
204
|
+
|
|
205
|
+
SWITCHROOM DIVERGENCE (candidate to upstream): tool_result messages carry
|
|
206
|
+
``role="user"`` in the Claude Code transcript but are not human turns; they
|
|
207
|
+
are skipped as boundaries (see ``_is_tool_result_only_user_message``). This
|
|
208
|
+
keeps the fixed-size retain window anchored to human turns so a tool-heavy
|
|
209
|
+
turn can never push the human's fact outside the window (silent memory loss).
|
|
210
|
+
Affects both the retain window-slice and the recall context slice — both
|
|
211
|
+
want "N human turns", not "N transcript user-messages".
|
|
177
212
|
"""
|
|
178
213
|
if not isinstance(messages, list) or not messages or turns <= 0:
|
|
179
214
|
return []
|
|
@@ -182,7 +217,8 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
|
|
|
182
217
|
start_index = -1
|
|
183
218
|
|
|
184
219
|
for i in range(len(messages) - 1, -1, -1):
|
|
185
|
-
|
|
220
|
+
msg = messages[i]
|
|
221
|
+
if msg.get("role") == "user" and not _is_tool_result_only_user_message(msg):
|
|
186
222
|
user_turns_seen += 1
|
|
187
223
|
if user_turns_seen >= turns:
|
|
188
224
|
start_index = i
|
|
@@ -221,10 +257,13 @@ def format_memories(results: list) -> str:
|
|
|
221
257
|
def format_current_time() -> str:
|
|
222
258
|
"""Format current UTC time for recall context.
|
|
223
259
|
|
|
260
|
+
The "UTC" suffix is explicit so client LLMs do not misread the
|
|
261
|
+
value as local time when reasoning about wall-clock context.
|
|
262
|
+
|
|
224
263
|
Port of: formatCurrentTimeForRecall() in index.js
|
|
225
264
|
"""
|
|
226
265
|
now = datetime.now(timezone.utc)
|
|
227
|
-
return now.strftime("%Y-%m-%d %H:%M")
|
|
266
|
+
return now.strftime("%Y-%m-%d %H:%M UTC")
|
|
228
267
|
|
|
229
268
|
|
|
230
269
|
# ---------------------------------------------------------------------------
|
|
@@ -71,8 +71,17 @@ def _is_embed_available(config: dict) -> bool:
|
|
|
71
71
|
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def _check_health(base_url: str, timeout: int =
|
|
75
|
-
"""Quick health check against a Hindsight server.
|
|
74
|
+
def _check_health(base_url: str, timeout: int = 10) -> bool:
|
|
75
|
+
"""Quick health check against a Hindsight server.
|
|
76
|
+
|
|
77
|
+
Default timeout is 10s (matching the recall hook budget): under load an
|
|
78
|
+
alive-but-busy daemon mid fact-extraction may not answer /health within a
|
|
79
|
+
couple of seconds. A too-short timeout yields a false negative, so
|
|
80
|
+
get_api_url() falls through to _ensure_daemon_running() ->
|
|
81
|
+
`hindsight-embed daemon start`, whose _clear_port() then SIGTERMs the
|
|
82
|
+
live daemon -- a restart/kill loop. A 10s budget lets a busy daemon
|
|
83
|
+
respond before it is declared dead.
|
|
84
|
+
"""
|
|
76
85
|
try:
|
|
77
86
|
url = f"{base_url.rstrip('/')}/health"
|
|
78
87
|
req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
|
|
@@ -229,6 +229,33 @@ def _resolve_sender_bank(
|
|
|
229
229
|
return additional_banks
|
|
230
230
|
|
|
231
231
|
|
|
232
|
+
def _tag_filter_sig(
|
|
233
|
+
recall_tags,
|
|
234
|
+
tags_match,
|
|
235
|
+
tag_groups,
|
|
236
|
+
additional_bank_filters,
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Stable fingerprint of the recall tag-filter configuration
|
|
239
|
+
(upstream 962140eef) for cache keying. Tag filters change what the
|
|
240
|
+
recall API returns for an identical query, so they MUST be part of
|
|
241
|
+
the cache key — otherwise a config change (or per-bank filter edit)
|
|
242
|
+
within the TTL window would serve stale, differently-filtered
|
|
243
|
+
results. Empty/default filters collapse to "" so pre-existing cache
|
|
244
|
+
behaviour (and keys) are unchanged when the feature is unused."""
|
|
245
|
+
if not (recall_tags or tag_groups or additional_bank_filters):
|
|
246
|
+
return ""
|
|
247
|
+
try:
|
|
248
|
+
return json.dumps(
|
|
249
|
+
[recall_tags, tags_match, tag_groups, additional_bank_filters],
|
|
250
|
+
sort_keys=True,
|
|
251
|
+
separators=(",", ":"),
|
|
252
|
+
)
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
# Unserializable config — fall back to repr; stable within a
|
|
255
|
+
# process and still distinguishes filtered from unfiltered.
|
|
256
|
+
return repr([recall_tags, tags_match, tag_groups, additional_bank_filters])
|
|
257
|
+
|
|
258
|
+
|
|
232
259
|
def _cache_key(
|
|
233
260
|
session_id: str,
|
|
234
261
|
prompt: str,
|
|
@@ -236,6 +263,7 @@ def _cache_key(
|
|
|
236
263
|
extra_banks: list,
|
|
237
264
|
active_thread_id: str | None = None,
|
|
238
265
|
active_sender: str | None = None,
|
|
266
|
+
tag_filter_sig: str = "",
|
|
239
267
|
) -> str:
|
|
240
268
|
"""Stable hash for cache keying. Session_id is included so a new
|
|
241
269
|
session always misses, regardless of the TTL setting. Extra banks
|
|
@@ -259,6 +287,9 @@ def _cache_key(
|
|
|
259
287
|
",".join(sorted(extra_banks or [])),
|
|
260
288
|
active_thread_id or "",
|
|
261
289
|
active_sender or "",
|
|
290
|
+
# Upstream 962140eef port: tag filters shape the result set, so
|
|
291
|
+
# they are part of the key (see _tag_filter_sig). "" when unused.
|
|
292
|
+
tag_filter_sig or "",
|
|
262
293
|
]
|
|
263
294
|
payload = "\x1f".join(parts)
|
|
264
295
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
@@ -703,11 +734,32 @@ def main():
|
|
|
703
734
|
additional_banks,
|
|
704
735
|
)
|
|
705
736
|
|
|
737
|
+
# Upstream 962140eef — optional recall tag filters. Resolved BEFORE the
|
|
738
|
+
# cache check so the tag-filter fingerprint is part of the cache key
|
|
739
|
+
# (filters change the result set for an identical query). Per-bank
|
|
740
|
+
# overrides in recallAdditionalBankFilters apply to any additional bank —
|
|
741
|
+
# including sender banks appended by _resolve_sender_bank above.
|
|
742
|
+
recall_tags = config.get("recallTags") or None
|
|
743
|
+
tag_groups = config.get("recallTagGroups") or None
|
|
744
|
+
tags_match = config.get("recallTagsMatch") if recall_tags or tag_groups else None
|
|
745
|
+
additional_bank_filters = config.get("recallAdditionalBankFilters") or {}
|
|
746
|
+
if not isinstance(additional_bank_filters, dict):
|
|
747
|
+
additional_bank_filters = {}
|
|
748
|
+
tag_filter_sig = _tag_filter_sig(recall_tags, tags_match, tag_groups, additional_bank_filters)
|
|
749
|
+
|
|
706
750
|
# Switchroom #424 phase 4.1 — cache check BEFORE any HTTP traffic.
|
|
707
751
|
# Whole-session-scoped, opt-in via HINDSIGHT_RECALL_CACHE_TTL_SECS.
|
|
708
752
|
cache_ttl = _cache_ttl_secs()
|
|
709
753
|
cache_key = (
|
|
710
|
-
_cache_key(
|
|
754
|
+
_cache_key(
|
|
755
|
+
session_id,
|
|
756
|
+
prompt,
|
|
757
|
+
bank_id,
|
|
758
|
+
additional_banks,
|
|
759
|
+
active_thread_id,
|
|
760
|
+
active_sender,
|
|
761
|
+
tag_filter_sig,
|
|
762
|
+
)
|
|
711
763
|
if cache_ttl > 0
|
|
712
764
|
else ""
|
|
713
765
|
)
|
|
@@ -787,6 +839,11 @@ def main():
|
|
|
787
839
|
max_tokens=config.get("recallMaxTokens", 1024),
|
|
788
840
|
budget=config.get("recallBudget", "mid"),
|
|
789
841
|
types=config.get("recallTypes"),
|
|
842
|
+
# Upstream 962140eef — optional tag filters (resolved above the
|
|
843
|
+
# cache check; part of the cache key).
|
|
844
|
+
tags=recall_tags,
|
|
845
|
+
tags_match=tags_match,
|
|
846
|
+
tag_groups=tag_groups,
|
|
790
847
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
791
848
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
792
849
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -809,6 +866,19 @@ def main():
|
|
|
809
866
|
# cache key reflects every bank queried; reuse that local instead of
|
|
810
867
|
# re-reading config.
|
|
811
868
|
for extra_bank_id in additional_banks:
|
|
869
|
+
# Upstream 962140eef — per-bank tag-filter overrides; fall back to
|
|
870
|
+
# the global filters when the bank has no entry. Applies uniformly
|
|
871
|
+
# to config-listed banks and sender banks appended by
|
|
872
|
+
# _resolve_sender_bank (both flow through `additional_banks`).
|
|
873
|
+
extra_filter = additional_bank_filters.get(extra_bank_id, {})
|
|
874
|
+
if not isinstance(extra_filter, dict):
|
|
875
|
+
extra_filter = {}
|
|
876
|
+
extra_tags = extra_filter.get("recallTags", recall_tags) or None
|
|
877
|
+
extra_tag_groups = extra_filter.get("recallTagGroups", tag_groups) or None
|
|
878
|
+
extra_tags_match = extra_filter.get(
|
|
879
|
+
"recallTagsMatch",
|
|
880
|
+
tags_match if extra_tags or extra_tag_groups else None,
|
|
881
|
+
)
|
|
812
882
|
try:
|
|
813
883
|
extra_response = client.recall(
|
|
814
884
|
bank_id=extra_bank_id,
|
|
@@ -816,6 +886,9 @@ def main():
|
|
|
816
886
|
max_tokens=config.get("recallMaxTokens", 1024),
|
|
817
887
|
budget=config.get("recallBudget", "mid"),
|
|
818
888
|
types=config.get("recallTypes"),
|
|
889
|
+
tags=extra_tags,
|
|
890
|
+
tags_match=extra_tags_match,
|
|
891
|
+
tag_groups=extra_tag_groups,
|
|
819
892
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
820
893
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
821
894
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -69,6 +69,60 @@ def read_transcript(transcript_path: str) -> list:
|
|
|
69
69
|
return messages
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def select_retain_window(
|
|
73
|
+
retain_mode: str,
|
|
74
|
+
retain_every_n: int,
|
|
75
|
+
overlap_turns: int,
|
|
76
|
+
all_messages: list,
|
|
77
|
+
force: bool = False,
|
|
78
|
+
) -> tuple:
|
|
79
|
+
"""Decide which messages to retain and whether to send as a full window.
|
|
80
|
+
|
|
81
|
+
Returns ``(messages_to_retain, retain_full_window)``.
|
|
82
|
+
|
|
83
|
+
SWITCHROOM DIVERGENCE (Phase 6b — candidate to upstream to
|
|
84
|
+
vectorize-io/hindsight): the chunked sliding-window is decoupled from
|
|
85
|
+
the ``retainEveryNTurns > 1`` throttle. Upstream only sliced a window
|
|
86
|
+
when ``retain_every_n > 1``; with ``retainEveryNTurns=1`` (switchroom's
|
|
87
|
+
every-turn crash-durability setting, applied in scaffold.ts) chunked
|
|
88
|
+
mode fell through to full-session and re-consolidated the ENTIRE
|
|
89
|
+
accumulated transcript on every Stop fire — an unbounded, per-turn cost.
|
|
90
|
+
|
|
91
|
+
Decoupling is safe because window selection and the throttle answer two
|
|
92
|
+
independent questions: the throttle decides *whether* to fire this turn
|
|
93
|
+
(still owned by run_retain, unchanged); this function only decides *what*
|
|
94
|
+
to retain once a fire happens. A chunked window of
|
|
95
|
+
``max(retain_every_n, 1) + overlap_turns`` turns is correct for any
|
|
96
|
+
``retain_every_n >= 1``. With ``retain_every_n=1, overlap=2`` the window
|
|
97
|
+
is the 3 most-recent HUMAN turns (tool_result messages don't count as
|
|
98
|
+
turns — see slice_last_turns_by_user_boundary).
|
|
99
|
+
|
|
100
|
+
``force=True`` (SessionEnd final retain) widens chunked mode to a
|
|
101
|
+
full-session sweep — belt-and-braces so a graceful shutdown always flushes
|
|
102
|
+
the whole session even if per-turn windowing had an edge. This costs a
|
|
103
|
+
full sweep only ONCE per session (at end), not per turn.
|
|
104
|
+
|
|
105
|
+
Durability invariant (jtbd-memory-survives-restart UAT): the window
|
|
106
|
+
always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
|
|
107
|
+
returns ``messages[start:]``), so the turn that just completed — the one
|
|
108
|
+
whose Stop hook is firing — is ALWAYS included. Every turn fires (no
|
|
109
|
+
throttle at n=1), so every turn's content is retained on its own fire.
|
|
110
|
+
Boundaries are counted on human messages only, so a tool-heavy turn can't
|
|
111
|
+
push the human's fact outside the window. No fact can fall outside every
|
|
112
|
+
window.
|
|
113
|
+
"""
|
|
114
|
+
if retain_mode == "chunked" and not force:
|
|
115
|
+
# Sliding window: N turns + configured overlap. max(retain_every_n, 1)
|
|
116
|
+
# keeps the window valid at n=1 (the decoupling); for n>1 this equals
|
|
117
|
+
# the previous `retain_every_n + overlap_turns` (behaviour unchanged).
|
|
118
|
+
window_turns = max(retain_every_n, 1) + overlap_turns
|
|
119
|
+
messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
|
|
120
|
+
return messages_to_retain, True
|
|
121
|
+
# Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
|
|
122
|
+
# sweep. Retain all messages, always as a full window.
|
|
123
|
+
return list(all_messages), True
|
|
124
|
+
|
|
125
|
+
|
|
72
126
|
def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
73
127
|
"""Run the auto-retain flow.
|
|
74
128
|
|
|
@@ -104,7 +158,8 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
104
158
|
|
|
105
159
|
debug_log(config, f"Read {len(all_messages)} messages from transcript")
|
|
106
160
|
|
|
107
|
-
# Retention mode: full session (default) or chunked
|
|
161
|
+
# Retention mode: full session (vendor default) or chunked. Switchroom
|
|
162
|
+
# runs chunked at retainEveryNTurns=1 (see select_retain_window / scaffold.ts).
|
|
108
163
|
retain_mode = config.get("retainMode", "full-session")
|
|
109
164
|
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
|
|
110
165
|
retain_full_window = False
|
|
@@ -118,19 +173,25 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
118
173
|
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
|
|
119
174
|
return {"status": "skipped", "reason": "throttled"}
|
|
120
175
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
176
|
+
# Window selection is decoupled from the throttle above — see
|
|
177
|
+
# select_retain_window() for the switchroom-divergence rationale
|
|
178
|
+
# (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
|
|
179
|
+
overlap_turns = config.get("retainOverlapTurns", 0)
|
|
180
|
+
messages_to_retain, retain_full_window = select_retain_window(
|
|
181
|
+
retain_mode, retain_every_n, overlap_turns, all_messages, force=force
|
|
182
|
+
)
|
|
183
|
+
if retain_mode == "chunked" and not force:
|
|
184
|
+
window_turns = max(retain_every_n, 1) + overlap_turns
|
|
127
185
|
debug_log(
|
|
128
186
|
config,
|
|
129
|
-
f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
|
|
187
|
+
f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
|
|
188
|
+
)
|
|
189
|
+
elif retain_mode == "chunked" and force:
|
|
190
|
+
debug_log(
|
|
191
|
+
config,
|
|
192
|
+
f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
|
|
130
193
|
)
|
|
131
194
|
else:
|
|
132
|
-
# Full session mode: retain all messages, always as full window
|
|
133
|
-
retain_full_window = True
|
|
134
195
|
debug_log(config, f"Full session retain: {len(all_messages)} messages")
|
|
135
196
|
|
|
136
197
|
# Format transcript
|
|
@@ -156,7 +217,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
156
217
|
|
|
157
218
|
api_token = config.get("hindsightApiToken")
|
|
158
219
|
try:
|
|
159
|
-
|
|
220
|
+
# Upstream 55ef70679 — honor the optional requestTimeoutSeconds
|
|
221
|
+
# override (retain runs outside the recall hook budget, so a longer
|
|
222
|
+
# timeout is safe here; recall.py deliberately omits this).
|
|
223
|
+
client = HindsightClient(
|
|
224
|
+
api_url,
|
|
225
|
+
api_token,
|
|
226
|
+
request_timeout_override=config.get("requestTimeoutSeconds"),
|
|
227
|
+
)
|
|
160
228
|
except ValueError as e:
|
|
161
229
|
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
|
|
162
230
|
return {"status": "failed", "error": e, "payload": None}
|