hexcli 2.8.0__py3-none-any.whl

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.
hexcli/distribution.py ADDED
@@ -0,0 +1,237 @@
1
+ """hexcli.distribution — Self-update and uninstall helpers for Hex CLI.
2
+
3
+ Called from hexcli.agent via --update and --uninstall flags.
4
+ All logic is stdlib only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ # The npurun binary comes from the fork's own releases, not Hex CLI's.
16
+ _GITHUB_API = "https://api.github.com/repos/NathanL15/npurun/releases/latest"
17
+ _NPURUN_ASSET = "npurun-arm64.exe"
18
+ _SHORTCUT_NAME = "Hex CLI.lnk"
19
+ _START_MENU = Path.home() / "AppData" / "Roaming" / "Microsoft" / "Windows" / "Start Menu" / "Programs"
20
+ # The Windows Terminal profile install.ps1 registers (a fragment, so it never
21
+ # edits the user's settings.json). Removed on uninstall.
22
+ _WT_FRAGMENT = Path.home() / "AppData" / "Local" / "Microsoft" / "Windows Terminal" / "Fragments" / "Hex CLI" / "hexcli.json"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _print(msg: str) -> None:
30
+ print(f" {msg}", flush=True)
31
+
32
+
33
+ def _fetch_latest_release() -> dict[str, Any]:
34
+ req = urllib.request.Request(
35
+ _GITHUB_API,
36
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "hexcli"},
37
+ )
38
+ with urllib.request.urlopen(req, timeout=15) as resp:
39
+ return json.loads(resp.read().decode())
40
+
41
+
42
+ def _find_asset_url(release: dict[str, Any], asset_name: str) -> str | None:
43
+ for asset in release.get("assets", []):
44
+ if asset.get("name") == asset_name:
45
+ return str(asset["browser_download_url"])
46
+ return None
47
+
48
+
49
+ def _download(url: str, dest: Path) -> None:
50
+ req = urllib.request.Request(url, headers={"User-Agent": "hexcli"})
51
+ with urllib.request.urlopen(req, timeout=120) as resp, dest.open("wb") as fh:
52
+ shutil.copyfileobj(resp, fh)
53
+
54
+
55
+ def _launcher():
56
+ try:
57
+ from . import launcher
58
+ return launcher
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def _parse_version(tag: str) -> tuple[int, ...]:
64
+ import re
65
+ m = re.search(r"(\d+)\.(\d+)\.(\d+)", tag or "")
66
+ return tuple(int(x) for x in m.groups()) if m else ()
67
+
68
+
69
+ def _git_pull(install_dir: Path) -> bool:
70
+ """Return True if git pull succeeds, False on any failure."""
71
+ git = shutil.which("git")
72
+ if not git:
73
+ _print("git not found; source update skipped.")
74
+ return False
75
+ try:
76
+ result = subprocess.run(
77
+ [git, "pull", "--ff-only"],
78
+ cwd=str(install_dir),
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=120,
82
+ )
83
+ except subprocess.TimeoutExpired:
84
+ _print("git pull timed out; source update skipped.")
85
+ return False
86
+ if result.returncode == 0:
87
+ _print(result.stdout.strip() or "Already up to date.")
88
+ return True
89
+ _print(f"git pull failed: {result.stderr.strip()}")
90
+ return False
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Public entry points
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def update(install_dir: Path | None = None) -> int:
98
+ """Pull the latest source and refresh the npurun binary.
99
+
100
+ Returns an exit code (0 = success, 1 = partial failure, 2 = hard failure).
101
+ """
102
+ from . import paths
103
+ print("\n Hex CLI update\n")
104
+
105
+ # 1. Update the Python source: git in a checkout, pip for an installed package.
106
+ install_dir = install_dir or paths.CHECKOUT_DIR
107
+ if install_dir is not None and (install_dir / ".git").exists():
108
+ _print("Pulling source...")
109
+ _git_pull(install_dir)
110
+ else:
111
+ _print("Installed as a package; update the code with:\n"
112
+ " pip install --upgrade git+https://github.com/NathanL15/Hex-CLI")
113
+
114
+ # 2. Fetch the fork's latest release metadata from GitHub.
115
+ _print("Checking the latest npurun release...")
116
+ try:
117
+ release = _fetch_latest_release()
118
+ except Exception as exc:
119
+ _print(f"GitHub API error: {exc}")
120
+ _print("Source updated. npurun not checked: no network.")
121
+ return 1
122
+
123
+ tag = release.get("tag_name", "unknown")
124
+ _print(f"Latest npurun: {tag}")
125
+
126
+ # Nothing to do when the binary the launcher will run is already there.
127
+ ln = _launcher()
128
+ if ln is not None:
129
+ have = ln.find_npurun_exe()
130
+ have_version = ln._npurun_version(have) if have else ()
131
+ want = _parse_version(tag)
132
+ if have_version and want and tuple(have_version) >= want:
133
+ _print(f"npurun {ln.version_str(have_version)} at {have} is current.")
134
+ return 0
135
+
136
+ # 3. Download the npurun binary if a matching asset exists.
137
+ url = _find_asset_url(release, _NPURUN_ASSET)
138
+ if not url:
139
+ _print(f"{tag} has no {_NPURUN_ASSET} asset; binary update skipped.")
140
+ return 0
141
+
142
+ bin_dir = paths.npurun_bin_dir()
143
+ bin_dir.mkdir(parents=True, exist_ok=True)
144
+ existing = bin_dir / _NPURUN_ASSET
145
+ dest_tmp = bin_dir / f"{_NPURUN_ASSET}.tmp"
146
+ _print(f"Downloading {_NPURUN_ASSET}...")
147
+ try:
148
+ _download(url, dest_tmp)
149
+ dest_tmp.replace(existing)
150
+ except Exception as exc:
151
+ dest_tmp.unlink(missing_ok=True)
152
+ _print(f"Download failed: {exc}")
153
+ return 1
154
+
155
+ _print(f"npurun updated: {existing}")
156
+ _print("Update complete.")
157
+ return 0
158
+
159
+
160
+ def uninstall(install_dir: Path | None = None) -> int:
161
+ """Remove the Start Menu shortcut and optionally purge user data."""
162
+ from . import paths
163
+ print("\n Hex CLI uninstall\n")
164
+
165
+ # 1. Remove Start Menu shortcut.
166
+ shortcut = _START_MENU / _SHORTCUT_NAME
167
+ if shortcut.exists():
168
+ try:
169
+ shortcut.unlink()
170
+ _print(f"Removed shortcut: {shortcut}")
171
+ except OSError as exc:
172
+ _print(f"Could not remove shortcut: {exc}")
173
+ else:
174
+ _print("Start Menu shortcut not found.")
175
+ if _WT_FRAGMENT.exists():
176
+ try:
177
+ _WT_FRAGMENT.unlink()
178
+ try:
179
+ _WT_FRAGMENT.parent.rmdir() # the fragment folder, if nothing else is in it
180
+ except OSError:
181
+ pass
182
+ _print("Removed Windows Terminal profile: Hex CLI")
183
+ except OSError as exc:
184
+ _print(f"Could not remove the Windows Terminal profile: {exc}")
185
+
186
+ # 2. Ask whether to purge per-user data (~/.shellai: sessions, memory,
187
+ # chat logs, the runtime config, the embedding model).
188
+ shellai_dir = paths.data_dir(create=False)
189
+ if shellai_dir.exists():
190
+ try:
191
+ answer = input(
192
+ f"\n Remove {shellai_dir} with its sessions and memory? [y/N] "
193
+ ).strip().lower()
194
+ except (EOFError, KeyboardInterrupt):
195
+ answer = "n"
196
+ if answer in ("y", "yes"):
197
+ try:
198
+ shutil.rmtree(shellai_dir)
199
+ _print(f"Removed: {shellai_dir}")
200
+ except OSError as exc:
201
+ _print(f"Could not remove {shellai_dir}: {exc}")
202
+ else:
203
+ _print(f"{shellai_dir} kept.")
204
+
205
+ # 3. The code itself.
206
+ checkout = install_dir or paths.CHECKOUT_DIR
207
+ _print("\nTo complete uninstall: pip uninstall hexcli")
208
+ if checkout is not None:
209
+ _print(f"and delete the checkout: Remove-Item -Recurse \"{checkout}\"")
210
+
211
+ return 0
212
+
213
+
214
+ def first_run_check(install_dir: Path | None = None) -> None:
215
+ """Print first-run setup hints when critical dependencies are missing.
216
+
217
+ Runs once per process on every `hexcli` invocation, but prints nothing
218
+ when everything looks healthy — zero noise for existing installs.
219
+ """
220
+ from . import paths
221
+ hints: list[str] = []
222
+
223
+ # npurun: the launcher's own search (source build, downloaded binary, PATH).
224
+ ln = _launcher()
225
+ found = ln.find_npurun_exe() if ln is not None else None
226
+ if found is None and not (shutil.which("npurun") or shutil.which("npurun.exe")):
227
+ hints.append(" npurun not found. Run: hexcli --update")
228
+
229
+ # ONNX embedding model for memory.
230
+ if not paths.embedding_model_path().exists():
231
+ hints.append(" Embedding model missing; memory is off. hexcli --doctor prints the download commands.")
232
+
233
+ if hints:
234
+ print("\n First-run setup", flush=True)
235
+ for h in hints:
236
+ print(h, flush=True)
237
+ print(flush=True)
hexcli/doctor.py ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.doctor — diagnose an installation and say exactly how to fix it.
3
+
4
+ The QAIRT SDK cannot be redistributed and the NPU bundles are multi-GB, so
5
+ this project can never be a one-click install. The honest response is to
6
+ diagnose perfectly: every check prints PASS/WARN/FAIL plus the exact command
7
+ that fixes it.
8
+
9
+ This exists because of a real failure: the ONNX embedding model was never
10
+ installed on the development machine, so semantic memory silently no-opped
11
+ for months (docs/V2_PLAN.md §14.1). `first_run_check` printed a hint that
12
+ scrolled past. Checks that only whisper are checks that don't work.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .ui import C, cprint
27
+
28
+ PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
29
+ _NPURUN_RELEASES = "https://github.com/NathanL15/npurun/releases"
30
+
31
+
32
+ @dataclass
33
+ class Check:
34
+ name: str
35
+ status: str
36
+ detail: str
37
+ fix: str = ""
38
+
39
+
40
+ def _mark(status: str) -> str:
41
+ return {
42
+ PASS: f"{C.BGREEN} ok {C.RESET}",
43
+ WARN: f"{C.BYELLOW} warn {C.RESET}",
44
+ FAIL: f"{C.BRED} fail {C.RESET}",
45
+ }[status]
46
+
47
+
48
+ def check_python() -> Check:
49
+ v = sys.version_info
50
+ if v < (3, 10):
51
+ return Check("Python", FAIL, f"{v.major}.{v.minor}; 3.10 or newer required",
52
+ "Install the ARM64 build of Python 3.11 or newer from python.org.")
53
+ return Check("Python", PASS, f"{v.major}.{v.minor}.{v.micro} ({sys.executable})")
54
+
55
+
56
+ def check_packages() -> list[Check]:
57
+ out: list[Check] = []
58
+ for mod, why, fix in (
59
+ ("numpy", "vector math for memory", "pip install numpy"),
60
+ ("onnxruntime", "embedding model runtime", "pip install onnxruntime"),
61
+ ):
62
+ try:
63
+ __import__(mod)
64
+ out.append(Check(mod, PASS, "importable"))
65
+ except ImportError:
66
+ out.append(Check(mod, WARN, f"missing; {why} off", fix))
67
+ return out
68
+
69
+
70
+ def check_embedding_model(app_dir: Path | None = None) -> list[Check]:
71
+ """The silent-failure case that motivated this whole command."""
72
+ from . import paths
73
+ if app_dir is not None: # an explicit location (tests, a custom layout)
74
+ model = app_dir / "onnx" / paths.EMBEDDING_MODEL_FILE
75
+ tok = app_dir / "onnx" / paths.EMBEDDING_TOKENIZER_FILE
76
+ dest = app_dir / "onnx"
77
+ else:
78
+ model = paths.embedding_model_path()
79
+ tok = paths.embedding_tokenizer_path()
80
+ dest = paths.embedding_dir(for_download=True)
81
+ fix = (
82
+ f"Download both into {dest} :\n"
83
+ f" curl -L -o \"{dest / paths.EMBEDDING_MODEL_FILE}\" "
84
+ f"{paths.EMBEDDING_BASE_URL}/onnx/{paths.EMBEDDING_MODEL_FILE}\n"
85
+ f" curl -L -o \"{dest / paths.EMBEDDING_TOKENIZER_FILE}\" "
86
+ f"{paths.EMBEDDING_BASE_URL}/{paths.EMBEDDING_TOKENIZER_FILE}"
87
+ )
88
+ checks = []
89
+ if model.exists() and model.stat().st_size > 1_000_000:
90
+ checks.append(Check("embedding model", PASS, f"{model.stat().st_size // 1_000_000} MB"))
91
+ else:
92
+ checks.append(Check("embedding model", WARN, "missing; memory is off", fix))
93
+ if tok.exists():
94
+ checks.append(Check("embedding tokenizer", PASS, "present"))
95
+ else:
96
+ checks.append(Check("embedding tokenizer", WARN, "missing; memory is off", fix))
97
+ return checks
98
+
99
+
100
+ def _launcher():
101
+ try:
102
+ from . import launcher
103
+ return launcher
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def check_qairt() -> list[Check]:
109
+ sdk = Path(os.environ.get("QNN_SDK_ROOT", r"C:\Qualcomm\AIStack\QAIRT_2.47.0"))
110
+ ln = _launcher()
111
+ if ln is not None:
112
+ sdk = ln.QNN_SDK_ROOT # what the launcher actually exports (newest valid; Rewind SDK first)
113
+ checks: list[Check] = []
114
+ if not sdk.exists():
115
+ checks.append(Check("QAIRT SDK", FAIL, f"not found at {sdk}",
116
+ "Download it from the Qualcomm developer portal and set QNN_SDK_ROOT."))
117
+ return checks
118
+ checks.append(Check("QAIRT SDK", PASS, str(sdk)))
119
+ lib = sdk / "lib" / "aarch64-windows-msvc"
120
+ checks.append(Check("QAIRT libs", PASS if lib.exists() else FAIL,
121
+ str(lib) if lib.exists() else "missing aarch64-windows-msvc libs",
122
+ "" if lib.exists() else "Re-run the QAIRT installer."))
123
+ adsp = Path(os.environ.get("ADSP_LIBRARY_PATH", ""))
124
+ wanted = sdk / "lib" / "hexagon-v73" / "unsigned"
125
+ if not adsp.exists():
126
+ checks.append(Check("ADSP_LIBRARY_PATH", WARN,
127
+ "unset; npurun crashes without it",
128
+ rf'setx ADSP_LIBRARY_PATH "{wanted}"'))
129
+ elif adsp.resolve() != wanted.resolve() and wanted.exists():
130
+ # Pinned to another SDK's libs (a setx from before 2.50 was installed).
131
+ # The launcher sets the right value for the server it starts; a
132
+ # server started by hand would use this one.
133
+ checks.append(Check("ADSP_LIBRARY_PATH", WARN,
134
+ f"{adsp} is not the {sdk.name} SDK's; the launcher overrides it",
135
+ rf'setx ADSP_LIBRARY_PATH "{wanted}"'))
136
+ else:
137
+ checks.append(Check("ADSP_LIBRARY_PATH", PASS, str(adsp)))
138
+ return checks
139
+
140
+
141
+ def check_npurun() -> list[Check]:
142
+ ln = _launcher()
143
+ # The launcher's candidate order, so the doctor diagnoses the binary
144
+ # that will actually run.
145
+ path = ln.find_npurun_exe() if ln is not None else None
146
+ if path is None:
147
+ exe = shutil.which("npurun") or shutil.which("npurun.exe")
148
+ local = Path.home() / ".cargo" / "bin" / "npurun.exe"
149
+ path = Path(exe) if exe else (local if local.exists() else None)
150
+ if path is None:
151
+ return [Check("npurun", FAIL, "binary not found",
152
+ f"hexcli --update\n{_NPURUN_RELEASES}")]
153
+ checks = [Check("npurun", PASS, str(path))]
154
+ if ln is not None:
155
+ version = ln._npurun_version(path)
156
+ ver = ".".join(str(n) for n in version) or "unknown"
157
+ need = ".".join(str(n) for n in ln.REQUIRED_NPURUN)
158
+ if ln.npurun_outdated(version=version) is not None:
159
+ checks.append(Check("npurun version", FAIL,
160
+ f"{ver}; {need} required",
161
+ "hexcli --update"))
162
+ else:
163
+ checks.append(Check("npurun version", PASS, ver))
164
+ if ln.REWIND_ROOT is not None:
165
+ checks.append(Check("KV prefix reuse", PASS,
166
+ f"on: npurun {ver}, {ln.REWIND_ROOT.name}"))
167
+ else:
168
+ checks.append(Check("KV prefix reuse", WARN,
169
+ f"off: npurun {ver}, {ln.QNN_SDK_ROOT.name}",
170
+ r"Install QAIRT 2.50 or newer under C:\Qualcomm\AIStack."))
171
+ try:
172
+ r = subprocess.run([str(path), "list"], capture_output=True, text=True, timeout=20)
173
+ models = [ln.split()[0] for ln in r.stdout.splitlines() if ln.strip()]
174
+ if models:
175
+ checks.append(Check("model bundles", PASS, ", ".join(models)))
176
+ else:
177
+ checks.append(Check("model bundles", FAIL, "none downloaded",
178
+ "npurun pull qwen3-4b-instruct-2507"))
179
+ except Exception as exc:
180
+ checks.append(Check("model bundles", WARN, f"could not list: {exc.__class__.__name__}"))
181
+ return checks
182
+
183
+
184
+ def check_server(config: dict[str, Any]) -> Check:
185
+ base = str(config.get("openai_compatible", {}).get("base_url", ""))
186
+ ln = _launcher()
187
+ if ln is not None and "_npurun_model" not in config:
188
+ # Run outside the launcher (the installer, a bare --doctor): the server
189
+ # that matters is the one the launcher starts, on its port, not the
190
+ # example config's.
191
+ base = f"http://127.0.0.1:{ln.NPURUN_PORT}"
192
+ if not base:
193
+ return Check("model server", WARN, "no openai_compatible.base_url configured")
194
+ host = base.split("//")[-1].split("/")[0]
195
+ try:
196
+ with urllib.request.urlopen(f"http://{host}/healthz", timeout=3) as r:
197
+ if r.status == 200:
198
+ detail = f"healthy at {host}"
199
+ try:
200
+ with urllib.request.urlopen(f"http://{host}/v1/models", timeout=3) as m:
201
+ models = json.loads(m.read().decode("utf-8")).get("data") or []
202
+ first = models[0] if models else {}
203
+ if first.get("input_token_budget"):
204
+ detail += (f"; input budget {first['input_token_budget']} of "
205
+ f"{first.get('context_size', '?')} tokens")
206
+ except Exception:
207
+ pass
208
+ return Check("model server", PASS, detail)
209
+ except Exception:
210
+ pass
211
+ return Check("model server", WARN, f"not responding at {host}",
212
+ "Launch Hex CLI, or run python launcher.py from the repo.")
213
+
214
+
215
+ def check_ruff() -> Check:
216
+ if shutil.which("ruff"):
217
+ return Check("ruff (optional)", PASS, "on PATH")
218
+ return Check("ruff (optional)", WARN, "not found; lint_code is off",
219
+ "pip install ruff")
220
+
221
+
222
+ def check_workspace() -> list[Check]:
223
+ cwd = Path.cwd()
224
+ checks = [Check("working directory", PASS, str(cwd))]
225
+ for name in ("AGENTS.md", ".shellai/AGENTS.md", "HEXCLI.md"):
226
+ if (cwd / name).is_file():
227
+ checks.append(Check("project instructions", PASS, f"{name} found"))
228
+ break
229
+ else:
230
+ checks.append(Check("project instructions", WARN,
231
+ "no AGENTS.md",
232
+ "Create AGENTS.md with a few lines about this project."))
233
+ return checks
234
+
235
+
236
+ def run_doctor(config: dict[str, Any], app_dir: Path | None = None) -> int:
237
+ """Print the full report. Returns 1 if any check FAILed."""
238
+ checks: list[Check] = [check_python()]
239
+ checks += check_packages()
240
+ checks += check_embedding_model()
241
+ checks += check_qairt()
242
+ checks += check_npurun()
243
+ checks.append(check_server(config))
244
+ checks.append(check_ruff())
245
+ checks += check_workspace()
246
+
247
+ print()
248
+ cprint(" Install check", C.BOLD)
249
+ print()
250
+ for c in checks:
251
+ print(f" {_mark(c.status)} {c.name:<22} {c.detail}")
252
+ if c.fix and c.status != PASS:
253
+ for line in c.fix.splitlines():
254
+ cprint(f" {line}", C.DIM)
255
+ fails = sum(1 for c in checks if c.status == FAIL)
256
+ warns = sum(1 for c in checks if c.status == WARN)
257
+ print()
258
+ if fails:
259
+ cprint(f" {fails} failed, {warns} warnings.", C.BRED)
260
+ elif warns:
261
+ cprint(f" {warns} warnings.", C.BYELLOW)
262
+ else:
263
+ cprint(" All checks passed.", C.BGREEN)
264
+ print()
265
+ return 1 if fails else 0
hexcli/escalate.py ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.escalate — Cloud escalation for stuck agent turns.
3
+
4
+ Triggered by error-loop detection. Sends the last 6 turns and the
5
+ failing tool sequence to Anthropic's API after a redaction pass.
6
+
7
+ Transport: stdlib urllib.request — no SDK dependency.
8
+ Key source: ANTHROPIC_API_KEY env var, then config["anthropic_api_key"].
9
+ Default model: claude-haiku-4-5-20251001 (override via config["escalation_model"]).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import json
15
+ import os
16
+ import re
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ _API_URL = "https://api.anthropic.com/v1/messages"
23
+ _ANTHROPIC_VERSION = "2023-06-01"
24
+ DEFAULT_ESCALATION_MODEL = "claude-haiku-4-5-20251001"
25
+ _MAX_FILE_CONTENT = 200 # chars — truncate long strings before sending
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Sensitive path prefixes — content following these in payload text is redacted.
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _SENSITIVE_PATH_PREFIXES: list[str] = [
32
+ str(Path.home() / ".ssh"),
33
+ str(Path.home() / ".aws"),
34
+ str(Path.home() / ".gnupg"),
35
+ str(Path.home() / ".gpg"),
36
+ # Generic tilde forms so tests can use them directly.
37
+ "~/.ssh",
38
+ "~/.aws",
39
+ "~/.gnupg",
40
+ "~/.gpg",
41
+ ]
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Redaction patterns: (compiled_pattern, replacement) applied in order.
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
48
+ # Anthropic / OpenAI API keys
49
+ (re.compile(r"sk-[A-Za-z0-9\-_]{10,}"), "sk-***"),
50
+ # Generic Bearer tokens
51
+ (re.compile(r"Bearer\s+[A-Za-z0-9._\-]{8,}"), "Bearer ***"),
52
+ # password= in query strings / config
53
+ (re.compile(r"password=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "password=***"),
54
+ # api_key= and api-key=
55
+ (re.compile(r"api[_\-]key=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "api_key=***"),
56
+ # token= (word-boundary to avoid "multipart" style false positives)
57
+ (re.compile(r"\btoken=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "token=***"),
58
+ # Connection strings
59
+ (
60
+ re.compile(
61
+ r"(postgresql|postgres|mongodb|mysql|redis|amqp|rabbitmq)://[^\s'\"\r\n<>{}[\]]{1,300}",
62
+ re.IGNORECASE,
63
+ ),
64
+ r"\1://***",
65
+ ),
66
+ ]
67
+
68
+
69
+ def redact_text(text: str) -> str:
70
+ """Apply all redaction rules to a string, in-place (returns new string)."""
71
+ # Sensitive path content: if a sensitive path prefix appears, redact the
72
+ # following _MAX_FILE_CONTENT chars (which is likely file content).
73
+ for prefix in _SENSITIVE_PATH_PREFIXES:
74
+ idx = text.lower().find(prefix.lower())
75
+ while idx != -1:
76
+ end = min(idx + len(prefix) + _MAX_FILE_CONTENT, len(text))
77
+ text = text[:idx] + "[REDACTED:sensitive-path]" + text[end:]
78
+ idx = text.lower().find(prefix.lower())
79
+
80
+ for pattern, replacement in _REDACT_PATTERNS:
81
+ text = pattern.sub(replacement, text)
82
+ return text
83
+
84
+
85
+ def _redact_value(v: Any) -> Any:
86
+ """Recursively redact strings in a JSON-like structure."""
87
+ if isinstance(v, str):
88
+ if len(v) > _MAX_FILE_CONTENT * 2:
89
+ v = v[: _MAX_FILE_CONTENT] + "...[truncated]"
90
+ return redact_text(v)
91
+ if isinstance(v, dict):
92
+ return {k: _redact_value(val) for k, val in v.items()}
93
+ if isinstance(v, list):
94
+ return [_redact_value(item) for item in v]
95
+ return v
96
+
97
+
98
+ def redact_payload(turns: list[dict[str, Any]]) -> list[dict[str, Any]]:
99
+ """Deep-copy and redact all strings in a list of message dicts."""
100
+ return [_redact_value(copy.deepcopy(t)) for t in turns]
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # API key resolution
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def get_api_key(config: dict[str, Any]) -> str | None:
108
+ """Return the Anthropic API key from env or config; None if absent."""
109
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
110
+ if not key:
111
+ key = str(config.get("anthropic_api_key", "") or "").strip()
112
+ return key or None
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Transport
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def _call_api(api_key: str, model: str, messages: list[dict[str, Any]]) -> str:
120
+ payload = json.dumps({
121
+ "model": model,
122
+ "max_tokens": 1024,
123
+ "messages": messages,
124
+ }).encode()
125
+ req = urllib.request.Request(
126
+ _API_URL,
127
+ data=payload,
128
+ headers={
129
+ "Content-Type": "application/json",
130
+ "x-api-key": api_key,
131
+ "anthropic-version": _ANTHROPIC_VERSION,
132
+ },
133
+ )
134
+ with urllib.request.urlopen(req, timeout=30) as resp:
135
+ data = json.loads(resp.read().decode())
136
+ content = data.get("content", [])
137
+ if content and isinstance(content[0], dict):
138
+ return str(content[0].get("text", "")).strip()
139
+ return str(data)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Main escalation entry point
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def escalate(
147
+ config: dict[str, Any],
148
+ turns: list[dict[str, Any]],
149
+ tool_seq: list[str],
150
+ ) -> str:
151
+ """Send redacted context to Claude cloud and return the suggestion.
152
+
153
+ Returns a user-visible message — either the LLM's suggestion or an
154
+ explanation of why escalation is unavailable.
155
+ """
156
+ api_key = get_api_key(config)
157
+ if not api_key:
158
+ return "(set ANTHROPIC_API_KEY to enable cloud escalation)"
159
+
160
+ model = str(config.get("escalation_model", DEFAULT_ESCALATION_MODEL)).strip()
161
+
162
+ # Build the escalation prompt.
163
+ parts: list[str] = [
164
+ "The local agent is stuck in a repeated error loop.\n",
165
+ ]
166
+ if tool_seq:
167
+ parts.append(f"Failing tool sequence: {', '.join(tool_seq)}\n")
168
+ if turns:
169
+ parts.append("\nLast session turns:\n")
170
+ for t in turns[-6:]:
171
+ role = t.get("role", "?")
172
+ content = str(t.get("content", ""))[:500]
173
+ parts.append(f"[{role}]: {content}\n")
174
+ parts.append(
175
+ "\nPlease provide a concise suggestion on how to resolve this loop "
176
+ "and unblock the agent."
177
+ )
178
+
179
+ prompt = redact_text("".join(parts))
180
+ messages = [{"role": "user", "content": prompt}]
181
+
182
+ try:
183
+ return _call_api(api_key, model, messages)
184
+ except urllib.error.HTTPError as exc:
185
+ body = ""
186
+ try:
187
+ body = exc.read().decode()[:200]
188
+ except Exception:
189
+ pass
190
+ return f"Cloud escalation failed (HTTP {exc.code}): {body}"
191
+ except Exception as exc:
192
+ return f"Cloud escalation failed: {exc}"