switchroom 0.12.26 → 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.
Files changed (57) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/skill-validate-pretool.mjs +72 -72
  5. package/dist/cli/switchroom.js +359 -357
  6. package/dist/host-control/main.js +99 -99
  7. package/dist/vault/approvals/kernel-server.js +82 -82
  8. package/dist/vault/broker/server.js +83 -83
  9. package/package.json +2 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  11. package/telegram-plugin/dist/gateway/gateway.js +368 -209
  12. package/telegram-plugin/dist/server.js +160 -160
  13. package/telegram-plugin/gateway/gateway.ts +55 -40
  14. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
  15. package/telegram-plugin/stderr-timestamps.ts +106 -0
  16. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
  17. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  18. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  19. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  20. package/vendor/hindsight-memory/LICENSE +21 -0
  21. package/vendor/hindsight-memory/README.md +329 -0
  22. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  23. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  24. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  25. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  26. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  27. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  28. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  29. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  30. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  31. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  32. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  33. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  34. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  36. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  37. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  38. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  39. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  40. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  44. package/vendor/hindsight-memory/settings.json +37 -0
  45. package/vendor/hindsight-memory/skills/setup.md +24 -0
  46. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  47. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  48. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  49. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  50. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  51. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  52. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  53. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  54. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  55. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  56. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  57. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """Drain ``~/.hindsight/pending-retains/``.
3
+
4
+ SessionStart calls into ``drain()`` to retry any retain payloads that
5
+ ``session_end.py`` queued on failure (#1071). Each entry is retried up
6
+ to ``MAX_ATTEMPTS`` (5) times; after that it's renamed to ``.dead`` so
7
+ the queue no longer drains it but the operator can still inspect via
8
+ ``switchroom doctor``.
9
+
10
+ Boundaries
11
+ ----------
12
+ * Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s).
13
+ * Stall guard: if ``STALL_THRESHOLD`` (3) consecutive entries fail with
14
+ the same error class, we stop draining for this session — that's a
15
+ systemic outage, not a transient flake, and continuing would only
16
+ burn the SessionStart timeout budget. The remaining entries stay
17
+ queued for the next session.
18
+ * Total wall-clock cap: ``HINDSIGHT_DRAIN_BUDGET_S`` (default 4s) so
19
+ drain never blocks SessionStart longer than the upstream
20
+ hook timeout permits.
21
+
22
+ Standalone usage::
23
+
24
+ python3 drain_pending.py # one-shot drain, prints summary
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import sys
31
+ import time
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+
35
+ from lib.client import HindsightClient
36
+ from lib.config import debug_log, load_config
37
+ from lib.pending import (
38
+ MAX_ATTEMPTS,
39
+ delete_entry,
40
+ iter_entries,
41
+ mark_dead,
42
+ update_attempt,
43
+ )
44
+
45
+
46
+ STALL_THRESHOLD = 3
47
+
48
+
49
+ def _per_entry_timeout() -> int:
50
+ raw = os.environ.get("HINDSIGHT_DRAIN_TIMEOUT", "5")
51
+ try:
52
+ v = int(raw)
53
+ return max(1, v)
54
+ except ValueError:
55
+ return 5
56
+
57
+
58
+ def _budget_seconds() -> float:
59
+ raw = os.environ.get("HINDSIGHT_DRAIN_BUDGET_S", "4")
60
+ try:
61
+ v = float(raw)
62
+ return max(0.5, v)
63
+ except ValueError:
64
+ return 4.0
65
+
66
+
67
+ def _retry_one(entry: dict, timeout: int) -> None:
68
+ """POST a single queued retain. Raises on failure."""
69
+ client = HindsightClient(entry["api_url"], entry.get("api_token"))
70
+ client.retain(
71
+ bank_id=entry["bank_id"],
72
+ content=entry["content"],
73
+ document_id=entry.get("document_id", "conversation"),
74
+ context=entry.get("context"),
75
+ metadata=entry.get("metadata") or {},
76
+ tags=entry.get("tags"),
77
+ timeout=timeout,
78
+ )
79
+
80
+
81
+ def drain(config: dict | None = None) -> dict:
82
+ """Walk the pending-retains directory and retry each entry.
83
+
84
+ Returns a summary dict::
85
+
86
+ {"drained": int, # successful retries (entries deleted)
87
+ "retried": int, # failures kept for next session
88
+ "dead": int, # entries promoted to .dead this run
89
+ "stalled": bool, # stall guard tripped
90
+ "budget_exceeded": bool}
91
+ """
92
+ config = config or load_config()
93
+ timeout = _per_entry_timeout()
94
+ budget = _budget_seconds()
95
+ started = time.monotonic()
96
+
97
+ summary = {
98
+ "drained": 0,
99
+ "retried": 0,
100
+ "dead": 0,
101
+ "stalled": False,
102
+ "budget_exceeded": False,
103
+ }
104
+
105
+ entries = iter_entries()
106
+ if not entries:
107
+ debug_log(config, "drain_pending: queue empty")
108
+ return summary
109
+
110
+ debug_log(config, f"drain_pending: {len(entries)} entries to retry")
111
+
112
+ consecutive_failures = 0
113
+ last_error_class: str | None = None
114
+
115
+ for path, entry in entries:
116
+ if time.monotonic() - started > budget:
117
+ summary["budget_exceeded"] = True
118
+ debug_log(config, "drain_pending: total budget exceeded, stopping")
119
+ break
120
+
121
+ try:
122
+ _retry_one(entry, timeout=timeout)
123
+ except Exception as e:
124
+ err_class = type(e).__name__
125
+ if err_class == last_error_class:
126
+ consecutive_failures += 1
127
+ else:
128
+ consecutive_failures = 1
129
+ last_error_class = err_class
130
+
131
+ attempts = int(entry.get("attempt_count", 1))
132
+ if attempts >= MAX_ATTEMPTS:
133
+ marker = mark_dead(path, entry)
134
+ summary["dead"] += 1
135
+ print(
136
+ f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
137
+ f"attempts, marking dead at {marker} (last error: {err_class}: {e})",
138
+ file=sys.stderr,
139
+ )
140
+ else:
141
+ update_attempt(path, entry, e)
142
+ summary["retried"] += 1
143
+ debug_log(
144
+ config,
145
+ f"drain_pending: retry {attempts}/{MAX_ATTEMPTS} failed for {path} ({err_class}: {e})",
146
+ )
147
+
148
+ if consecutive_failures >= STALL_THRESHOLD:
149
+ summary["stalled"] = True
150
+ print(
151
+ f"[Hindsight] drain_pending: {consecutive_failures} consecutive "
152
+ f"failures with {err_class}, stalling drain. Remaining entries "
153
+ f"stay queued.",
154
+ file=sys.stderr,
155
+ )
156
+ break
157
+ continue
158
+
159
+ # Success — delete the entry.
160
+ delete_entry(path)
161
+ summary["drained"] += 1
162
+ consecutive_failures = 0
163
+ last_error_class = None
164
+
165
+ return summary
166
+
167
+
168
+ def main() -> int:
169
+ config = load_config()
170
+ summary = drain(config)
171
+ if summary["drained"] or summary["retried"] or summary["dead"]:
172
+ print(
173
+ f"[Hindsight] drain_pending: "
174
+ f"drained={summary['drained']} retried={summary['retried']} "
175
+ f"dead={summary['dead']} "
176
+ f"stalled={summary['stalled']} budget_exceeded={summary['budget_exceeded']}",
177
+ file=sys.stderr,
178
+ )
179
+ # Non-zero only when we promoted entries to .dead — that's the
180
+ # operator-visible signal. Plain retry-still-pending isn't an error,
181
+ # the next SessionStart picks them up.
182
+ return 1 if summary["dead"] else 0
183
+
184
+
185
+ if __name__ == "__main__":
186
+ try:
187
+ sys.exit(main())
188
+ except Exception as e:
189
+ print(f"[Hindsight] drain_pending unexpected error: {e}", file=sys.stderr)
190
+ sys.exit(2)
@@ -0,0 +1,122 @@
1
+ """Bank ID derivation and mission management.
2
+
3
+ Port of Openclaw's deriveBankId() and banksWithMissionSet logic, adapted
4
+ for Claude Code's context model.
5
+
6
+ Openclaw derives bank IDs from: agent, channel, user, provider.
7
+ Claude Code equivalent dimensions:
8
+ - agent → configured name or "claude-code" (HINDSIGHT_AGENT_NAME)
9
+ - project → derived from cwd (working directory basename)
10
+ - session → session_id from hook input
11
+ - channel → from env var HINDSIGHT_CHANNEL_ID (for Telegram/Discord agents)
12
+ - user → from env var HINDSIGHT_USER_ID (for multi-user agents)
13
+
14
+ The channel/user dimensions enable the same per-user/per-channel isolation
15
+ that Openclaw provides via its messageProvider/channelId/senderId context.
16
+ Telegram/Discord agents set HINDSIGHT_CHANNEL_ID and HINDSIGHT_USER_ID in
17
+ their environment to achieve equivalent behavior.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+
23
+ from .state import read_state, write_state
24
+
25
+ DEFAULT_BANK_NAME = "claude-code"
26
+
27
+ # Valid granularity fields for Claude Code
28
+ VALID_FIELDS = {"agent", "project", "session", "channel", "user"}
29
+
30
+
31
+ def derive_bank_id(hook_input: dict, config: dict) -> str:
32
+ """Derive a bank ID from hook context and config.
33
+
34
+ Port of: deriveBankId() in index.js
35
+
36
+ When dynamicBankId is false, returns the static bank.
37
+ When true, composes from granularity fields joined by '::'.
38
+
39
+ Args:
40
+ hook_input: The hook's stdin JSON (has session_id, cwd).
41
+ config: Plugin configuration dict.
42
+ """
43
+ prefix = config.get("bankIdPrefix", "")
44
+
45
+ if not config.get("dynamicBankId", False):
46
+ # Static mode — single bank for everything
47
+ base = config.get("bankId") or DEFAULT_BANK_NAME
48
+ return f"{prefix}-{base}" if prefix else base
49
+
50
+ # Dynamic mode — compose from granularity fields
51
+ fields = config.get("dynamicBankGranularity")
52
+ if not fields or not isinstance(fields, list):
53
+ fields = ["agent", "project"]
54
+
55
+ # Warn on unknown fields (mirrors Openclaw's runtime check)
56
+ for f in fields:
57
+ if f not in VALID_FIELDS:
58
+ print(
59
+ f'[Hindsight] Unknown dynamicBankGranularity field "{f}" — '
60
+ f"valid for Claude Code: {', '.join(sorted(VALID_FIELDS))}",
61
+ file=sys.stderr,
62
+ )
63
+
64
+ # Build field values from hook context + env vars
65
+ cwd = hook_input.get("cwd", "")
66
+ session_id = hook_input.get("session_id", "")
67
+ agent_name = config.get("agentName", "claude-code")
68
+
69
+ # Channel and user come from environment variables, set by the host agent
70
+ # (e.g. Telegram bot sets HINDSIGHT_CHANNEL_ID=telegram-group-12345)
71
+ channel_id = os.environ.get("HINDSIGHT_CHANNEL_ID", "")
72
+ user_id = os.environ.get("HINDSIGHT_USER_ID", "")
73
+
74
+ field_map = {
75
+ "agent": agent_name,
76
+ "project": os.path.basename(cwd) if cwd else "unknown",
77
+ "session": session_id or "unknown",
78
+ "channel": channel_id or "default",
79
+ "user": user_id or "anonymous",
80
+ }
81
+
82
+ # bank_id is stored as-is server-side; HTTP path encoding is the client layer's job.
83
+ segments = [field_map.get(f, "unknown") for f in fields]
84
+ base_bank_id = "::".join(segments)
85
+
86
+ return f"{prefix}-{base_bank_id}" if prefix else base_bank_id
87
+
88
+
89
+ def ensure_bank_mission(client, bank_id: str, config: dict, debug_fn=None):
90
+ """Set bank mission on first use, skip if already set.
91
+
92
+ Port of: banksWithMissionSet Set tracking in index.js
93
+
94
+ Uses a state file to persist which banks have had their mission set
95
+ across ephemeral hook invocations.
96
+ """
97
+ mission = config.get("bankMission", "")
98
+ if not mission or not mission.strip():
99
+ return
100
+
101
+ # Check if we've already set mission for this bank
102
+ missions_set = read_state("bank_missions.json", {})
103
+ if bank_id in missions_set:
104
+ return
105
+
106
+ try:
107
+ retain_mission = config.get("retainMission")
108
+ client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10)
109
+ missions_set[bank_id] = True
110
+ # Cap tracked banks (mirrors Openclaw's MAX_TRACKED_BANK_CLIENTS)
111
+ if len(missions_set) > 10000:
112
+ keys = sorted(missions_set.keys())
113
+ for k in keys[: len(keys) // 2]:
114
+ del missions_set[k]
115
+ write_state("bank_missions.json", missions_set)
116
+ if debug_fn:
117
+ debug_fn(f"Set mission for bank: {bank_id}")
118
+ except Exception as e:
119
+ # Don't fail if mission set fails — bank might not exist yet,
120
+ # will be created on first retain (mirrors Openclaw behavior)
121
+ if debug_fn:
122
+ debug_fn(f"Could not set bank mission for {bank_id}: {e}")
@@ -0,0 +1,204 @@
1
+ """Hindsight REST API client.
2
+
3
+ Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the
4
+ Openclaw HindsightClient (client.js), adapted for Python stdlib.
5
+ """
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ DEFAULT_TIMEOUT = 15 # seconds
15
+ HEALTH_CHECK_RETRIES = 3
16
+ HEALTH_CHECK_DELAY = 2 # seconds
17
+
18
+
19
+ def _plugin_version() -> str:
20
+ """Read the plugin version from plugin.json (single source of truth)."""
21
+ manifest = Path(__file__).resolve().parents[2] / ".claude-plugin" / "plugin.json"
22
+ try:
23
+ return json.loads(manifest.read_text()).get("version", "0.0.0")
24
+ except (OSError, ValueError):
25
+ return "0.0.0"
26
+
27
+
28
+ # Sent on every request so self-hosted deployments behind Cloudflare (or any
29
+ # reverse proxy with UA-based bot filtering) don't block the stdlib default
30
+ # "Python-urllib/X.Y", which trips Cloudflare error 1010.
31
+ USER_AGENT = f"hindsight-claude-code/{_plugin_version()}"
32
+
33
+
34
+ def _validate_api_url(url: str) -> str:
35
+ """Validate and normalize the API URL. Reject non-HTTP schemes."""
36
+ parsed = urllib.parse.urlparse(url)
37
+ if parsed.scheme not in ("http", "https"):
38
+ raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
39
+ if not parsed.hostname:
40
+ raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
41
+ return url.rstrip("/")
42
+
43
+
44
+ class HindsightClient:
45
+ """HTTP client for the Hindsight API."""
46
+
47
+ def __init__(self, api_url: str, api_token: Optional[str] = None):
48
+ self.api_url = _validate_api_url(api_url)
49
+ self.api_token = api_token
50
+
51
+ def _headers(self) -> dict:
52
+ headers = {
53
+ "Content-Type": "application/json",
54
+ "User-Agent": USER_AGENT,
55
+ }
56
+ if self.api_token:
57
+ headers["Authorization"] = f"Bearer {self.api_token}"
58
+ return headers
59
+
60
+ def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
61
+ url = f"{self.api_url}{path}"
62
+ data = json.dumps(body).encode() if body else None
63
+ req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
64
+ try:
65
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
66
+ return json.loads(resp.read().decode())
67
+ except urllib.error.HTTPError as e:
68
+ body_text = ""
69
+ try:
70
+ body_text = e.read().decode()
71
+ except Exception:
72
+ pass
73
+ raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
74
+
75
+ def health_check(self, timeout: int = 5) -> bool:
76
+ """Check if the Hindsight server is reachable.
77
+
78
+ Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
79
+ with 2s delay between attempts.
80
+ """
81
+ import time
82
+
83
+ for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
84
+ try:
85
+ url = f"{self.api_url}/health"
86
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
87
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
88
+ if resp.status == 200:
89
+ return True
90
+ except Exception:
91
+ pass
92
+ if attempt < HEALTH_CHECK_RETRIES:
93
+ time.sleep(HEALTH_CHECK_DELAY)
94
+ return False
95
+
96
+ def recall(
97
+ self,
98
+ bank_id: str,
99
+ query: str,
100
+ max_tokens: int = 1024,
101
+ budget: str = "mid",
102
+ types: Optional[list] = None,
103
+ timeout: int = 10,
104
+ ) -> dict:
105
+ """Recall memories from a bank.
106
+
107
+ Returns the raw API response dict with 'results' list.
108
+ """
109
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
110
+ body = {
111
+ "query": query,
112
+ "max_tokens": max_tokens,
113
+ }
114
+ if budget:
115
+ body["budget"] = budget
116
+ if types:
117
+ body["types"] = types
118
+ return self._request("POST", path, body, timeout=timeout)
119
+
120
+ def retain(
121
+ self,
122
+ bank_id: str,
123
+ content: str,
124
+ document_id: str = "conversation",
125
+ context: Optional[str] = None,
126
+ metadata: Optional[dict] = None,
127
+ tags: Optional[list] = None,
128
+ timeout: int = 15,
129
+ ) -> dict:
130
+ """Retain content into a bank's memory.
131
+
132
+ Posts with async=true so the server processes in the background.
133
+ The context field helps Hindsight cluster memories by provenance
134
+ (e.g. "claude-code" vs manual retains).
135
+ """
136
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
137
+ item = {
138
+ "content": content,
139
+ "document_id": document_id,
140
+ "metadata": metadata or {},
141
+ }
142
+ if context:
143
+ item["context"] = context
144
+ if tags:
145
+ item["tags"] = tags
146
+ body = {
147
+ "items": [item],
148
+ "async": True,
149
+ }
150
+ return self._request("POST", path, body, timeout=timeout)
151
+
152
+ def list_directives(
153
+ self,
154
+ bank_id: str,
155
+ active_only: bool = True,
156
+ tags: Optional[list] = None,
157
+ timeout: int = 5,
158
+ ) -> dict:
159
+ """List active directives for a bank.
160
+
161
+ Switchroom-local: this method was missing in the vendored
162
+ HindsightClient even though `lib/directives.py` (also
163
+ switchroom-local, the workaround for upstream
164
+ vectorize-io/hindsight#1269) calls it on every recall hook.
165
+ Without this method the directives fetch always raised
166
+ AttributeError → silently caught at directives.py:47-49 → no
167
+ directives ever surfaced in the recall block.
168
+
169
+ The upstream REST endpoint is `GET
170
+ /v1/default/banks/{bank_id}/directives` with `active_only` and
171
+ optional `tags` query params (see upstream
172
+ `hindsight-clients/python/hindsight_client_api/api/directives_api.py`).
173
+
174
+ Returns the raw response dict, expected to have an `items` list
175
+ of directives where each item has at least `id`, `name`,
176
+ `content`, `priority`, `is_active`, `tags`. Caller (directives.py)
177
+ already defends against missing/malformed entries.
178
+ """
179
+ params = {}
180
+ if active_only:
181
+ # Server expects lowercase string per the OpenAPI spec.
182
+ params["active_only"] = "true"
183
+ if tags:
184
+ # Hindsight accepts repeated `tags=` query params for
185
+ # multi-tag filtering. urlencode with doseq=True handles it.
186
+ params["tags"] = tags
187
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/directives"
188
+ if params:
189
+ path = f"{path}?{urllib.parse.urlencode(params, doseq=True)}"
190
+ return self._request("GET", path, timeout=timeout)
191
+
192
+ def set_bank_mission(
193
+ self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
194
+ ) -> dict:
195
+ """Set the mission/persona for a bank.
196
+
197
+ Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
198
+ The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19.
199
+ """
200
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
201
+ updates = {"reflect_mission": mission}
202
+ if retain_mission:
203
+ updates["retain_mission"] = retain_mission
204
+ return self._request("PATCH", path, {"updates": updates}, timeout=timeout)