chopratejas commited on
Commit
aa444bb
·
2 Parent(s): 069743118d80ca

Merge remote-tracking branch 'origin/main' into rust-rewrite

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .git-blame-ignore-revs +10 -0
  2. .gitattributes +1 -0
  3. .github/actions/headroom-e2e-setup/action.yml +68 -0
  4. .github/workflows/init-native-e2e.yml +132 -0
  5. CHANGELOG.md +50 -0
  6. README.md +1 -0
  7. docs/package-lock.json +1453 -153
  8. docs/package.json +2 -2
  9. e2e/__init__.py +7 -0
  10. e2e/_lib/__init__.py +35 -0
  11. e2e/_lib/assertions.py +43 -0
  12. e2e/_lib/harness.py +336 -0
  13. e2e/_lib/make_shim.ps1 +24 -0
  14. e2e/_lib/make_shim.sh +28 -0
  15. e2e/_lib/path_env.py +54 -0
  16. e2e/_lib/paths.py +47 -0
  17. e2e/_lib/shims.py +96 -0
  18. e2e/init/Dockerfile +6 -1
  19. e2e/init/run.py +336 -236
  20. headroom/cli/init.py +817 -679
  21. headroom/cli/tools.py +5 -1
  22. headroom/cli/wrap.py +0 -0
  23. headroom/compress.py +347 -347
  24. headroom/copilot_auth.py +444 -444
  25. headroom/install/health.py +28 -28
  26. headroom/install/providers.py +174 -174
  27. headroom/install/runtime.py +279 -275
  28. headroom/install/supervisors.py +10 -6
  29. headroom/learn/analyzer.py +65 -0
  30. headroom/learn/writer.py +11 -0
  31. headroom/memory/adapters/embedders.py +60 -21
  32. headroom/memory/adapters/sqlite_vector.py +243 -65
  33. headroom/memory/mcp_server.py +9 -7
  34. headroom/memory/traffic_learner.py +247 -16
  35. headroom/providers/aider/install.py +12 -12
  36. headroom/providers/claude/install.py +63 -63
  37. headroom/providers/codex/install.py +68 -68
  38. headroom/providers/copilot/install.py +25 -25
  39. headroom/providers/cursor/install.py +15 -15
  40. headroom/providers/install_registry.py +86 -86
  41. headroom/providers/openclaw/install.py +50 -50
  42. headroom/proxy/handlers/anthropic.py +7 -0
  43. headroom/proxy/handlers/openai.py +0 -0
  44. headroom/proxy/handlers/streaming.py +4 -1
  45. headroom/proxy/helpers.py +84 -0
  46. headroom/proxy/models.py +5 -0
  47. headroom/proxy/server.py +0 -0
  48. headroom/release_version.py +310 -310
  49. headroom/subscription/__init__.py +72 -72
  50. headroom/subscription/base.py +230 -230
.git-blame-ignore-revs ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Revisions listed here are skipped by `git blame --ignore-revs-file`
2
+ # and by GitHub's blame UI. Use for mechanical, repo-wide changes that
3
+ # touch every line of a file but don't change semantics (formatter runs,
4
+ # line-ending normalization, bulk renames applied by codemod, etc.).
5
+ #
6
+ # Configure your local git to auto-pick this up:
7
+ # git config blame.ignoreRevsFile .git-blame-ignore-revs
8
+
9
+ # chore: renormalize line endings to LF
10
+ efd2ac1ca4d88d8f5990259c98b673c603902896
.gitattributes CHANGED
@@ -1 +1,2 @@
 
1
  *.sh text eol=lf
 
1
+ *.py text eol=lf
2
  *.sh text eol=lf
.github/actions/headroom-e2e-setup/action.yml ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Headroom e2e setup
2
+ description: >-
3
+ Checkout-agnostic setup shared by native e2e workflows (init, install, wrap).
4
+ Installs Python, installs headroom in editable mode, and (optionally) drops
5
+ a noop shim onto PATH so ``headroom init -g <target>`` can detect a tool
6
+ that isn't actually installed on the runner.
7
+ inputs:
8
+ python-version:
9
+ description: Python version to install
10
+ required: false
11
+ default: "3.11"
12
+ shim-target:
13
+ description: >-
14
+ Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave
15
+ empty to skip shim creation.
16
+ required: false
17
+ default: ""
18
+ outputs:
19
+ shim-dir:
20
+ description: Absolute path to the directory containing the dropped shim
21
+ value: ${{ steps.shim.outputs.shim-dir }}
22
+ runs:
23
+ using: composite
24
+ steps:
25
+ - name: Set up Python ${{ inputs.python-version }}
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ inputs.python-version }}
29
+
30
+ - name: Install headroom (editable, with proxy extras)
31
+ shell: bash
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ # ``headroom/cli/__init__.py`` eagerly imports ``proxy.server`` (via
35
+ # ``cli/proxy.py``), which requires ``fastapi`` even for ``init``.
36
+ # Install with the ``[proxy]`` extras to match the Docker e2e image.
37
+ pip install -e ".[proxy]"
38
+
39
+ - name: Drop shim (POSIX)
40
+ if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }}
41
+ id: shim-posix
42
+ shell: bash
43
+ run: |
44
+ shim_dir="${RUNNER_TEMP}/headroom-e2e-shims"
45
+ bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir"
46
+ echo "$shim_dir" >> "$GITHUB_PATH"
47
+ echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT"
48
+
49
+ - name: Drop shim (Windows)
50
+ if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }}
51
+ id: shim-windows
52
+ shell: pwsh
53
+ run: |
54
+ $shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims"
55
+ & pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir
56
+ Add-Content -Path $env:GITHUB_PATH -Value $shimDir
57
+ "shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
58
+
59
+ - name: Export shim dir to job output
60
+ if: ${{ inputs.shim-target != '' }}
61
+ id: shim
62
+ shell: bash
63
+ run: |
64
+ if [ "${{ runner.os }}" = "Windows" ]; then
65
+ echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
66
+ else
67
+ echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
68
+ fi
.github/workflows/init-native-e2e.yml ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Init Native E2E
2
+
3
+ # Cross-platform (linux / macos / windows) smoke tests for the per-subcommand
4
+ # ``headroom init -g <target>`` flows. Each matrix cell drops a noop shim for
5
+ # the target agent onto PATH and asserts ``headroom init -g <target>``
6
+ # succeeds, writes the expected settings file, and (for claude/codex) places
7
+ # hooks in the right place.
8
+ #
9
+ # Deliberately scoped to pull_request + push-to-main + workflow_dispatch to
10
+ # avoid bloating CI minutes on every push to every feature branch. The Docker
11
+ # init-e2e.yml still runs on every PR and provides the deeper functional
12
+ # coverage; this workflow exists to catch platform-specific bugs (Windows
13
+ # path separators, macos keychain prompts, PowerShell-vs-bash hook matchers)
14
+ # that the single-platform Docker suite can miss.
15
+ #
16
+ # Extending to other commands (``headroom install``, ``headroom wrap``) is
17
+ # expected to be a near-copy of this file. The shared composite action at
18
+ # ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so
19
+ # each per-command workflow only supplies its matrix and assertion steps.
20
+
21
+ on:
22
+ pull_request:
23
+ branches: [main]
24
+ paths:
25
+ - "headroom/cli/init.py"
26
+ - "headroom/install/**"
27
+ - "e2e/_lib/**"
28
+ - "e2e/init/**"
29
+ - ".github/actions/headroom-e2e-setup/**"
30
+ - ".github/workflows/init-native-e2e.yml"
31
+ push:
32
+ branches: [main]
33
+ workflow_dispatch:
34
+
35
+ jobs:
36
+ init-native:
37
+ runs-on: ${{ matrix.os }}
38
+ timeout-minutes: 15
39
+ strategy:
40
+ fail-fast: false
41
+ matrix:
42
+ os: [ubuntu-latest, macos-latest, windows-latest]
43
+ target: [claude, codex, copilot, openclaw]
44
+ exclude:
45
+ # openclaw delegates to ``headroom wrap openclaw`` which needs a
46
+ # running OpenClaw CLI; it can't be shimmed cheaply, so it's
47
+ # covered by the bundled Docker e2e instead.
48
+ - target: openclaw
49
+
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+
53
+ - name: Setup (shim=${{ matrix.target }})
54
+ uses: ./.github/actions/headroom-e2e-setup
55
+ with:
56
+ python-version: "3.11"
57
+ shim-target: ${{ matrix.target }}
58
+
59
+ - name: Verify shim is on PATH (POSIX)
60
+ if: runner.os != 'Windows'
61
+ shell: bash
62
+ run: |
63
+ which "${{ matrix.target }}"
64
+
65
+ - name: Verify shim is on PATH (Windows)
66
+ if: runner.os == 'Windows'
67
+ shell: pwsh
68
+ run: |
69
+ # On Windows the shim is ``<target>.cmd``; Get-Command resolves via
70
+ # PATHEXT (same as Python's ``shutil.which`` used by headroom init).
71
+ # Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh.
72
+ $cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop
73
+ Write-Output $cmd.Source
74
+
75
+ - name: Run headroom init -g ${{ matrix.target }}
76
+ shell: bash
77
+ run: |
78
+ set -euo pipefail
79
+ headroom init -g "${{ matrix.target }}"
80
+
81
+ - name: Assert settings file (POSIX)
82
+ if: runner.os != 'Windows'
83
+ shell: bash
84
+ run: |
85
+ set -euo pipefail
86
+ case "${{ matrix.target }}" in
87
+ claude)
88
+ test -f "$HOME/.claude/settings.json"
89
+ grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json"
90
+ ;;
91
+ codex)
92
+ test -f "$HOME/.codex/config.toml"
93
+ test -f "$HOME/.codex/hooks.json"
94
+ grep -q "headroom" "$HOME/.codex/config.toml"
95
+ ;;
96
+ copilot)
97
+ test -f "$HOME/.copilot/config.json"
98
+ grep -q "SessionStart" "$HOME/.copilot/config.json"
99
+ ;;
100
+ esac
101
+
102
+ - name: Assert settings file (Windows)
103
+ if: runner.os == 'Windows'
104
+ shell: pwsh
105
+ run: |
106
+ $ErrorActionPreference = "Stop"
107
+ $home_ = $env:USERPROFILE
108
+ switch ("${{ matrix.target }}") {
109
+ "claude" {
110
+ $p = Join-Path $home_ ".claude\settings.json"
111
+ if (-not (Test-Path $p)) { throw "Missing $p" }
112
+ if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) {
113
+ throw "settings.json missing ANTHROPIC_BASE_URL"
114
+ }
115
+ }
116
+ "codex" {
117
+ $c = Join-Path $home_ ".codex\config.toml"
118
+ $h = Join-Path $home_ ".codex\hooks.json"
119
+ if (-not (Test-Path $c)) { throw "Missing $c" }
120
+ if (-not (Test-Path $h)) { throw "Missing $h" }
121
+ if (-not ((Get-Content $c -Raw) -match "headroom")) {
122
+ throw "config.toml missing headroom provider"
123
+ }
124
+ }
125
+ "copilot" {
126
+ $p = Join-Path $home_ ".copilot\config.json"
127
+ if (-not (Test-Path $p)) { throw "Missing $p" }
128
+ if (-not ((Get-Content $p -Raw) -match "SessionStart")) {
129
+ throw "copilot config missing SessionStart hooks"
130
+ }
131
+ }
132
+ }
CHANGELOG.md CHANGED
@@ -8,14 +8,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
  ## [Unreleased]
9
 
10
  ### Fixed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  - **`headroom learn` no longer clobbers prior recommendations on re-run** —
12
  the marker block in `CLAUDE.md` / `MEMORY.md` is now merged with the
13
  prior block instead of wholesale-replaced. Sections re-surfaced by the
14
  new run win; sections not re-surfaced are carried forward so learnings
15
  accumulate across runs instead of disappearing. To fully rebuild the
16
  block, delete it manually and re-run. (#231)
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ### Added
 
 
 
 
 
 
 
 
 
 
 
19
  - **Live flush of traffic-learned patterns to CLAUDE.md / MEMORY.md** — the
20
  `TrafficLearner` now writes to agent-native context files continuously
21
  during proxy operation, not just at shutdown. A new dirty-flag debounced
 
8
  ## [Unreleased]
9
 
10
  ### Fixed
11
+ - **`Learned: error recovery` section in MEMORY.md no longer bloats with
12
+ stale or contradictory entries.** The dedup key for error-recovery
13
+ patterns was the literal rendered bullet text, so near-duplicate
14
+ recoveries (same intent, different `| tail -N` count, same error path
15
+ guessed against different successors) each created a new row. There was
16
+ also no TTL or re-validation, so wrong-today entries lingered. Fixed by:
17
+ (1) normalizing the hash on recovery intent — Read recoveries key on
18
+ `(basename(error_path), basename(success_path))`; Bash recoveries strip
19
+ volatile suffixes and hash only the primary command before the first
20
+ `|`/`&&`; (2) stamping `first_seen_at` / `last_seen_at` on every pattern
21
+ and bumping them in `_bump_persisted_evidence` via `json_set`; (3)
22
+ refining at render time — drop rows not re-observed in 21 days,
23
+ re-validate Read success paths against the filesystem, collapse
24
+ same-error_path-with-multiple-targets into one "use Glob/Grep first"
25
+ bullet, rank by `evidence_count * 0.5 ** (days/5)`, cap the section at
26
+ 15. Other `Learned: …` categories (environment, preference,
27
+ architecture) are untouched.
28
+ - **`headroom unwrap codex` now actually undoes `headroom wrap codex`** —
29
+ previously there was no `unwrap codex` subcommand at all, so the injected
30
+ `model_provider = "headroom"` / `[model_providers.headroom]` block stayed
31
+ in `~/.codex/config.toml` forever and Codex continued routing through the
32
+ (potentially stopped) proxy, surfacing as `Missing environment variable:
33
+ OPENAI_API_KEY`. `wrap codex` now snapshots the pre-wrap
34
+ `config.toml` to `config.toml.headroom-backup` before its first injection,
35
+ and `unwrap codex` restores that snapshot byte-for-byte (or, if the
36
+ backup is missing, strips only the Headroom-managed block and leaves
37
+ surrounding user content intact). Safe no-op when run without a prior
38
+ wrap. Reported by @raenaryl in Discord.
39
  - **`headroom learn` no longer clobbers prior recommendations on re-run** —
40
  the marker block in `CLAUDE.md` / `MEMORY.md` is now merged with the
41
  prior block instead of wholesale-replaced. Sections re-surfaced by the
42
  new run win; sections not re-surfaced are carried forward so learnings
43
  accumulate across runs instead of disappearing. To fully rebuild the
44
  block, delete it manually and re-run. (#231)
45
+ - **`headroom learn` no longer emits dangling cross-references when a
46
+ section is re-surfaced** — the analyzer now includes the project's
47
+ current `<!-- headroom:learn -->` block (from `CLAUDE.md` and
48
+ `MEMORY.md`) in the LLM digest as a "Prior Learned Patterns" section,
49
+ and the system prompt instructs the LLM that re-emitting a section
50
+ replaces the prior one wholesale. Prevents bullets like "`X` is *also*
51
+ large — same rule as `Y`, `Z`" from appearing after `Y` and `Z` got
52
+ dropped during per-section replacement. The writer's section-level
53
+ carry-forward from #231 remains in place as a safety net for sections
54
+ the LLM omits entirely. New helper `extract_marker_block` added to
55
+ `headroom.learn.writer`.
56
 
57
  ### Added
58
+ - **`turn_id` linking agent-loop API calls to a single user prompt** — a new
59
+ `compute_turn_id(model, system, messages)` helper in
60
+ `headroom/proxy/helpers.py` hashes the message prefix up to and including
61
+ the last user-text message, yielding an id that is stable across every
62
+ agent-loop iteration of one prompt but rolls over when the user sends a
63
+ new prompt (or runs `/compact`, `/clear`). `RequestLog` gained a
64
+ `turn_id: str | None` field, which is stamped at every log site
65
+ (anthropic handler bedrock + direct branches, and the streaming handler)
66
+ and surfaced as `turn_id` in `/transformations/feed`. Lets downstream
67
+ consumers (e.g. the Headroom Desktop Activity tab) aggregate savings per
68
+ user prompt rather than per API call.
69
  - **Live flush of traffic-learned patterns to CLAUDE.md / MEMORY.md** — the
70
  `TrafficLearner` now writes to agent-native context files continuously
71
  during proxy operation, not just at shutdown. A new dirty-flag debounced
README.md CHANGED
@@ -5,6 +5,7 @@
5
  **Compress everything your AI agent reads. Same answers, fraction of the tokens.**
6
 
7
  [![CI](https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg)](https://github.com/chopratejas/headroom/actions/workflows/ci.yml)
 
8
  [![PyPI](https://img.shields.io/pypi/v/headroom-ai.svg)](https://pypi.org/project/headroom-ai/)
9
  [![npm](https://img.shields.io/npm/v/headroom-ai.svg)](https://www.npmjs.com/package/headroom-ai)
10
  [![Model: Kompress-base](https://img.shields.io/badge/model-Kompress--base-yellow.svg)](https://huggingface.co/chopratejas/kompress-base)
 
5
  **Compress everything your AI agent reads. Same answers, fraction of the tokens.**
6
 
7
  [![CI](https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg)](https://github.com/chopratejas/headroom/actions/workflows/ci.yml)
8
+ [![codecov](https://codecov.io/gh/chopratejas/headroom/graph/badge.svg)](https://app.codecov.io/gh/chopratejas/headroom)
9
  [![PyPI](https://img.shields.io/pypi/v/headroom-ai.svg)](https://pypi.org/project/headroom-ai/)
10
  [![npm](https://img.shields.io/npm/v/headroom-ai.svg)](https://www.npmjs.com/package/headroom-ai)
11
  [![Model: Kompress-base](https://img.shields.io/badge/model-Kompress--base-yellow.svg)](https://huggingface.co/chopratejas/kompress-base)
docs/package-lock.json CHANGED
@@ -17,7 +17,7 @@
17
  "fumadocs-ui": "16.7.10",
18
  "headroom-ai": "file:../sdk/typescript",
19
  "lucide-react": "^1.7.0",
20
- "next": "16.2.2",
21
  "react": "^19.2.4",
22
  "react-dom": "^19.2.4",
23
  "recharts": "^3.8.1",
@@ -33,7 +33,7 @@
33
  "@types/react-dom": "^19.2.3",
34
  "ai": "^6.0.149",
35
  "openai": "^6.33.0",
36
- "postcss": "^8.5.8",
37
  "tailwindcss": "^4.2.2",
38
  "typescript": "^5.9.3"
39
  }
@@ -186,6 +186,80 @@
186
  "node": ">=6.9.0"
187
  }
188
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  "node_modules/@esbuild/darwin-arm64": {
190
  "version": "0.27.7",
191
  "cpu": [
@@ -200,100 +274,854 @@
200
  "node": ">=18"
201
  }
202
  },
203
- "node_modules/@floating-ui/core": {
204
- "version": "1.7.5",
 
 
 
 
 
205
  "license": "MIT",
206
- "dependencies": {
207
- "@floating-ui/utils": "^0.2.11"
 
 
 
 
208
  }
209
  },
210
- "node_modules/@floating-ui/dom": {
211
- "version": "1.7.6",
 
 
 
 
 
212
  "license": "MIT",
213
- "dependencies": {
214
- "@floating-ui/core": "^1.7.5",
215
- "@floating-ui/utils": "^0.2.11"
 
 
 
216
  }
217
  },
218
- "node_modules/@floating-ui/react-dom": {
219
- "version": "2.1.8",
 
 
 
 
 
220
  "license": "MIT",
221
- "dependencies": {
222
- "@floating-ui/dom": "^1.7.6"
223
- },
224
- "peerDependencies": {
225
- "react": ">=16.8.0",
226
- "react-dom": ">=16.8.0"
227
  }
228
  },
229
- "node_modules/@floating-ui/utils": {
230
- "version": "0.2.11",
231
- "license": "MIT"
 
 
 
 
 
 
 
 
 
 
 
 
232
  },
233
- "node_modules/@formatjs/fast-memoize": {
234
- "version": "3.1.1",
235
- "license": "MIT"
 
 
 
 
 
 
 
 
 
 
 
 
236
  },
237
- "node_modules/@formatjs/intl-localematcher": {
238
- "version": "0.8.2",
 
 
 
 
 
239
  "license": "MIT",
240
- "dependencies": {
241
- "@formatjs/fast-memoize": "3.1.1"
 
 
 
 
242
  }
243
  },
244
- "node_modules/@fumadocs/tailwind": {
245
- "version": "0.0.3",
 
 
 
 
 
246
  "license": "MIT",
247
- "dependencies": {
248
- "postcss-selector-parser": "^7.1.1"
249
- },
250
- "peerDependencies": {
251
- "tailwindcss": "^4.0.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  },
253
- "peerDependenciesMeta": {
254
- "tailwindcss": {
255
- "optional": true
256
- }
257
  }
258
  },
259
- "node_modules/@img/colour": {
260
- "version": "1.1.0",
261
- "license": "MIT",
 
 
 
 
 
262
  "optional": true,
 
 
 
263
  "engines": {
264
- "node": ">=18"
 
 
 
265
  }
266
  },
267
- "node_modules/@img/sharp-darwin-arm64": {
268
  "version": "0.34.5",
 
 
269
  "cpu": [
270
- "arm64"
271
  ],
272
- "license": "Apache-2.0",
273
  "optional": true,
274
  "os": [
275
- "darwin"
276
  ],
277
  "engines": {
278
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
279
  },
280
  "funding": {
281
  "url": "https://opencollective.com/libvips"
282
- },
283
- "optionalDependencies": {
284
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
285
  }
286
  },
287
- "node_modules/@img/sharp-libvips-darwin-arm64": {
288
- "version": "1.2.4",
 
 
289
  "cpu": [
290
- "arm64"
291
  ],
292
- "license": "LGPL-3.0-or-later",
293
  "optional": true,
294
  "os": [
295
- "darwin"
296
  ],
 
 
 
297
  "funding": {
298
  "url": "https://opencollective.com/libvips"
299
  }
@@ -374,11 +1202,15 @@
374
  }
375
  },
376
  "node_modules/@next/env": {
377
- "version": "16.2.2",
 
 
378
  "license": "MIT"
379
  },
380
  "node_modules/@next/swc-darwin-arm64": {
381
- "version": "16.2.2",
 
 
382
  "cpu": [
383
  "arm64"
384
  ],
@@ -392,12 +1224,13 @@
392
  }
393
  },
394
  "node_modules/@next/swc-darwin-x64": {
395
- "version": "16.2.2",
396
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
397
- "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
398
  "cpu": [
399
  "x64"
400
  ],
 
401
  "optional": true,
402
  "os": [
403
  "darwin"
@@ -407,12 +1240,13 @@
407
  }
408
  },
409
  "node_modules/@next/swc-linux-arm64-gnu": {
410
- "version": "16.2.2",
411
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
412
- "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
413
  "cpu": [
414
  "arm64"
415
  ],
 
416
  "optional": true,
417
  "os": [
418
  "linux"
@@ -422,12 +1256,13 @@
422
  }
423
  },
424
  "node_modules/@next/swc-linux-arm64-musl": {
425
- "version": "16.2.2",
426
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
427
- "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
428
  "cpu": [
429
  "arm64"
430
  ],
 
431
  "optional": true,
432
  "os": [
433
  "linux"
@@ -437,12 +1272,13 @@
437
  }
438
  },
439
  "node_modules/@next/swc-linux-x64-gnu": {
440
- "version": "16.2.2",
441
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
442
- "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
443
  "cpu": [
444
  "x64"
445
  ],
 
446
  "optional": true,
447
  "os": [
448
  "linux"
@@ -452,12 +1288,13 @@
452
  }
453
  },
454
  "node_modules/@next/swc-linux-x64-musl": {
455
- "version": "16.2.2",
456
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
457
- "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
458
  "cpu": [
459
  "x64"
460
  ],
 
461
  "optional": true,
462
  "os": [
463
  "linux"
@@ -467,12 +1304,13 @@
467
  }
468
  },
469
  "node_modules/@next/swc-win32-arm64-msvc": {
470
- "version": "16.2.2",
471
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
472
- "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
473
  "cpu": [
474
  "arm64"
475
  ],
 
476
  "optional": true,
477
  "os": [
478
  "win32"
@@ -482,12 +1320,13 @@
482
  }
483
  },
484
  "node_modules/@next/swc-win32-x64-msvc": {
485
- "version": "16.2.2",
486
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
487
- "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
488
  "cpu": [
489
  "x64"
490
  ],
 
491
  "optional": true,
492
  "os": [
493
  "win32"
@@ -502,7 +1341,6 @@
502
  "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
503
  "devOptional": true,
504
  "license": "Apache-2.0",
505
- "peer": true,
506
  "engines": {
507
  "node": ">=8.0.0"
508
  }
@@ -1421,73 +2259,337 @@
1421
  "node": ">=20"
1422
  }
1423
  },
1424
- "node_modules/@shikijs/vscode-textmate": {
1425
- "version": "10.0.2",
1426
- "license": "MIT"
1427
- },
1428
- "node_modules/@standard-schema/spec": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1429
  "version": "1.1.0",
1430
- "license": "MIT"
1431
- },
1432
- "node_modules/@standard-schema/utils": {
1433
- "version": "0.3.0",
1434
- "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1435
- "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
1436
- "license": "MIT"
1437
  },
1438
- "node_modules/@swc/helpers": {
1439
- "version": "0.5.15",
1440
- "license": "Apache-2.0",
 
 
 
1441
  "dependencies": {
1442
- "tslib": "^2.8.0"
 
 
 
 
 
 
1443
  }
1444
  },
1445
- "node_modules/@tailwindcss/node": {
1446
- "version": "4.2.2",
1447
  "dev": true,
 
1448
  "license": "MIT",
 
1449
  "dependencies": {
1450
- "@jridgewell/remapping": "^2.3.5",
1451
- "enhanced-resolve": "^5.19.0",
1452
- "jiti": "^2.6.1",
1453
- "lightningcss": "1.32.0",
1454
- "magic-string": "^0.30.21",
1455
- "source-map-js": "^1.2.1",
1456
- "tailwindcss": "4.2.2"
1457
  }
1458
  },
1459
- "node_modules/@tailwindcss/oxide": {
 
 
 
 
 
 
 
1460
  "version": "4.2.2",
 
 
 
 
 
1461
  "dev": true,
1462
  "license": "MIT",
 
 
 
 
1463
  "engines": {
1464
  "node": ">= 20"
1465
- },
1466
- "optionalDependencies": {
1467
- "@tailwindcss/oxide-android-arm64": "4.2.2",
1468
- "@tailwindcss/oxide-darwin-arm64": "4.2.2",
1469
- "@tailwindcss/oxide-darwin-x64": "4.2.2",
1470
- "@tailwindcss/oxide-freebsd-x64": "4.2.2",
1471
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
1472
- "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
1473
- "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
1474
- "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
1475
- "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
1476
- "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
1477
- "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
1478
- "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
1479
  }
1480
  },
1481
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
1482
  "version": "4.2.2",
 
 
1483
  "cpu": [
1484
- "arm64"
1485
  ],
1486
  "dev": true,
1487
  "license": "MIT",
1488
  "optional": true,
1489
  "os": [
1490
- "darwin"
1491
  ],
1492
  "engines": {
1493
  "node": ">= 20"
@@ -1656,15 +2758,13 @@
1656
  "node_modules/@types/mdast": {
1657
  "version": "4.0.4",
1658
  "license": "MIT",
1659
- "peer": true,
1660
  "dependencies": {
1661
  "@types/unist": "*"
1662
  }
1663
  },
1664
  "node_modules/@types/mdx": {
1665
  "version": "2.0.13",
1666
- "license": "MIT",
1667
- "peer": true
1668
  },
1669
  "node_modules/@types/ms": {
1670
  "version": "2.1.0",
@@ -1682,7 +2782,6 @@
1682
  "version": "19.2.14",
1683
  "devOptional": true,
1684
  "license": "MIT",
1685
- "peer": true,
1686
  "dependencies": {
1687
  "csstype": "^3.2.2"
1688
  }
@@ -1691,7 +2790,6 @@
1691
  "version": "19.2.3",
1692
  "devOptional": true,
1693
  "license": "MIT",
1694
- "peer": true,
1695
  "peerDependencies": {
1696
  "@types/react": "^19.2.0"
1697
  }
@@ -1735,7 +2833,6 @@
1735
  "node_modules/acorn": {
1736
  "version": "8.16.0",
1737
  "license": "MIT",
1738
- "peer": true,
1739
  "bin": {
1740
  "acorn": "bin/acorn"
1741
  },
@@ -2417,7 +3514,6 @@
2417
  "node_modules/fumadocs-core": {
2418
  "version": "16.7.10",
2419
  "license": "MIT",
2420
- "peer": true,
2421
  "dependencies": {
2422
  "@formatjs/intl-localematcher": "^0.8.2",
2423
  "@orama/orama": "^3.1.18",
@@ -2655,7 +3751,6 @@
2655
  "node_modules/fumadocs-ui": {
2656
  "version": "16.7.10",
2657
  "license": "MIT",
2658
- "peer": true,
2659
  "dependencies": {
2660
  "@fumadocs/tailwind": "0.0.3",
2661
  "@radix-ui/react-accordion": "^1.2.12",
@@ -3059,6 +4154,27 @@
3059
  "lightningcss-win32-x64-msvc": "1.32.0"
3060
  }
3061
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3062
  "node_modules/lightningcss-darwin-arm64": {
3063
  "version": "1.32.0",
3064
  "cpu": [
@@ -3078,6 +4194,195 @@
3078
  "url": "https://opencollective.com/parcel"
3079
  }
3080
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3081
  "node_modules/longest-streak": {
3082
  "version": "3.1.0",
3083
  "license": "MIT",
@@ -3089,7 +4394,6 @@
3089
  "node_modules/lucide-react": {
3090
  "version": "1.7.0",
3091
  "license": "ISC",
3092
- "peer": true,
3093
  "peerDependencies": {
3094
  "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
3095
  }
@@ -4106,11 +5410,12 @@
4106
  }
4107
  },
4108
  "node_modules/next": {
4109
- "version": "16.2.2",
 
 
4110
  "license": "MIT",
4111
- "peer": true,
4112
  "dependencies": {
4113
- "@next/env": "16.2.2",
4114
  "@swc/helpers": "0.5.15",
4115
  "baseline-browser-mapping": "^2.9.19",
4116
  "caniuse-lite": "^1.0.30001579",
@@ -4124,14 +5429,14 @@
4124
  "node": ">=20.9.0"
4125
  },
4126
  "optionalDependencies": {
4127
- "@next/swc-darwin-arm64": "16.2.2",
4128
- "@next/swc-darwin-x64": "16.2.2",
4129
- "@next/swc-linux-arm64-gnu": "16.2.2",
4130
- "@next/swc-linux-arm64-musl": "16.2.2",
4131
- "@next/swc-linux-x64-gnu": "16.2.2",
4132
- "@next/swc-linux-x64-musl": "16.2.2",
4133
- "@next/swc-win32-arm64-msvc": "16.2.2",
4134
- "@next/swc-win32-x64-msvc": "16.2.2",
4135
  "sharp": "^0.34.5"
4136
  },
4137
  "peerDependencies": {
@@ -4305,7 +5610,9 @@
4305
  }
4306
  },
4307
  "node_modules/postcss": {
4308
- "version": "8.5.8",
 
 
4309
  "dev": true,
4310
  "funding": [
4311
  {
@@ -4366,7 +5673,6 @@
4366
  "node_modules/react": {
4367
  "version": "19.2.4",
4368
  "license": "MIT",
4369
- "peer": true,
4370
  "engines": {
4371
  "node": ">=0.10.0"
4372
  }
@@ -4374,7 +5680,6 @@
4374
  "node_modules/react-dom": {
4375
  "version": "19.2.4",
4376
  "license": "MIT",
4377
- "peer": true,
4378
  "dependencies": {
4379
  "scheduler": "^0.27.0"
4380
  },
@@ -4408,7 +5713,6 @@
4408
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
4409
  "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
4410
  "license": "MIT",
4411
- "peer": true,
4412
  "dependencies": {
4413
  "@types/use-sync-external-store": "^0.0.6",
4414
  "use-sync-external-store": "^1.4.0"
@@ -4594,8 +5898,7 @@
4594
  "version": "5.0.1",
4595
  "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
4596
  "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
4597
- "license": "MIT",
4598
- "peer": true
4599
  },
4600
  "node_modules/redux-thunk": {
4601
  "version": "3.1.0",
@@ -4908,8 +6211,7 @@
4908
  "node_modules/tailwindcss": {
4909
  "version": "4.2.2",
4910
  "devOptional": true,
4911
- "license": "MIT",
4912
- "peer": true
4913
  },
4914
  "node_modules/tapable": {
4915
  "version": "2.3.2",
@@ -4998,7 +6300,6 @@
4998
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
4999
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
5000
  "license": "Apache-2.0",
5001
- "peer": true,
5002
  "bin": {
5003
  "tsc": "bin/tsc",
5004
  "tsserver": "bin/tsserver"
@@ -5240,7 +6541,6 @@
5240
  "node_modules/zod": {
5241
  "version": "4.3.6",
5242
  "license": "MIT",
5243
- "peer": true,
5244
  "funding": {
5245
  "url": "https://github.com/sponsors/colinhacks"
5246
  }
 
17
  "fumadocs-ui": "16.7.10",
18
  "headroom-ai": "file:../sdk/typescript",
19
  "lucide-react": "^1.7.0",
20
+ "next": "16.2.4",
21
  "react": "^19.2.4",
22
  "react-dom": "^19.2.4",
23
  "recharts": "^3.8.1",
 
33
  "@types/react-dom": "^19.2.3",
34
  "ai": "^6.0.149",
35
  "openai": "^6.33.0",
36
+ "postcss": "^8.5.10",
37
  "tailwindcss": "^4.2.2",
38
  "typescript": "^5.9.3"
39
  }
 
186
  "node": ">=6.9.0"
187
  }
188
  },
189
+ "node_modules/@emnapi/runtime": {
190
+ "version": "1.10.0",
191
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
192
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
193
+ "license": "MIT",
194
+ "optional": true,
195
+ "dependencies": {
196
+ "tslib": "^2.4.0"
197
+ }
198
+ },
199
+ "node_modules/@esbuild/aix-ppc64": {
200
+ "version": "0.27.7",
201
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
202
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
203
+ "cpu": [
204
+ "ppc64"
205
+ ],
206
+ "license": "MIT",
207
+ "optional": true,
208
+ "os": [
209
+ "aix"
210
+ ],
211
+ "engines": {
212
+ "node": ">=18"
213
+ }
214
+ },
215
+ "node_modules/@esbuild/android-arm": {
216
+ "version": "0.27.7",
217
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
218
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
219
+ "cpu": [
220
+ "arm"
221
+ ],
222
+ "license": "MIT",
223
+ "optional": true,
224
+ "os": [
225
+ "android"
226
+ ],
227
+ "engines": {
228
+ "node": ">=18"
229
+ }
230
+ },
231
+ "node_modules/@esbuild/android-arm64": {
232
+ "version": "0.27.7",
233
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
234
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
235
+ "cpu": [
236
+ "arm64"
237
+ ],
238
+ "license": "MIT",
239
+ "optional": true,
240
+ "os": [
241
+ "android"
242
+ ],
243
+ "engines": {
244
+ "node": ">=18"
245
+ }
246
+ },
247
+ "node_modules/@esbuild/android-x64": {
248
+ "version": "0.27.7",
249
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
250
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
251
+ "cpu": [
252
+ "x64"
253
+ ],
254
+ "license": "MIT",
255
+ "optional": true,
256
+ "os": [
257
+ "android"
258
+ ],
259
+ "engines": {
260
+ "node": ">=18"
261
+ }
262
+ },
263
  "node_modules/@esbuild/darwin-arm64": {
264
  "version": "0.27.7",
265
  "cpu": [
 
274
  "node": ">=18"
275
  }
276
  },
277
+ "node_modules/@esbuild/darwin-x64": {
278
+ "version": "0.27.7",
279
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
280
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
281
+ "cpu": [
282
+ "x64"
283
+ ],
284
  "license": "MIT",
285
+ "optional": true,
286
+ "os": [
287
+ "darwin"
288
+ ],
289
+ "engines": {
290
+ "node": ">=18"
291
  }
292
  },
293
+ "node_modules/@esbuild/freebsd-arm64": {
294
+ "version": "0.27.7",
295
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
296
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
297
+ "cpu": [
298
+ "arm64"
299
+ ],
300
  "license": "MIT",
301
+ "optional": true,
302
+ "os": [
303
+ "freebsd"
304
+ ],
305
+ "engines": {
306
+ "node": ">=18"
307
  }
308
  },
309
+ "node_modules/@esbuild/freebsd-x64": {
310
+ "version": "0.27.7",
311
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
312
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
313
+ "cpu": [
314
+ "x64"
315
+ ],
316
  "license": "MIT",
317
+ "optional": true,
318
+ "os": [
319
+ "freebsd"
320
+ ],
321
+ "engines": {
322
+ "node": ">=18"
323
  }
324
  },
325
+ "node_modules/@esbuild/linux-arm": {
326
+ "version": "0.27.7",
327
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
328
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
329
+ "cpu": [
330
+ "arm"
331
+ ],
332
+ "license": "MIT",
333
+ "optional": true,
334
+ "os": [
335
+ "linux"
336
+ ],
337
+ "engines": {
338
+ "node": ">=18"
339
+ }
340
  },
341
+ "node_modules/@esbuild/linux-arm64": {
342
+ "version": "0.27.7",
343
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
344
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
345
+ "cpu": [
346
+ "arm64"
347
+ ],
348
+ "license": "MIT",
349
+ "optional": true,
350
+ "os": [
351
+ "linux"
352
+ ],
353
+ "engines": {
354
+ "node": ">=18"
355
+ }
356
  },
357
+ "node_modules/@esbuild/linux-ia32": {
358
+ "version": "0.27.7",
359
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
360
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
361
+ "cpu": [
362
+ "ia32"
363
+ ],
364
  "license": "MIT",
365
+ "optional": true,
366
+ "os": [
367
+ "linux"
368
+ ],
369
+ "engines": {
370
+ "node": ">=18"
371
  }
372
  },
373
+ "node_modules/@esbuild/linux-loong64": {
374
+ "version": "0.27.7",
375
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
376
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
377
+ "cpu": [
378
+ "loong64"
379
+ ],
380
  "license": "MIT",
381
+ "optional": true,
382
+ "os": [
383
+ "linux"
384
+ ],
385
+ "engines": {
386
+ "node": ">=18"
387
+ }
388
+ },
389
+ "node_modules/@esbuild/linux-mips64el": {
390
+ "version": "0.27.7",
391
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
392
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
393
+ "cpu": [
394
+ "mips64el"
395
+ ],
396
+ "license": "MIT",
397
+ "optional": true,
398
+ "os": [
399
+ "linux"
400
+ ],
401
+ "engines": {
402
+ "node": ">=18"
403
+ }
404
+ },
405
+ "node_modules/@esbuild/linux-ppc64": {
406
+ "version": "0.27.7",
407
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
408
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
409
+ "cpu": [
410
+ "ppc64"
411
+ ],
412
+ "license": "MIT",
413
+ "optional": true,
414
+ "os": [
415
+ "linux"
416
+ ],
417
+ "engines": {
418
+ "node": ">=18"
419
+ }
420
+ },
421
+ "node_modules/@esbuild/linux-riscv64": {
422
+ "version": "0.27.7",
423
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
424
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
425
+ "cpu": [
426
+ "riscv64"
427
+ ],
428
+ "license": "MIT",
429
+ "optional": true,
430
+ "os": [
431
+ "linux"
432
+ ],
433
+ "engines": {
434
+ "node": ">=18"
435
+ }
436
+ },
437
+ "node_modules/@esbuild/linux-s390x": {
438
+ "version": "0.27.7",
439
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
440
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
441
+ "cpu": [
442
+ "s390x"
443
+ ],
444
+ "license": "MIT",
445
+ "optional": true,
446
+ "os": [
447
+ "linux"
448
+ ],
449
+ "engines": {
450
+ "node": ">=18"
451
+ }
452
+ },
453
+ "node_modules/@esbuild/linux-x64": {
454
+ "version": "0.27.7",
455
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
456
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
457
+ "cpu": [
458
+ "x64"
459
+ ],
460
+ "license": "MIT",
461
+ "optional": true,
462
+ "os": [
463
+ "linux"
464
+ ],
465
+ "engines": {
466
+ "node": ">=18"
467
+ }
468
+ },
469
+ "node_modules/@esbuild/netbsd-arm64": {
470
+ "version": "0.27.7",
471
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
472
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
473
+ "cpu": [
474
+ "arm64"
475
+ ],
476
+ "license": "MIT",
477
+ "optional": true,
478
+ "os": [
479
+ "netbsd"
480
+ ],
481
+ "engines": {
482
+ "node": ">=18"
483
+ }
484
+ },
485
+ "node_modules/@esbuild/netbsd-x64": {
486
+ "version": "0.27.7",
487
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
488
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
489
+ "cpu": [
490
+ "x64"
491
+ ],
492
+ "license": "MIT",
493
+ "optional": true,
494
+ "os": [
495
+ "netbsd"
496
+ ],
497
+ "engines": {
498
+ "node": ">=18"
499
+ }
500
+ },
501
+ "node_modules/@esbuild/openbsd-arm64": {
502
+ "version": "0.27.7",
503
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
504
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
505
+ "cpu": [
506
+ "arm64"
507
+ ],
508
+ "license": "MIT",
509
+ "optional": true,
510
+ "os": [
511
+ "openbsd"
512
+ ],
513
+ "engines": {
514
+ "node": ">=18"
515
+ }
516
+ },
517
+ "node_modules/@esbuild/openbsd-x64": {
518
+ "version": "0.27.7",
519
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
520
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
521
+ "cpu": [
522
+ "x64"
523
+ ],
524
+ "license": "MIT",
525
+ "optional": true,
526
+ "os": [
527
+ "openbsd"
528
+ ],
529
+ "engines": {
530
+ "node": ">=18"
531
+ }
532
+ },
533
+ "node_modules/@esbuild/openharmony-arm64": {
534
+ "version": "0.27.7",
535
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
536
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
537
+ "cpu": [
538
+ "arm64"
539
+ ],
540
+ "license": "MIT",
541
+ "optional": true,
542
+ "os": [
543
+ "openharmony"
544
+ ],
545
+ "engines": {
546
+ "node": ">=18"
547
+ }
548
+ },
549
+ "node_modules/@esbuild/sunos-x64": {
550
+ "version": "0.27.7",
551
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
552
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
553
+ "cpu": [
554
+ "x64"
555
+ ],
556
+ "license": "MIT",
557
+ "optional": true,
558
+ "os": [
559
+ "sunos"
560
+ ],
561
+ "engines": {
562
+ "node": ">=18"
563
+ }
564
+ },
565
+ "node_modules/@esbuild/win32-arm64": {
566
+ "version": "0.27.7",
567
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
568
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
569
+ "cpu": [
570
+ "arm64"
571
+ ],
572
+ "license": "MIT",
573
+ "optional": true,
574
+ "os": [
575
+ "win32"
576
+ ],
577
+ "engines": {
578
+ "node": ">=18"
579
+ }
580
+ },
581
+ "node_modules/@esbuild/win32-ia32": {
582
+ "version": "0.27.7",
583
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
584
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
585
+ "cpu": [
586
+ "ia32"
587
+ ],
588
+ "license": "MIT",
589
+ "optional": true,
590
+ "os": [
591
+ "win32"
592
+ ],
593
+ "engines": {
594
+ "node": ">=18"
595
+ }
596
+ },
597
+ "node_modules/@esbuild/win32-x64": {
598
+ "version": "0.27.7",
599
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
600
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
601
+ "cpu": [
602
+ "x64"
603
+ ],
604
+ "license": "MIT",
605
+ "optional": true,
606
+ "os": [
607
+ "win32"
608
+ ],
609
+ "engines": {
610
+ "node": ">=18"
611
+ }
612
+ },
613
+ "node_modules/@floating-ui/core": {
614
+ "version": "1.7.5",
615
+ "license": "MIT",
616
+ "dependencies": {
617
+ "@floating-ui/utils": "^0.2.11"
618
+ }
619
+ },
620
+ "node_modules/@floating-ui/dom": {
621
+ "version": "1.7.6",
622
+ "license": "MIT",
623
+ "dependencies": {
624
+ "@floating-ui/core": "^1.7.5",
625
+ "@floating-ui/utils": "^0.2.11"
626
+ }
627
+ },
628
+ "node_modules/@floating-ui/react-dom": {
629
+ "version": "2.1.8",
630
+ "license": "MIT",
631
+ "dependencies": {
632
+ "@floating-ui/dom": "^1.7.6"
633
+ },
634
+ "peerDependencies": {
635
+ "react": ">=16.8.0",
636
+ "react-dom": ">=16.8.0"
637
+ }
638
+ },
639
+ "node_modules/@floating-ui/utils": {
640
+ "version": "0.2.11",
641
+ "license": "MIT"
642
+ },
643
+ "node_modules/@formatjs/fast-memoize": {
644
+ "version": "3.1.1",
645
+ "license": "MIT"
646
+ },
647
+ "node_modules/@formatjs/intl-localematcher": {
648
+ "version": "0.8.2",
649
+ "license": "MIT",
650
+ "dependencies": {
651
+ "@formatjs/fast-memoize": "3.1.1"
652
+ }
653
+ },
654
+ "node_modules/@fumadocs/tailwind": {
655
+ "version": "0.0.3",
656
+ "license": "MIT",
657
+ "dependencies": {
658
+ "postcss-selector-parser": "^7.1.1"
659
+ },
660
+ "peerDependencies": {
661
+ "tailwindcss": "^4.0.0"
662
+ },
663
+ "peerDependenciesMeta": {
664
+ "tailwindcss": {
665
+ "optional": true
666
+ }
667
+ }
668
+ },
669
+ "node_modules/@img/colour": {
670
+ "version": "1.1.0",
671
+ "license": "MIT",
672
+ "optional": true,
673
+ "engines": {
674
+ "node": ">=18"
675
+ }
676
+ },
677
+ "node_modules/@img/sharp-darwin-arm64": {
678
+ "version": "0.34.5",
679
+ "cpu": [
680
+ "arm64"
681
+ ],
682
+ "license": "Apache-2.0",
683
+ "optional": true,
684
+ "os": [
685
+ "darwin"
686
+ ],
687
+ "engines": {
688
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
689
+ },
690
+ "funding": {
691
+ "url": "https://opencollective.com/libvips"
692
+ },
693
+ "optionalDependencies": {
694
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
695
+ }
696
+ },
697
+ "node_modules/@img/sharp-darwin-x64": {
698
+ "version": "0.34.5",
699
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
700
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
701
+ "cpu": [
702
+ "x64"
703
+ ],
704
+ "license": "Apache-2.0",
705
+ "optional": true,
706
+ "os": [
707
+ "darwin"
708
+ ],
709
+ "engines": {
710
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
711
+ },
712
+ "funding": {
713
+ "url": "https://opencollective.com/libvips"
714
+ },
715
+ "optionalDependencies": {
716
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
717
+ }
718
+ },
719
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
720
+ "version": "1.2.4",
721
+ "cpu": [
722
+ "arm64"
723
+ ],
724
+ "license": "LGPL-3.0-or-later",
725
+ "optional": true,
726
+ "os": [
727
+ "darwin"
728
+ ],
729
+ "funding": {
730
+ "url": "https://opencollective.com/libvips"
731
+ }
732
+ },
733
+ "node_modules/@img/sharp-libvips-darwin-x64": {
734
+ "version": "1.2.4",
735
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
736
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
737
+ "cpu": [
738
+ "x64"
739
+ ],
740
+ "license": "LGPL-3.0-or-later",
741
+ "optional": true,
742
+ "os": [
743
+ "darwin"
744
+ ],
745
+ "funding": {
746
+ "url": "https://opencollective.com/libvips"
747
+ }
748
+ },
749
+ "node_modules/@img/sharp-libvips-linux-arm": {
750
+ "version": "1.2.4",
751
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
752
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
753
+ "cpu": [
754
+ "arm"
755
+ ],
756
+ "license": "LGPL-3.0-or-later",
757
+ "optional": true,
758
+ "os": [
759
+ "linux"
760
+ ],
761
+ "funding": {
762
+ "url": "https://opencollective.com/libvips"
763
+ }
764
+ },
765
+ "node_modules/@img/sharp-libvips-linux-arm64": {
766
+ "version": "1.2.4",
767
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
768
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
769
+ "cpu": [
770
+ "arm64"
771
+ ],
772
+ "license": "LGPL-3.0-or-later",
773
+ "optional": true,
774
+ "os": [
775
+ "linux"
776
+ ],
777
+ "funding": {
778
+ "url": "https://opencollective.com/libvips"
779
+ }
780
+ },
781
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
782
+ "version": "1.2.4",
783
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
784
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
785
+ "cpu": [
786
+ "ppc64"
787
+ ],
788
+ "license": "LGPL-3.0-or-later",
789
+ "optional": true,
790
+ "os": [
791
+ "linux"
792
+ ],
793
+ "funding": {
794
+ "url": "https://opencollective.com/libvips"
795
+ }
796
+ },
797
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
798
+ "version": "1.2.4",
799
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
800
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
801
+ "cpu": [
802
+ "riscv64"
803
+ ],
804
+ "license": "LGPL-3.0-or-later",
805
+ "optional": true,
806
+ "os": [
807
+ "linux"
808
+ ],
809
+ "funding": {
810
+ "url": "https://opencollective.com/libvips"
811
+ }
812
+ },
813
+ "node_modules/@img/sharp-libvips-linux-s390x": {
814
+ "version": "1.2.4",
815
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
816
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
817
+ "cpu": [
818
+ "s390x"
819
+ ],
820
+ "license": "LGPL-3.0-or-later",
821
+ "optional": true,
822
+ "os": [
823
+ "linux"
824
+ ],
825
+ "funding": {
826
+ "url": "https://opencollective.com/libvips"
827
+ }
828
+ },
829
+ "node_modules/@img/sharp-libvips-linux-x64": {
830
+ "version": "1.2.4",
831
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
832
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
833
+ "cpu": [
834
+ "x64"
835
+ ],
836
+ "license": "LGPL-3.0-or-later",
837
+ "optional": true,
838
+ "os": [
839
+ "linux"
840
+ ],
841
+ "funding": {
842
+ "url": "https://opencollective.com/libvips"
843
+ }
844
+ },
845
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
846
+ "version": "1.2.4",
847
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
848
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
849
+ "cpu": [
850
+ "arm64"
851
+ ],
852
+ "license": "LGPL-3.0-or-later",
853
+ "optional": true,
854
+ "os": [
855
+ "linux"
856
+ ],
857
+ "funding": {
858
+ "url": "https://opencollective.com/libvips"
859
+ }
860
+ },
861
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
862
+ "version": "1.2.4",
863
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
864
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
865
+ "cpu": [
866
+ "x64"
867
+ ],
868
+ "license": "LGPL-3.0-or-later",
869
+ "optional": true,
870
+ "os": [
871
+ "linux"
872
+ ],
873
+ "funding": {
874
+ "url": "https://opencollective.com/libvips"
875
+ }
876
+ },
877
+ "node_modules/@img/sharp-linux-arm": {
878
+ "version": "0.34.5",
879
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
880
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
881
+ "cpu": [
882
+ "arm"
883
+ ],
884
+ "license": "Apache-2.0",
885
+ "optional": true,
886
+ "os": [
887
+ "linux"
888
+ ],
889
+ "engines": {
890
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
891
+ },
892
+ "funding": {
893
+ "url": "https://opencollective.com/libvips"
894
+ },
895
+ "optionalDependencies": {
896
+ "@img/sharp-libvips-linux-arm": "1.2.4"
897
+ }
898
+ },
899
+ "node_modules/@img/sharp-linux-arm64": {
900
+ "version": "0.34.5",
901
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
902
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
903
+ "cpu": [
904
+ "arm64"
905
+ ],
906
+ "license": "Apache-2.0",
907
+ "optional": true,
908
+ "os": [
909
+ "linux"
910
+ ],
911
+ "engines": {
912
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
913
+ },
914
+ "funding": {
915
+ "url": "https://opencollective.com/libvips"
916
+ },
917
+ "optionalDependencies": {
918
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
919
+ }
920
+ },
921
+ "node_modules/@img/sharp-linux-ppc64": {
922
+ "version": "0.34.5",
923
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
924
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
925
+ "cpu": [
926
+ "ppc64"
927
+ ],
928
+ "license": "Apache-2.0",
929
+ "optional": true,
930
+ "os": [
931
+ "linux"
932
+ ],
933
+ "engines": {
934
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
935
+ },
936
+ "funding": {
937
+ "url": "https://opencollective.com/libvips"
938
+ },
939
+ "optionalDependencies": {
940
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
941
+ }
942
+ },
943
+ "node_modules/@img/sharp-linux-riscv64": {
944
+ "version": "0.34.5",
945
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
946
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
947
+ "cpu": [
948
+ "riscv64"
949
+ ],
950
+ "license": "Apache-2.0",
951
+ "optional": true,
952
+ "os": [
953
+ "linux"
954
+ ],
955
+ "engines": {
956
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
957
+ },
958
+ "funding": {
959
+ "url": "https://opencollective.com/libvips"
960
+ },
961
+ "optionalDependencies": {
962
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
963
+ }
964
+ },
965
+ "node_modules/@img/sharp-linux-s390x": {
966
+ "version": "0.34.5",
967
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
968
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
969
+ "cpu": [
970
+ "s390x"
971
+ ],
972
+ "license": "Apache-2.0",
973
+ "optional": true,
974
+ "os": [
975
+ "linux"
976
+ ],
977
+ "engines": {
978
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
979
+ },
980
+ "funding": {
981
+ "url": "https://opencollective.com/libvips"
982
+ },
983
+ "optionalDependencies": {
984
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
985
+ }
986
+ },
987
+ "node_modules/@img/sharp-linux-x64": {
988
+ "version": "0.34.5",
989
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
990
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
991
+ "cpu": [
992
+ "x64"
993
+ ],
994
+ "license": "Apache-2.0",
995
+ "optional": true,
996
+ "os": [
997
+ "linux"
998
+ ],
999
+ "engines": {
1000
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1001
+ },
1002
+ "funding": {
1003
+ "url": "https://opencollective.com/libvips"
1004
+ },
1005
+ "optionalDependencies": {
1006
+ "@img/sharp-libvips-linux-x64": "1.2.4"
1007
+ }
1008
+ },
1009
+ "node_modules/@img/sharp-linuxmusl-arm64": {
1010
+ "version": "0.34.5",
1011
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
1012
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
1013
+ "cpu": [
1014
+ "arm64"
1015
+ ],
1016
+ "license": "Apache-2.0",
1017
+ "optional": true,
1018
+ "os": [
1019
+ "linux"
1020
+ ],
1021
+ "engines": {
1022
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1023
+ },
1024
+ "funding": {
1025
+ "url": "https://opencollective.com/libvips"
1026
+ },
1027
+ "optionalDependencies": {
1028
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
1029
+ }
1030
+ },
1031
+ "node_modules/@img/sharp-linuxmusl-x64": {
1032
+ "version": "0.34.5",
1033
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
1034
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
1035
+ "cpu": [
1036
+ "x64"
1037
+ ],
1038
+ "license": "Apache-2.0",
1039
+ "optional": true,
1040
+ "os": [
1041
+ "linux"
1042
+ ],
1043
+ "engines": {
1044
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1045
+ },
1046
+ "funding": {
1047
+ "url": "https://opencollective.com/libvips"
1048
+ },
1049
+ "optionalDependencies": {
1050
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
1051
+ }
1052
+ },
1053
+ "node_modules/@img/sharp-wasm32": {
1054
+ "version": "0.34.5",
1055
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
1056
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
1057
+ "cpu": [
1058
+ "wasm32"
1059
+ ],
1060
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
1061
+ "optional": true,
1062
+ "dependencies": {
1063
+ "@emnapi/runtime": "^1.7.0"
1064
+ },
1065
+ "engines": {
1066
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1067
  },
1068
+ "funding": {
1069
+ "url": "https://opencollective.com/libvips"
 
 
1070
  }
1071
  },
1072
+ "node_modules/@img/sharp-win32-arm64": {
1073
+ "version": "0.34.5",
1074
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
1075
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
1076
+ "cpu": [
1077
+ "arm64"
1078
+ ],
1079
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1080
  "optional": true,
1081
+ "os": [
1082
+ "win32"
1083
+ ],
1084
  "engines": {
1085
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1086
+ },
1087
+ "funding": {
1088
+ "url": "https://opencollective.com/libvips"
1089
  }
1090
  },
1091
+ "node_modules/@img/sharp-win32-ia32": {
1092
  "version": "0.34.5",
1093
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
1094
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
1095
  "cpu": [
1096
+ "ia32"
1097
  ],
1098
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1099
  "optional": true,
1100
  "os": [
1101
+ "win32"
1102
  ],
1103
  "engines": {
1104
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1105
  },
1106
  "funding": {
1107
  "url": "https://opencollective.com/libvips"
 
 
 
1108
  }
1109
  },
1110
+ "node_modules/@img/sharp-win32-x64": {
1111
+ "version": "0.34.5",
1112
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
1113
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
1114
  "cpu": [
1115
+ "x64"
1116
  ],
1117
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
1118
  "optional": true,
1119
  "os": [
1120
+ "win32"
1121
  ],
1122
+ "engines": {
1123
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1124
+ },
1125
  "funding": {
1126
  "url": "https://opencollective.com/libvips"
1127
  }
 
1202
  }
1203
  },
1204
  "node_modules/@next/env": {
1205
+ "version": "16.2.4",
1206
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
1207
+ "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
1208
  "license": "MIT"
1209
  },
1210
  "node_modules/@next/swc-darwin-arm64": {
1211
+ "version": "16.2.4",
1212
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
1213
+ "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
1214
  "cpu": [
1215
  "arm64"
1216
  ],
 
1224
  }
1225
  },
1226
  "node_modules/@next/swc-darwin-x64": {
1227
+ "version": "16.2.4",
1228
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
1229
+ "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
1230
  "cpu": [
1231
  "x64"
1232
  ],
1233
+ "license": "MIT",
1234
  "optional": true,
1235
  "os": [
1236
  "darwin"
 
1240
  }
1241
  },
1242
  "node_modules/@next/swc-linux-arm64-gnu": {
1243
+ "version": "16.2.4",
1244
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
1245
+ "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
1246
  "cpu": [
1247
  "arm64"
1248
  ],
1249
+ "license": "MIT",
1250
  "optional": true,
1251
  "os": [
1252
  "linux"
 
1256
  }
1257
  },
1258
  "node_modules/@next/swc-linux-arm64-musl": {
1259
+ "version": "16.2.4",
1260
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
1261
+ "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
1262
  "cpu": [
1263
  "arm64"
1264
  ],
1265
+ "license": "MIT",
1266
  "optional": true,
1267
  "os": [
1268
  "linux"
 
1272
  }
1273
  },
1274
  "node_modules/@next/swc-linux-x64-gnu": {
1275
+ "version": "16.2.4",
1276
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
1277
+ "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
1278
  "cpu": [
1279
  "x64"
1280
  ],
1281
+ "license": "MIT",
1282
  "optional": true,
1283
  "os": [
1284
  "linux"
 
1288
  }
1289
  },
1290
  "node_modules/@next/swc-linux-x64-musl": {
1291
+ "version": "16.2.4",
1292
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
1293
+ "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
1294
  "cpu": [
1295
  "x64"
1296
  ],
1297
+ "license": "MIT",
1298
  "optional": true,
1299
  "os": [
1300
  "linux"
 
1304
  }
1305
  },
1306
  "node_modules/@next/swc-win32-arm64-msvc": {
1307
+ "version": "16.2.4",
1308
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
1309
+ "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
1310
  "cpu": [
1311
  "arm64"
1312
  ],
1313
+ "license": "MIT",
1314
  "optional": true,
1315
  "os": [
1316
  "win32"
 
1320
  }
1321
  },
1322
  "node_modules/@next/swc-win32-x64-msvc": {
1323
+ "version": "16.2.4",
1324
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
1325
+ "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
1326
  "cpu": [
1327
  "x64"
1328
  ],
1329
+ "license": "MIT",
1330
  "optional": true,
1331
  "os": [
1332
  "win32"
 
1341
  "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
1342
  "devOptional": true,
1343
  "license": "Apache-2.0",
 
1344
  "engines": {
1345
  "node": ">=8.0.0"
1346
  }
 
2259
  "node": ">=20"
2260
  }
2261
  },
2262
+ "node_modules/@shikijs/vscode-textmate": {
2263
+ "version": "10.0.2",
2264
+ "license": "MIT"
2265
+ },
2266
+ "node_modules/@standard-schema/spec": {
2267
+ "version": "1.1.0",
2268
+ "license": "MIT"
2269
+ },
2270
+ "node_modules/@standard-schema/utils": {
2271
+ "version": "0.3.0",
2272
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
2273
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
2274
+ "license": "MIT"
2275
+ },
2276
+ "node_modules/@swc/helpers": {
2277
+ "version": "0.5.15",
2278
+ "license": "Apache-2.0",
2279
+ "dependencies": {
2280
+ "tslib": "^2.8.0"
2281
+ }
2282
+ },
2283
+ "node_modules/@tailwindcss/node": {
2284
+ "version": "4.2.2",
2285
+ "dev": true,
2286
+ "license": "MIT",
2287
+ "dependencies": {
2288
+ "@jridgewell/remapping": "^2.3.5",
2289
+ "enhanced-resolve": "^5.19.0",
2290
+ "jiti": "^2.6.1",
2291
+ "lightningcss": "1.32.0",
2292
+ "magic-string": "^0.30.21",
2293
+ "source-map-js": "^1.2.1",
2294
+ "tailwindcss": "4.2.2"
2295
+ }
2296
+ },
2297
+ "node_modules/@tailwindcss/oxide": {
2298
+ "version": "4.2.2",
2299
+ "dev": true,
2300
+ "license": "MIT",
2301
+ "engines": {
2302
+ "node": ">= 20"
2303
+ },
2304
+ "optionalDependencies": {
2305
+ "@tailwindcss/oxide-android-arm64": "4.2.2",
2306
+ "@tailwindcss/oxide-darwin-arm64": "4.2.2",
2307
+ "@tailwindcss/oxide-darwin-x64": "4.2.2",
2308
+ "@tailwindcss/oxide-freebsd-x64": "4.2.2",
2309
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
2310
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
2311
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
2312
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
2313
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
2314
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
2315
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
2316
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
2317
+ }
2318
+ },
2319
+ "node_modules/@tailwindcss/oxide-android-arm64": {
2320
+ "version": "4.2.2",
2321
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
2322
+ "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
2323
+ "cpu": [
2324
+ "arm64"
2325
+ ],
2326
+ "dev": true,
2327
+ "license": "MIT",
2328
+ "optional": true,
2329
+ "os": [
2330
+ "android"
2331
+ ],
2332
+ "engines": {
2333
+ "node": ">= 20"
2334
+ }
2335
+ },
2336
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
2337
+ "version": "4.2.2",
2338
+ "cpu": [
2339
+ "arm64"
2340
+ ],
2341
+ "dev": true,
2342
+ "license": "MIT",
2343
+ "optional": true,
2344
+ "os": [
2345
+ "darwin"
2346
+ ],
2347
+ "engines": {
2348
+ "node": ">= 20"
2349
+ }
2350
+ },
2351
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
2352
+ "version": "4.2.2",
2353
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
2354
+ "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
2355
+ "cpu": [
2356
+ "x64"
2357
+ ],
2358
+ "dev": true,
2359
+ "license": "MIT",
2360
+ "optional": true,
2361
+ "os": [
2362
+ "darwin"
2363
+ ],
2364
+ "engines": {
2365
+ "node": ">= 20"
2366
+ }
2367
+ },
2368
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
2369
+ "version": "4.2.2",
2370
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
2371
+ "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
2372
+ "cpu": [
2373
+ "x64"
2374
+ ],
2375
+ "dev": true,
2376
+ "license": "MIT",
2377
+ "optional": true,
2378
+ "os": [
2379
+ "freebsd"
2380
+ ],
2381
+ "engines": {
2382
+ "node": ">= 20"
2383
+ }
2384
+ },
2385
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
2386
+ "version": "4.2.2",
2387
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
2388
+ "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
2389
+ "cpu": [
2390
+ "arm"
2391
+ ],
2392
+ "dev": true,
2393
+ "license": "MIT",
2394
+ "optional": true,
2395
+ "os": [
2396
+ "linux"
2397
+ ],
2398
+ "engines": {
2399
+ "node": ">= 20"
2400
+ }
2401
+ },
2402
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
2403
+ "version": "4.2.2",
2404
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
2405
+ "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
2406
+ "cpu": [
2407
+ "arm64"
2408
+ ],
2409
+ "dev": true,
2410
+ "license": "MIT",
2411
+ "optional": true,
2412
+ "os": [
2413
+ "linux"
2414
+ ],
2415
+ "engines": {
2416
+ "node": ">= 20"
2417
+ }
2418
+ },
2419
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
2420
+ "version": "4.2.2",
2421
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
2422
+ "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
2423
+ "cpu": [
2424
+ "arm64"
2425
+ ],
2426
+ "dev": true,
2427
+ "license": "MIT",
2428
+ "optional": true,
2429
+ "os": [
2430
+ "linux"
2431
+ ],
2432
+ "engines": {
2433
+ "node": ">= 20"
2434
+ }
2435
+ },
2436
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
2437
+ "version": "4.2.2",
2438
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
2439
+ "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
2440
+ "cpu": [
2441
+ "x64"
2442
+ ],
2443
+ "dev": true,
2444
+ "license": "MIT",
2445
+ "optional": true,
2446
+ "os": [
2447
+ "linux"
2448
+ ],
2449
+ "engines": {
2450
+ "node": ">= 20"
2451
+ }
2452
+ },
2453
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
2454
+ "version": "4.2.2",
2455
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
2456
+ "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
2457
+ "cpu": [
2458
+ "x64"
2459
+ ],
2460
+ "dev": true,
2461
+ "license": "MIT",
2462
+ "optional": true,
2463
+ "os": [
2464
+ "linux"
2465
+ ],
2466
+ "engines": {
2467
+ "node": ">= 20"
2468
+ }
2469
+ },
2470
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
2471
+ "version": "4.2.2",
2472
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
2473
+ "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
2474
+ "bundleDependencies": [
2475
+ "@napi-rs/wasm-runtime",
2476
+ "@emnapi/core",
2477
+ "@emnapi/runtime",
2478
+ "@tybys/wasm-util",
2479
+ "@emnapi/wasi-threads",
2480
+ "tslib"
2481
+ ],
2482
+ "cpu": [
2483
+ "wasm32"
2484
+ ],
2485
+ "dev": true,
2486
+ "license": "MIT",
2487
+ "optional": true,
2488
+ "dependencies": {
2489
+ "@emnapi/core": "^1.8.1",
2490
+ "@emnapi/runtime": "^1.8.1",
2491
+ "@emnapi/wasi-threads": "^1.1.0",
2492
+ "@napi-rs/wasm-runtime": "^1.1.1",
2493
+ "@tybys/wasm-util": "^0.10.1",
2494
+ "tslib": "^2.8.1"
2495
+ },
2496
+ "engines": {
2497
+ "node": ">=14.0.0"
2498
+ }
2499
+ },
2500
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
2501
+ "version": "1.8.1",
2502
+ "dev": true,
2503
+ "inBundle": true,
2504
+ "license": "MIT",
2505
+ "optional": true,
2506
+ "dependencies": {
2507
+ "@emnapi/wasi-threads": "1.1.0",
2508
+ "tslib": "^2.4.0"
2509
+ }
2510
+ },
2511
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
2512
+ "version": "1.8.1",
2513
+ "dev": true,
2514
+ "inBundle": true,
2515
+ "license": "MIT",
2516
+ "optional": true,
2517
+ "dependencies": {
2518
+ "tslib": "^2.4.0"
2519
+ }
2520
+ },
2521
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
2522
  "version": "1.1.0",
2523
+ "dev": true,
2524
+ "inBundle": true,
2525
+ "license": "MIT",
2526
+ "optional": true,
2527
+ "dependencies": {
2528
+ "tslib": "^2.4.0"
2529
+ }
2530
  },
2531
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
2532
+ "version": "1.1.1",
2533
+ "dev": true,
2534
+ "inBundle": true,
2535
+ "license": "MIT",
2536
+ "optional": true,
2537
  "dependencies": {
2538
+ "@emnapi/core": "^1.7.1",
2539
+ "@emnapi/runtime": "^1.7.1",
2540
+ "@tybys/wasm-util": "^0.10.1"
2541
+ },
2542
+ "funding": {
2543
+ "type": "github",
2544
+ "url": "https://github.com/sponsors/Brooooooklyn"
2545
  }
2546
  },
2547
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
2548
+ "version": "0.10.1",
2549
  "dev": true,
2550
+ "inBundle": true,
2551
  "license": "MIT",
2552
+ "optional": true,
2553
  "dependencies": {
2554
+ "tslib": "^2.4.0"
 
 
 
 
 
 
2555
  }
2556
  },
2557
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
2558
+ "version": "2.8.1",
2559
+ "dev": true,
2560
+ "inBundle": true,
2561
+ "license": "0BSD",
2562
+ "optional": true
2563
+ },
2564
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
2565
  "version": "4.2.2",
2566
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
2567
+ "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
2568
+ "cpu": [
2569
+ "arm64"
2570
+ ],
2571
  "dev": true,
2572
  "license": "MIT",
2573
+ "optional": true,
2574
+ "os": [
2575
+ "win32"
2576
+ ],
2577
  "engines": {
2578
  "node": ">= 20"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2579
  }
2580
  },
2581
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
2582
  "version": "4.2.2",
2583
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
2584
+ "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
2585
  "cpu": [
2586
+ "x64"
2587
  ],
2588
  "dev": true,
2589
  "license": "MIT",
2590
  "optional": true,
2591
  "os": [
2592
+ "win32"
2593
  ],
2594
  "engines": {
2595
  "node": ">= 20"
 
2758
  "node_modules/@types/mdast": {
2759
  "version": "4.0.4",
2760
  "license": "MIT",
 
2761
  "dependencies": {
2762
  "@types/unist": "*"
2763
  }
2764
  },
2765
  "node_modules/@types/mdx": {
2766
  "version": "2.0.13",
2767
+ "license": "MIT"
 
2768
  },
2769
  "node_modules/@types/ms": {
2770
  "version": "2.1.0",
 
2782
  "version": "19.2.14",
2783
  "devOptional": true,
2784
  "license": "MIT",
 
2785
  "dependencies": {
2786
  "csstype": "^3.2.2"
2787
  }
 
2790
  "version": "19.2.3",
2791
  "devOptional": true,
2792
  "license": "MIT",
 
2793
  "peerDependencies": {
2794
  "@types/react": "^19.2.0"
2795
  }
 
2833
  "node_modules/acorn": {
2834
  "version": "8.16.0",
2835
  "license": "MIT",
 
2836
  "bin": {
2837
  "acorn": "bin/acorn"
2838
  },
 
3514
  "node_modules/fumadocs-core": {
3515
  "version": "16.7.10",
3516
  "license": "MIT",
 
3517
  "dependencies": {
3518
  "@formatjs/intl-localematcher": "^0.8.2",
3519
  "@orama/orama": "^3.1.18",
 
3751
  "node_modules/fumadocs-ui": {
3752
  "version": "16.7.10",
3753
  "license": "MIT",
 
3754
  "dependencies": {
3755
  "@fumadocs/tailwind": "0.0.3",
3756
  "@radix-ui/react-accordion": "^1.2.12",
 
4154
  "lightningcss-win32-x64-msvc": "1.32.0"
4155
  }
4156
  },
4157
+ "node_modules/lightningcss-android-arm64": {
4158
+ "version": "1.32.0",
4159
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
4160
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
4161
+ "cpu": [
4162
+ "arm64"
4163
+ ],
4164
+ "dev": true,
4165
+ "license": "MPL-2.0",
4166
+ "optional": true,
4167
+ "os": [
4168
+ "android"
4169
+ ],
4170
+ "engines": {
4171
+ "node": ">= 12.0.0"
4172
+ },
4173
+ "funding": {
4174
+ "type": "opencollective",
4175
+ "url": "https://opencollective.com/parcel"
4176
+ }
4177
+ },
4178
  "node_modules/lightningcss-darwin-arm64": {
4179
  "version": "1.32.0",
4180
  "cpu": [
 
4194
  "url": "https://opencollective.com/parcel"
4195
  }
4196
  },
4197
+ "node_modules/lightningcss-darwin-x64": {
4198
+ "version": "1.32.0",
4199
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
4200
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
4201
+ "cpu": [
4202
+ "x64"
4203
+ ],
4204
+ "dev": true,
4205
+ "license": "MPL-2.0",
4206
+ "optional": true,
4207
+ "os": [
4208
+ "darwin"
4209
+ ],
4210
+ "engines": {
4211
+ "node": ">= 12.0.0"
4212
+ },
4213
+ "funding": {
4214
+ "type": "opencollective",
4215
+ "url": "https://opencollective.com/parcel"
4216
+ }
4217
+ },
4218
+ "node_modules/lightningcss-freebsd-x64": {
4219
+ "version": "1.32.0",
4220
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
4221
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
4222
+ "cpu": [
4223
+ "x64"
4224
+ ],
4225
+ "dev": true,
4226
+ "license": "MPL-2.0",
4227
+ "optional": true,
4228
+ "os": [
4229
+ "freebsd"
4230
+ ],
4231
+ "engines": {
4232
+ "node": ">= 12.0.0"
4233
+ },
4234
+ "funding": {
4235
+ "type": "opencollective",
4236
+ "url": "https://opencollective.com/parcel"
4237
+ }
4238
+ },
4239
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
4240
+ "version": "1.32.0",
4241
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
4242
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
4243
+ "cpu": [
4244
+ "arm"
4245
+ ],
4246
+ "dev": true,
4247
+ "license": "MPL-2.0",
4248
+ "optional": true,
4249
+ "os": [
4250
+ "linux"
4251
+ ],
4252
+ "engines": {
4253
+ "node": ">= 12.0.0"
4254
+ },
4255
+ "funding": {
4256
+ "type": "opencollective",
4257
+ "url": "https://opencollective.com/parcel"
4258
+ }
4259
+ },
4260
+ "node_modules/lightningcss-linux-arm64-gnu": {
4261
+ "version": "1.32.0",
4262
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
4263
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
4264
+ "cpu": [
4265
+ "arm64"
4266
+ ],
4267
+ "dev": true,
4268
+ "license": "MPL-2.0",
4269
+ "optional": true,
4270
+ "os": [
4271
+ "linux"
4272
+ ],
4273
+ "engines": {
4274
+ "node": ">= 12.0.0"
4275
+ },
4276
+ "funding": {
4277
+ "type": "opencollective",
4278
+ "url": "https://opencollective.com/parcel"
4279
+ }
4280
+ },
4281
+ "node_modules/lightningcss-linux-arm64-musl": {
4282
+ "version": "1.32.0",
4283
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
4284
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
4285
+ "cpu": [
4286
+ "arm64"
4287
+ ],
4288
+ "dev": true,
4289
+ "license": "MPL-2.0",
4290
+ "optional": true,
4291
+ "os": [
4292
+ "linux"
4293
+ ],
4294
+ "engines": {
4295
+ "node": ">= 12.0.0"
4296
+ },
4297
+ "funding": {
4298
+ "type": "opencollective",
4299
+ "url": "https://opencollective.com/parcel"
4300
+ }
4301
+ },
4302
+ "node_modules/lightningcss-linux-x64-gnu": {
4303
+ "version": "1.32.0",
4304
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
4305
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
4306
+ "cpu": [
4307
+ "x64"
4308
+ ],
4309
+ "dev": true,
4310
+ "license": "MPL-2.0",
4311
+ "optional": true,
4312
+ "os": [
4313
+ "linux"
4314
+ ],
4315
+ "engines": {
4316
+ "node": ">= 12.0.0"
4317
+ },
4318
+ "funding": {
4319
+ "type": "opencollective",
4320
+ "url": "https://opencollective.com/parcel"
4321
+ }
4322
+ },
4323
+ "node_modules/lightningcss-linux-x64-musl": {
4324
+ "version": "1.32.0",
4325
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
4326
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
4327
+ "cpu": [
4328
+ "x64"
4329
+ ],
4330
+ "dev": true,
4331
+ "license": "MPL-2.0",
4332
+ "optional": true,
4333
+ "os": [
4334
+ "linux"
4335
+ ],
4336
+ "engines": {
4337
+ "node": ">= 12.0.0"
4338
+ },
4339
+ "funding": {
4340
+ "type": "opencollective",
4341
+ "url": "https://opencollective.com/parcel"
4342
+ }
4343
+ },
4344
+ "node_modules/lightningcss-win32-arm64-msvc": {
4345
+ "version": "1.32.0",
4346
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
4347
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
4348
+ "cpu": [
4349
+ "arm64"
4350
+ ],
4351
+ "dev": true,
4352
+ "license": "MPL-2.0",
4353
+ "optional": true,
4354
+ "os": [
4355
+ "win32"
4356
+ ],
4357
+ "engines": {
4358
+ "node": ">= 12.0.0"
4359
+ },
4360
+ "funding": {
4361
+ "type": "opencollective",
4362
+ "url": "https://opencollective.com/parcel"
4363
+ }
4364
+ },
4365
+ "node_modules/lightningcss-win32-x64-msvc": {
4366
+ "version": "1.32.0",
4367
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
4368
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
4369
+ "cpu": [
4370
+ "x64"
4371
+ ],
4372
+ "dev": true,
4373
+ "license": "MPL-2.0",
4374
+ "optional": true,
4375
+ "os": [
4376
+ "win32"
4377
+ ],
4378
+ "engines": {
4379
+ "node": ">= 12.0.0"
4380
+ },
4381
+ "funding": {
4382
+ "type": "opencollective",
4383
+ "url": "https://opencollective.com/parcel"
4384
+ }
4385
+ },
4386
  "node_modules/longest-streak": {
4387
  "version": "3.1.0",
4388
  "license": "MIT",
 
4394
  "node_modules/lucide-react": {
4395
  "version": "1.7.0",
4396
  "license": "ISC",
 
4397
  "peerDependencies": {
4398
  "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
4399
  }
 
5410
  }
5411
  },
5412
  "node_modules/next": {
5413
+ "version": "16.2.4",
5414
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
5415
+ "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
5416
  "license": "MIT",
 
5417
  "dependencies": {
5418
+ "@next/env": "16.2.4",
5419
  "@swc/helpers": "0.5.15",
5420
  "baseline-browser-mapping": "^2.9.19",
5421
  "caniuse-lite": "^1.0.30001579",
 
5429
  "node": ">=20.9.0"
5430
  },
5431
  "optionalDependencies": {
5432
+ "@next/swc-darwin-arm64": "16.2.4",
5433
+ "@next/swc-darwin-x64": "16.2.4",
5434
+ "@next/swc-linux-arm64-gnu": "16.2.4",
5435
+ "@next/swc-linux-arm64-musl": "16.2.4",
5436
+ "@next/swc-linux-x64-gnu": "16.2.4",
5437
+ "@next/swc-linux-x64-musl": "16.2.4",
5438
+ "@next/swc-win32-arm64-msvc": "16.2.4",
5439
+ "@next/swc-win32-x64-msvc": "16.2.4",
5440
  "sharp": "^0.34.5"
5441
  },
5442
  "peerDependencies": {
 
5610
  }
5611
  },
5612
  "node_modules/postcss": {
5613
+ "version": "8.5.10",
5614
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
5615
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
5616
  "dev": true,
5617
  "funding": [
5618
  {
 
5673
  "node_modules/react": {
5674
  "version": "19.2.4",
5675
  "license": "MIT",
 
5676
  "engines": {
5677
  "node": ">=0.10.0"
5678
  }
 
5680
  "node_modules/react-dom": {
5681
  "version": "19.2.4",
5682
  "license": "MIT",
 
5683
  "dependencies": {
5684
  "scheduler": "^0.27.0"
5685
  },
 
5713
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
5714
  "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
5715
  "license": "MIT",
 
5716
  "dependencies": {
5717
  "@types/use-sync-external-store": "^0.0.6",
5718
  "use-sync-external-store": "^1.4.0"
 
5898
  "version": "5.0.1",
5899
  "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
5900
  "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
5901
+ "license": "MIT"
 
5902
  },
5903
  "node_modules/redux-thunk": {
5904
  "version": "3.1.0",
 
6211
  "node_modules/tailwindcss": {
6212
  "version": "4.2.2",
6213
  "devOptional": true,
6214
+ "license": "MIT"
 
6215
  },
6216
  "node_modules/tapable": {
6217
  "version": "2.3.2",
 
6300
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
6301
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
6302
  "license": "Apache-2.0",
 
6303
  "bin": {
6304
  "tsc": "bin/tsc",
6305
  "tsserver": "bin/tsserver"
 
6541
  "node_modules/zod": {
6542
  "version": "4.3.6",
6543
  "license": "MIT",
 
6544
  "funding": {
6545
  "url": "https://github.com/sponsors/colinhacks"
6546
  }
docs/package.json CHANGED
@@ -18,7 +18,7 @@
18
  "fumadocs-ui": "16.7.10",
19
  "headroom-ai": "file:../sdk/typescript",
20
  "lucide-react": "^1.7.0",
21
- "next": "16.2.2",
22
  "react": "^19.2.4",
23
  "react-dom": "^19.2.4",
24
  "recharts": "^3.8.1",
@@ -34,7 +34,7 @@
34
  "@types/react-dom": "^19.2.3",
35
  "ai": "^6.0.149",
36
  "openai": "^6.33.0",
37
- "postcss": "^8.5.8",
38
  "tailwindcss": "^4.2.2",
39
  "typescript": "^5.9.3"
40
  }
 
18
  "fumadocs-ui": "16.7.10",
19
  "headroom-ai": "file:../sdk/typescript",
20
  "lucide-react": "^1.7.0",
21
+ "next": "16.2.4",
22
  "react": "^19.2.4",
23
  "react-dom": "^19.2.4",
24
  "recharts": "^3.8.1",
 
34
  "@types/react-dom": "^19.2.3",
35
  "ai": "^6.0.149",
36
  "openai": "^6.33.0",
37
+ "postcss": "^8.5.10",
38
  "tailwindcss": "^4.2.2",
39
  "typescript": "^5.9.3"
40
  }
e2e/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """End-to-end test suites for Headroom CLI commands.
2
+
3
+ Subpackages:
4
+ _lib — shared harness and helpers
5
+ init — ``headroom init`` coverage
6
+ wrap — ``headroom wrap`` coverage
7
+ """
e2e/_lib/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for Docker / CI e2e tests.
2
+
3
+ This package centralizes utilities used by the per-command e2e harnesses
4
+ (`e2e/init/run.py`, future `e2e/install/run.py`, `e2e/wrap/run.py`, ...).
5
+ The goal is that each command test suite is a small declarative file that
6
+ imports from this package, so new commands can be covered with minimal
7
+ duplication.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .assertions import (
13
+ assert_exit,
14
+ assert_stderr_contains,
15
+ assert_stdout_contains,
16
+ read_agent_settings,
17
+ )
18
+ from .harness import Case, CaseContext, run_case_sequence, run_cases
19
+ from .path_env import with_clean_path
20
+ from .paths import agent_settings_path
21
+ from .shims import make_shim
22
+
23
+ __all__ = [
24
+ "Case",
25
+ "CaseContext",
26
+ "agent_settings_path",
27
+ "assert_exit",
28
+ "assert_stderr_contains",
29
+ "assert_stdout_contains",
30
+ "make_shim",
31
+ "read_agent_settings",
32
+ "run_case_sequence",
33
+ "run_cases",
34
+ "with_clean_path",
35
+ ]
e2e/_lib/assertions.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared assertion helpers for e2e cases.
2
+
3
+ Assertions raise ``AssertionError`` with a descriptive message. The harness
4
+ catches them and attributes the failure to the owning ``Case``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .paths import Agent, Scope, agent_settings_path
14
+
15
+
16
+ def assert_exit(actual: int, expected: int, *, context: str = "") -> None:
17
+ if actual != expected:
18
+ suffix = f" ({context})" if context else ""
19
+ raise AssertionError(f"Expected exit code {expected}, got {actual}{suffix}")
20
+
21
+
22
+ def assert_stdout_contains(stdout: str, needle: str) -> None:
23
+ if needle not in stdout:
24
+ raise AssertionError(f"stdout missing {needle!r}:\n---\n{stdout}\n---")
25
+
26
+
27
+ def assert_stderr_contains(stderr: str, needle: str) -> None:
28
+ if needle not in stderr:
29
+ raise AssertionError(f"stderr missing {needle!r}:\n---\n{stderr}\n---")
30
+
31
+
32
+ def read_agent_settings(
33
+ agent: Agent, *, scope: Scope, home: Path, project: Path
34
+ ) -> dict[str, Any] | str:
35
+ """Read an agent's settings file, returning dict for JSON and str for TOML/other."""
36
+
37
+ path = agent_settings_path(agent, scope=scope, home=home, project=project)
38
+ if not path.exists():
39
+ raise AssertionError(f"Expected settings file at {path}, not found")
40
+ text = path.read_text(encoding="utf-8")
41
+ if path.suffix == ".json":
42
+ return json.loads(text)
43
+ return text
e2e/_lib/harness.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Declarative test-case harness for Docker e2e runners.
2
+
3
+ Each command gets its own ``run.py`` file that builds a list of ``Case``
4
+ objects and calls ``run_cases(cases)``. The harness handles:
5
+
6
+ * creating a scratch HOME and project directory per case
7
+ * dropping the requested shims into a dedicated shim dir
8
+ * building a clean PATH that only exposes the shim dir + minimal system dirs
9
+ * invoking the ``headroom`` subprocess with the case's argv
10
+ * running the case's assertions against stdout / stderr / exit code / files
11
+ * reporting pass/fail per case and a final summary
12
+
13
+ ``run_cases`` returns a non-zero exit code if any case fails, so Docker
14
+ containers driving it can fail fast.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ from .assertions import assert_exit, assert_stderr_contains, assert_stdout_contains
28
+ from .path_env import with_clean_path
29
+ from .shims import ShimBehavior, make_shim
30
+
31
+ CaseCallback = Callable[["CaseContext"], None]
32
+
33
+
34
+ @dataclass
35
+ class CaseContext:
36
+ """Runtime context passed to assertion callbacks."""
37
+
38
+ name: str
39
+ home: Path
40
+ project: Path
41
+ shim_dir: Path
42
+ shim_log: Path
43
+ stdout: str
44
+ stderr: str
45
+ exit_code: int
46
+
47
+
48
+ @dataclass
49
+ class Case:
50
+ """Declarative specification of a single e2e test case.
51
+
52
+ Attributes:
53
+ name: Human-readable identifier, printed on success/failure.
54
+ argv: Arguments passed to ``headroom`` (e.g. ``["init", "-g", "claude"]``).
55
+ shims: Mapping of shim name -> behavior to drop into the shim dir.
56
+ env_extra: Extra env vars layered on top of the clean env.
57
+ expected_exit: Required exit code (default 0).
58
+ expected_stdout_contains: Substrings that must appear on stdout.
59
+ expected_stderr_contains: Substrings that must appear on stderr.
60
+ expected_files: Paths (relative to home or project) that must exist.
61
+ Use ``{home}/...`` or ``{project}/...`` placeholders.
62
+ extra_assertions: Optional list of callbacks invoked after exit-code /
63
+ stdout / stderr / file checks pass. Receives a
64
+ ``CaseContext``. Use for JSON-content assertions,
65
+ shim-log inspection, etc.
66
+ """
67
+
68
+ name: str
69
+ argv: list[str]
70
+ shims: dict[str, ShimBehavior] = field(default_factory=dict)
71
+ env_extra: dict[str, str] = field(default_factory=dict)
72
+ expected_exit: int = 0
73
+ expected_stdout_contains: list[str] = field(default_factory=list)
74
+ expected_stderr_contains: list[str] = field(default_factory=list)
75
+ expected_files: list[str] = field(default_factory=list)
76
+ extra_assertions: list[CaseCallback] = field(default_factory=list)
77
+
78
+
79
+ def _log(message: str) -> None:
80
+ print(f"[e2e] {message}", flush=True)
81
+
82
+
83
+ def _resolve_placeholder(spec: str, *, home: Path, project: Path) -> Path:
84
+ return Path(spec.format(home=str(home), project=str(project)))
85
+
86
+
87
+ def _resolve_headroom_bin(name: str) -> str:
88
+ """Return the absolute path to the headroom binary before PATH is scrubbed.
89
+
90
+ ``with_clean_path`` intentionally narrows PATH so agent shims dominate;
91
+ that would also hide the real ``headroom`` binary (typically at
92
+ ``/opt/*venv/bin/headroom`` or similar). Resolving up-front lets the
93
+ subprocess launch even after PATH is cleaned.
94
+ """
95
+
96
+ if os.sep in name or (os.altsep and os.altsep in name):
97
+ return name
98
+ import shutil
99
+
100
+ resolved = shutil.which(name)
101
+ if resolved:
102
+ return resolved
103
+ # Fall back to the bare name; subprocess will raise a clear
104
+ # FileNotFoundError that the case output surfaces.
105
+ return name
106
+
107
+
108
+ def _run_single(case: Case, headroom_bin: str = "headroom") -> bool:
109
+ """Execute one case. Return True on pass, False on fail."""
110
+
111
+ with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{case.name}-") as temp_raw:
112
+ temp_root = Path(temp_raw)
113
+ home = temp_root / "home"
114
+ project = temp_root / "project"
115
+ shim_dir = temp_root / "bin"
116
+ shim_log = temp_root / "shim-log.jsonl"
117
+ home.mkdir(parents=True)
118
+ project.mkdir(parents=True)
119
+
120
+ for shim_name, behavior in case.shims.items():
121
+ make_shim(shim_name, shim_dir, behavior=behavior)
122
+
123
+ # Resolve headroom to its absolute path BEFORE mutating PATH so the
124
+ # shim dir can dominate PATH without losing the headroom binary.
125
+ resolved_bin = _resolve_headroom_bin(headroom_bin)
126
+
127
+ with with_clean_path([shim_dir]) as env:
128
+ env["HOME"] = str(home)
129
+ env["USERPROFILE"] = str(home)
130
+ env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log)
131
+ env.update(case.env_extra)
132
+
133
+ proc = subprocess.run(
134
+ [resolved_bin, *case.argv],
135
+ env=env,
136
+ cwd=str(project),
137
+ capture_output=True,
138
+ text=True,
139
+ encoding="utf-8",
140
+ errors="replace",
141
+ timeout=180,
142
+ )
143
+
144
+ ctx = CaseContext(
145
+ name=case.name,
146
+ home=home,
147
+ project=project,
148
+ shim_dir=shim_dir,
149
+ shim_log=shim_log,
150
+ stdout=proc.stdout,
151
+ stderr=proc.stderr,
152
+ exit_code=proc.returncode,
153
+ )
154
+
155
+ try:
156
+ assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}")
157
+ for needle in case.expected_stdout_contains:
158
+ assert_stdout_contains(proc.stdout, needle)
159
+ for needle in case.expected_stderr_contains:
160
+ assert_stderr_contains(proc.stderr, needle)
161
+ for spec in case.expected_files:
162
+ path = _resolve_placeholder(spec, home=home, project=project)
163
+ if not path.exists():
164
+ raise AssertionError(f"Expected file {path} not found")
165
+ for callback in case.extra_assertions:
166
+ callback(ctx)
167
+ except AssertionError as exc:
168
+ _log(f"FAIL {case.name}: {exc}")
169
+ if proc.stdout.strip():
170
+ _log(f" stdout: {proc.stdout.rstrip()}")
171
+ if proc.stderr.strip():
172
+ _log(f" stderr: {proc.stderr.rstrip()}")
173
+ return False
174
+
175
+ _log(f"PASS {case.name}")
176
+ return True
177
+
178
+
179
+ def _run_in_scratch(
180
+ case: Case,
181
+ *,
182
+ home: Path,
183
+ project: Path,
184
+ shim_dir: Path,
185
+ shim_log: Path,
186
+ headroom_bin: str,
187
+ ) -> bool:
188
+ """Execute one case inside a pre-existing scratch layout.
189
+
190
+ Shims are *added* to ``shim_dir`` (existing shims from prior sequence
191
+ steps are preserved). This enables sequence cases to build up shim state.
192
+ """
193
+
194
+ for shim_name, behavior in case.shims.items():
195
+ make_shim(shim_name, shim_dir, behavior=behavior)
196
+
197
+ resolved_bin = _resolve_headroom_bin(headroom_bin)
198
+
199
+ with with_clean_path([shim_dir]) as env:
200
+ env["HOME"] = str(home)
201
+ env["USERPROFILE"] = str(home)
202
+ env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log)
203
+ env.update(case.env_extra)
204
+
205
+ proc = subprocess.run(
206
+ [resolved_bin, *case.argv],
207
+ env=env,
208
+ cwd=str(project),
209
+ capture_output=True,
210
+ text=True,
211
+ encoding="utf-8",
212
+ errors="replace",
213
+ timeout=180,
214
+ )
215
+
216
+ ctx = CaseContext(
217
+ name=case.name,
218
+ home=home,
219
+ project=project,
220
+ shim_dir=shim_dir,
221
+ shim_log=shim_log,
222
+ stdout=proc.stdout,
223
+ stderr=proc.stderr,
224
+ exit_code=proc.returncode,
225
+ )
226
+
227
+ try:
228
+ assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}")
229
+ for needle in case.expected_stdout_contains:
230
+ assert_stdout_contains(proc.stdout, needle)
231
+ for needle in case.expected_stderr_contains:
232
+ assert_stderr_contains(proc.stderr, needle)
233
+ for spec in case.expected_files:
234
+ path = _resolve_placeholder(spec, home=home, project=project)
235
+ if not path.exists():
236
+ raise AssertionError(f"Expected file {path} not found")
237
+ for callback in case.extra_assertions:
238
+ callback(ctx)
239
+ except AssertionError as exc:
240
+ _log(f"FAIL {case.name}: {exc}")
241
+ if proc.stdout.strip():
242
+ _log(f" stdout: {proc.stdout.rstrip()}")
243
+ if proc.stderr.strip():
244
+ _log(f" stderr: {proc.stderr.rstrip()}")
245
+ return False
246
+
247
+ _log(f"PASS {case.name}")
248
+ return True
249
+
250
+
251
+ def run_cases(
252
+ cases: list[Case],
253
+ *,
254
+ headroom_bin: str = "headroom",
255
+ fail_fast: bool = False,
256
+ ) -> int:
257
+ """Run each case in its own scratch dir. Return exit code (0 = all pass)."""
258
+
259
+ passed = 0
260
+ failed = 0
261
+ for case in cases:
262
+ ok = _run_single(case, headroom_bin=headroom_bin)
263
+ if ok:
264
+ passed += 1
265
+ else:
266
+ failed += 1
267
+ if fail_fast:
268
+ break
269
+
270
+ _log(f"Summary: {passed} passed, {failed} failed, {len(cases)} total")
271
+ return 0 if failed == 0 else 1
272
+
273
+
274
+ def run_case_sequence(
275
+ cases: list[Case],
276
+ *,
277
+ headroom_bin: str = "headroom",
278
+ label: str = "sequence",
279
+ fail_fast: bool = True,
280
+ ) -> int:
281
+ """Run cases sequentially inside a single shared scratch dir.
282
+
283
+ Useful when later cases must observe state left by earlier ones (e.g.
284
+ ``headroom init`` accumulating targets in a shared manifest across
285
+ successive calls).
286
+ """
287
+
288
+ passed = 0
289
+ failed = 0
290
+ with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{label}-") as temp_raw:
291
+ temp_root = Path(temp_raw)
292
+ home = temp_root / "home"
293
+ project = temp_root / "project"
294
+ shim_dir = temp_root / "bin"
295
+ shim_log = temp_root / "shim-log.jsonl"
296
+ home.mkdir(parents=True)
297
+ project.mkdir(parents=True)
298
+
299
+ for case in cases:
300
+ ok = _run_in_scratch(
301
+ case,
302
+ home=home,
303
+ project=project,
304
+ shim_dir=shim_dir,
305
+ shim_log=shim_log,
306
+ headroom_bin=headroom_bin,
307
+ )
308
+ if ok:
309
+ passed += 1
310
+ else:
311
+ failed += 1
312
+ if fail_fast:
313
+ break
314
+
315
+ _log(f"Summary ({label}): {passed} passed, {failed} failed, {len(cases)} total")
316
+ return 0 if failed == 0 else 1
317
+
318
+
319
+ # Allow callers to adopt a different exit strategy (e.g. raising) easily.
320
+ def main_from_cases(cases: list[Case]) -> None:
321
+ """Convenience entry point for ``run.py`` scripts."""
322
+
323
+ code = run_cases(cases)
324
+ sys.exit(code)
325
+
326
+
327
+ __all__ = [
328
+ "Case",
329
+ "CaseContext",
330
+ "main_from_cases",
331
+ "run_case_sequence",
332
+ "run_cases",
333
+ ]
334
+
335
+ # Silence unused-import lint for re-exports used by callers.
336
+ _ = os
e2e/_lib/make_shim.ps1 ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Create a noop executable shim at $Dir\$Name.cmd for use in PATH during
2
+ # native (non-Docker) e2e tests on Windows. Mirrors e2e/_lib/shims.py
3
+ # make_shim(noop).
4
+ #
5
+ # Usage: make_shim.ps1 -Name <name> -Dir <dir>
6
+
7
+ param(
8
+ [Parameter(Mandatory = $true)][string]$Name,
9
+ [Parameter(Mandatory = $true)][string]$Dir
10
+ )
11
+
12
+ $ErrorActionPreference = "Stop"
13
+
14
+ if (-not (Test-Path $Dir)) {
15
+ New-Item -ItemType Directory -Path $Dir -Force | Out-Null
16
+ }
17
+
18
+ $path = Join-Path $Dir "$Name.cmd"
19
+ $content = @"
20
+ @echo off
21
+ exit /b 0
22
+ "@
23
+ Set-Content -Path $path -Value $content -Encoding ASCII -NoNewline
24
+ Write-Output $path
e2e/_lib/make_shim.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Create a noop executable shim at $2/$1 suitable for use in PATH during
3
+ # native (non-Docker) e2e tests. Mirrors e2e/_lib/shims.py make_shim(noop).
4
+ #
5
+ # Usage: make_shim.sh <name> <dir>
6
+ #
7
+ # Exit codes:
8
+ # 0 on success
9
+ # 2 on usage error
10
+
11
+ set -euo pipefail
12
+
13
+ if [ $# -ne 2 ]; then
14
+ echo "usage: $0 <name> <dir>" >&2
15
+ exit 2
16
+ fi
17
+
18
+ name="$1"
19
+ dir="$2"
20
+
21
+ mkdir -p "$dir"
22
+ path="$dir/$name"
23
+ cat >"$path" <<'EOS'
24
+ #!/usr/bin/env bash
25
+ exit 0
26
+ EOS
27
+ chmod +x "$path"
28
+ echo "$path"
e2e/_lib/path_env.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PATH environment helpers for e2e test isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+
11
+ def _minimal_path_dirs() -> list[str]:
12
+ """Directories always needed so Python / basic shell utilities work."""
13
+
14
+ if os.name == "nt":
15
+ system_root = os.environ.get("SystemRoot", r"C:\Windows")
16
+ return [
17
+ rf"{system_root}\System32",
18
+ system_root,
19
+ rf"{system_root}\System32\Wbem",
20
+ rf"{system_root}\System32\WindowsPowerShell\v1.0",
21
+ ]
22
+ # POSIX: keep enough for bash, python3, mkdir, chmod, etc.
23
+ return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
24
+
25
+
26
+ @contextmanager
27
+ def with_clean_path(extra_dirs: list[Path] | None = None) -> Iterator[dict[str, str]]:
28
+ """Set PATH to a minimal known-good value plus ``extra_dirs``.
29
+
30
+ Yields the (already-mutated) environment dict so callers can pass it
31
+ directly to ``subprocess.run(env=...)``. On exit, the previous PATH is
32
+ restored.
33
+ """
34
+
35
+ extras = [str(Path(p)) for p in (extra_dirs or [])]
36
+ new_path = os.pathsep.join(extras + _minimal_path_dirs())
37
+ env = os.environ.copy()
38
+ previous = env.get("PATH")
39
+ env["PATH"] = new_path
40
+ # Also mutate the real environment so ``shutil.which`` inside this process
41
+ # sees the clean PATH. Restore on exit.
42
+ real_previous = os.environ.get("PATH")
43
+ os.environ["PATH"] = new_path
44
+ try:
45
+ yield env
46
+ finally:
47
+ if real_previous is None:
48
+ os.environ.pop("PATH", None)
49
+ else:
50
+ os.environ["PATH"] = real_previous
51
+ if previous is None:
52
+ env.pop("PATH", None)
53
+ else:
54
+ env["PATH"] = previous
e2e/_lib/paths.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-agent settings-file locators for e2e assertions.
2
+
3
+ These paths mirror the logic in ``headroom.cli.init`` so e2e tests can
4
+ verify that the right file was written without importing private init
5
+ helpers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ Agent = Literal["claude", "codex", "copilot", "openclaw"]
14
+ Scope = Literal["user", "local"]
15
+
16
+
17
+ def agent_settings_path(agent: Agent, *, scope: Scope, home: Path, project: Path) -> Path:
18
+ """Return the file that ``headroom init`` should have written for ``agent``.
19
+
20
+ ``home`` is the test's simulated HOME directory and ``project`` is the cwd
21
+ used when invoking ``headroom init``. For global (``-g``) invocations only
22
+ ``home`` matters; for local invocations only ``project`` matters.
23
+ """
24
+
25
+ home = Path(home)
26
+ project = Path(project)
27
+
28
+ if agent == "claude":
29
+ if scope == "user":
30
+ return home / ".claude" / "settings.json"
31
+ return project / ".claude" / "settings.local.json"
32
+
33
+ if agent == "codex":
34
+ if scope == "user":
35
+ return home / ".codex" / "config.toml"
36
+ return project / ".codex" / "config.toml"
37
+
38
+ if agent == "copilot":
39
+ # Copilot init requires -g; no local scope.
40
+ return home / ".copilot" / "config.json"
41
+
42
+ if agent == "openclaw":
43
+ # OpenClaw init is delegated to `headroom wrap openclaw`; it writes
44
+ # the openclaw json under $HOME.
45
+ return home / ".openclaw" / "openclaw.json"
46
+
47
+ raise ValueError(f"Unknown agent: {agent!r}")
e2e/_lib/shims.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-platform agent binary shim factory for e2e tests.
2
+
3
+ A "shim" is a tiny executable with a given name (e.g. `claude`, `codex`) that
4
+ the harness drops into a temporary directory and prepends to PATH. It lets
5
+ tests drive `headroom init` without requiring a real Claude/Codex install.
6
+
7
+ Three behaviors are supported:
8
+
9
+ * ``noop`` — exits 0 with no output. Default.
10
+ * ``fail`` — exits 1 with a short stderr message.
11
+ * ``record-args`` — appends a JSON record of (tool, argv, cwd) to the file at
12
+ ``$HEADROOM_E2E_SHIM_LOG``, then exits 0. Useful for
13
+ asserting that `init claude` invoked
14
+ `claude plugin install` with the right arguments.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import stat
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Literal
24
+
25
+ ShimBehavior = Literal["noop", "fail", "record-args"]
26
+
27
+ _NOOP_SH = """#!/usr/bin/env bash
28
+ exit 0
29
+ """
30
+
31
+ _FAIL_SH = """#!/usr/bin/env bash
32
+ echo "${0##*/}: simulated failure" >&2
33
+ exit 1
34
+ """
35
+
36
+ _RECORD_SH = """#!/usr/bin/env bash
37
+ tool="${0##*/}"
38
+ log="${HEADROOM_E2E_SHIM_LOG:-/dev/null}"
39
+ mkdir -p "$(dirname "$log")" 2>/dev/null || true
40
+ python3 - "$tool" "$log" "$@" <<'PY'
41
+ import json, os, sys
42
+ tool, log, *argv = sys.argv[1:]
43
+ record = {"tool": tool, "argv": argv, "cwd": os.getcwd()}
44
+ if log != "/dev/null":
45
+ with open(log, "a", encoding="utf-8") as handle:
46
+ handle.write(json.dumps(record) + "\\n")
47
+ print(f"{tool} shim executed")
48
+ PY
49
+ exit 0
50
+ """
51
+
52
+ # Windows equivalents. Use `.cmd` so `shutil.which` and PATHEXT find them.
53
+ _NOOP_CMD = "@echo off\r\nexit /b 0\r\n"
54
+
55
+ _FAIL_CMD = "@echo off\r\necho %~n0: simulated failure 1>&2\r\nexit /b 1\r\n"
56
+
57
+ _RECORD_CMD = (
58
+ "@echo off\r\n"
59
+ "setlocal\r\n"
60
+ 'if "%HEADROOM_E2E_SHIM_LOG%"=="" set HEADROOM_E2E_SHIM_LOG=NUL\r\n'
61
+ "python -c \"import json,os,sys; name=r'%~n0'; log=os.environ['HEADROOM_E2E_SHIM_LOG']; "
62
+ "rec={'tool':name,'argv':sys.argv[1:],'cwd':os.getcwd()};\r\n"
63
+ "open(log,'a',encoding='utf-8').write(json.dumps(rec)+chr(10)) if log!='NUL' else None;\r\n"
64
+ "print(f'{name} shim executed')\" %*\r\n"
65
+ "exit /b 0\r\n"
66
+ )
67
+
68
+
69
+ def _is_windows() -> bool:
70
+ return os.name == "nt" or sys.platform == "win32"
71
+
72
+
73
+ def make_shim(name: str, dir: Path, behavior: ShimBehavior = "noop") -> Path:
74
+ """Create an executable shim named ``name`` inside ``dir``.
75
+
76
+ Returns the absolute path to the created shim. On POSIX this is a ``.sh``
77
+ file made executable and named without extension (so ``shutil.which(name)``
78
+ finds it). On Windows this is a ``.cmd`` file — again, ``shutil.which``
79
+ honours ``PATHEXT`` and will find it.
80
+ """
81
+
82
+ dir = Path(dir)
83
+ dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ if _is_windows():
86
+ body = {"noop": _NOOP_CMD, "fail": _FAIL_CMD, "record-args": _RECORD_CMD}[behavior]
87
+ path = dir / f"{name}.cmd"
88
+ path.write_text(body, encoding="utf-8")
89
+ return path
90
+
91
+ body = {"noop": _NOOP_SH, "fail": _FAIL_SH, "record-args": _RECORD_SH}[behavior]
92
+ path = dir / name
93
+ path.write_text(body, encoding="utf-8")
94
+ mode = path.stat().st_mode
95
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
96
+ return path
e2e/init/Dockerfile CHANGED
@@ -24,10 +24,15 @@ COPY headroom ./headroom
24
  COPY .claude-plugin ./.claude-plugin
25
  COPY .github/plugin ./.github/plugin
26
  COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks
 
 
 
 
 
27
  COPY e2e/init ./e2e/init
28
 
29
  RUN python -m venv /opt/headroom-venv && \
30
- /opt/headroom-venv/bin/python -m pip install --upgrade pip && \
31
  /opt/headroom-venv/bin/python -m pip install -e ".[proxy]"
32
 
33
  CMD ["python", "e2e/init/run.py"]
 
24
  COPY .claude-plugin ./.claude-plugin
25
  COPY .github/plugin ./.github/plugin
26
  COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks
27
+ # The init e2e harness imports from e2e._lib; both directories must be
28
+ # present and each must contain an __init__.py so Python sees them as
29
+ # packages rooted at /workspace.
30
+ COPY e2e/__init__.py ./e2e/__init__.py
31
+ COPY e2e/_lib ./e2e/_lib
32
  COPY e2e/init ./e2e/init
33
 
34
  RUN python -m venv /opt/headroom-venv && \
35
+ /opt/headroom-venv/bin/python -m pip install --upgrade "pip<25" && \
36
  /opt/headroom-venv/bin/python -m pip install -e ".[proxy]"
37
 
38
  CMD ["python", "e2e/init/run.py"]
e2e/init/run.py CHANGED
@@ -1,236 +1,336 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- import stat
6
- import subprocess
7
- import sys
8
- import tempfile
9
- import textwrap
10
- from pathlib import Path
11
-
12
- from headroom.cli import init as init_cli
13
-
14
- REPO_ROOT = Path("/workspace")
15
- HEADROOM = "headroom"
16
-
17
-
18
- def log(message: str) -> None:
19
- print(f"[init-e2e] {message}", flush=True)
20
-
21
-
22
- def run(
23
- cmd: list[str],
24
- *,
25
- env: dict[str, str],
26
- cwd: Path,
27
- timeout: int = 180,
28
- ) -> subprocess.CompletedProcess[str]:
29
- log(f"$ {' '.join(cmd)}")
30
- result = subprocess.run(
31
- cmd,
32
- env=env,
33
- cwd=str(cwd),
34
- capture_output=True,
35
- text=True,
36
- encoding="utf-8",
37
- errors="replace",
38
- timeout=timeout,
39
- )
40
- if result.stdout.strip():
41
- print(result.stdout.rstrip(), flush=True)
42
- if result.stderr.strip():
43
- print(result.stderr.rstrip(), file=sys.stderr, flush=True)
44
- if result.returncode != 0:
45
- raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}")
46
- return result
47
-
48
-
49
- def assert_true(condition: bool, message: str) -> None:
50
- if not condition:
51
- raise AssertionError(message)
52
-
53
-
54
- def write_executable(path: Path, content: str) -> None:
55
- path.write_text(content, encoding="utf-8")
56
- path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
57
-
58
-
59
- def read_jsonl(path: Path) -> list[dict[str, object]]:
60
- if not path.exists():
61
- return []
62
- return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
63
-
64
-
65
- def create_agent_shims(shim_dir: Path, log_path: Path) -> None:
66
- shim = textwrap.dedent(
67
- """\
68
- #!/usr/bin/env python3
69
- from __future__ import annotations
70
-
71
- import json
72
- import os
73
- import sys
74
- from pathlib import Path
75
-
76
- record = {
77
- "tool": Path(sys.argv[0]).name,
78
- "argv": sys.argv[1:],
79
- "cwd": os.getcwd(),
80
- }
81
- log_path = Path(os.environ["HEADROOM_INIT_E2E_LOG"])
82
- log_path.parent.mkdir(parents=True, exist_ok=True)
83
- with log_path.open("a", encoding="utf-8") as handle:
84
- handle.write(json.dumps(record) + "\\n")
85
- print(f"{record['tool']} shim executed")
86
- raise SystemExit(0)
87
- """
88
- )
89
- shim_dir.mkdir(parents=True, exist_ok=True)
90
- for name in ("claude", "copilot"):
91
- write_executable(shim_dir / name, shim)
92
-
93
-
94
- def expect_hook_command(command: str, profile: str) -> None:
95
- assert_true("init hook ensure" in command, f"missing init hook ensure in: {command}")
96
- assert_true(f"--profile {profile}" in command, f"missing profile {profile} in: {command}")
97
-
98
-
99
- def read_manifest(home_dir: Path, profile: str) -> dict[str, object]:
100
- path = home_dir / ".headroom" / "deploy" / profile / "manifest.json"
101
- assert_true(path.exists(), f"Expected manifest at {path}")
102
- return json.loads(path.read_text(encoding="utf-8"))
103
-
104
-
105
- def verify_claude_local(home_dir: Path, project_dir: Path, shim_log: Path) -> None:
106
- settings = json.loads(
107
- (project_dir / ".claude" / "settings.local.json").read_text(encoding="utf-8")
108
- )
109
- assert_true(
110
- settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011",
111
- "Claude local settings should point at the requested proxy port",
112
- )
113
- session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
114
- pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
115
- profile = init_cli._local_profile(project_dir)
116
- expect_hook_command(session_start, profile)
117
- expect_hook_command(pre_tool, profile)
118
-
119
- manifest = read_manifest(home_dir, profile)
120
- assert_true("claude" in manifest["targets"], "Claude init should register the claude target")
121
-
122
- claude_calls = [record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "claude"]
123
- assert_true(
124
- claude_calls
125
- == [
126
- ["plugin", "marketplace", "add", str(REPO_ROOT)],
127
- ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"],
128
- ],
129
- f"Unexpected Claude install commands: {claude_calls}",
130
- )
131
-
132
-
133
- def verify_copilot_global(home_dir: Path, shim_log: Path) -> None:
134
- config = json.loads((home_dir / ".copilot" / "config.json").read_text(encoding="utf-8"))
135
- assert_true(
136
- "SessionStart" in config["hooks"], "Copilot config should include SessionStart hooks"
137
- )
138
- assert_true("PreToolUse" in config["hooks"], "Copilot config should include PreToolUse hooks")
139
- session_start = config["hooks"]["SessionStart"][0]["command"]
140
- expect_hook_command(session_start, "init-user")
141
-
142
- for shell_file in (home_dir / ".bashrc", home_dir / ".zshrc", home_dir / ".profile"):
143
- content = shell_file.read_text(encoding="utf-8")
144
- assert_true(
145
- 'export COPILOT_PROVIDER_TYPE="openai"' in content,
146
- f"{shell_file.name} should contain the Copilot provider type",
147
- )
148
- assert_true(
149
- 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"' in content,
150
- f"{shell_file.name} should contain the Copilot provider base URL",
151
- )
152
- assert_true(
153
- 'export COPILOT_PROVIDER_WIRE_API="completions"' in content,
154
- f"{shell_file.name} should contain the Copilot wire API",
155
- )
156
-
157
- copilot_calls = [
158
- record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "copilot"
159
- ]
160
- assert_true(
161
- copilot_calls
162
- == [
163
- ["plugin", "marketplace", "add", str(REPO_ROOT)],
164
- ["plugin", "install", "headroom@headroom-marketplace"],
165
- ],
166
- f"Unexpected Copilot install commands: {copilot_calls}",
167
- )
168
-
169
-
170
- def verify_codex_local(home_dir: Path, project_dir: Path) -> None:
171
- config_path = project_dir / ".codex" / "config.toml"
172
- hooks_path = project_dir / ".codex" / "hooks.json"
173
- config = config_path.read_text(encoding="utf-8")
174
- hooks = json.loads(hooks_path.read_text(encoding="utf-8"))
175
- profile = init_cli._local_profile(project_dir)
176
-
177
- assert_true(
178
- 'base_url = "http://127.0.0.1:9012/v1"' in config,
179
- "Codex config should point at the requested proxy port",
180
- )
181
- assert_true(
182
- config.count("[features]") == 1, "Codex config should keep a single [features] table"
183
- )
184
- assert_true("codex_hooks = true" in config, "Codex config should enable codex_hooks")
185
- command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
186
- expect_hook_command(command, profile)
187
-
188
- manifest = read_manifest(home_dir, profile)
189
- targets = manifest["targets"]
190
- assert_true(set(targets) == {"claude", "codex"}, f"Unexpected merged targets: {targets}")
191
-
192
-
193
- def main() -> None:
194
- with tempfile.TemporaryDirectory(prefix="headroom-init-e2e-") as temp_root_raw:
195
- temp_root = Path(temp_root_raw)
196
- home_dir = temp_root / "home"
197
- project_dir = temp_root / "project"
198
- shim_dir = temp_root / "bin"
199
- shim_log = temp_root / "shim-log.jsonl"
200
- home_dir.mkdir(parents=True)
201
- project_dir.mkdir(parents=True)
202
- create_agent_shims(shim_dir, shim_log)
203
-
204
- env = os.environ.copy()
205
- env["HOME"] = str(home_dir)
206
- env["USERPROFILE"] = str(home_dir)
207
- env["HEADROOM_INIT_E2E_LOG"] = str(shim_log)
208
- env["PATH"] = f"{shim_dir}:{env['PATH']}"
209
-
210
- run([HEADROOM, "init", "--port", "9011", "claude"], env=env, cwd=project_dir)
211
- verify_claude_local(home_dir, project_dir, shim_log)
212
-
213
- run(
214
- [
215
- HEADROOM,
216
- "init",
217
- "-g",
218
- "--port",
219
- "9005",
220
- "--backend",
221
- "openai",
222
- "copilot",
223
- ],
224
- env=env,
225
- cwd=project_dir,
226
- )
227
- verify_copilot_global(home_dir, shim_log)
228
-
229
- run([HEADROOM, "init", "--port", "9012", "codex"], env=env, cwd=project_dir)
230
- verify_codex_local(home_dir, project_dir)
231
-
232
- log("Init e2e completed successfully")
233
-
234
-
235
- if __name__ == "__main__":
236
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Docker e2e cases for ``headroom init``.
2
+
3
+ Every case is described declaratively with :class:`Case` from
4
+ ``e2e/_lib/harness.py``. Three groups run in order:
5
+
6
+ 1. **existing sequence**: preserves the original scenario that exercised
7
+ ``headroom init claude`` (local) -> ``init -g copilot`` (global) ->
8
+ ``init codex`` (local), sharing scratch state so manifest-merge is
9
+ exercised end-to-end.
10
+ 2. **bare ``init -g`` detection**: verifies the UX regression from #245
11
+ stays fixed — both "no shims found" (friendly error, exit 1) and
12
+ "all shims found" (exit 0, all four agents configured).
13
+ 3. **per-subcommand**: one case per ``init -g <agent>`` with only that
14
+ agent's shim on PATH, so the explicit path is covered independently.
15
+
16
+ The fourth group covers ``--verbose`` output going to stderr.
17
+
18
+ Run directly: ``python e2e/init/run.py`` (inside the Docker image built
19
+ from ``e2e/init/Dockerfile``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ # Add repo root to sys.path so the harness import works whether the file is
29
+ # invoked as ``python e2e/init/run.py`` or ``python -m e2e.init.run``.
30
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
31
+ if str(_REPO_ROOT) not in sys.path:
32
+ sys.path.insert(0, str(_REPO_ROOT))
33
+
34
+ from e2e._lib import ( # noqa: E402
35
+ Case,
36
+ CaseContext,
37
+ run_case_sequence,
38
+ run_cases,
39
+ )
40
+ from headroom.cli import init as init_cli # noqa: E402
41
+
42
+ # ----- helpers reused across cases --------------------------------------------
43
+
44
+ # Docker image builds the workspace at /workspace; the marketplace source
45
+ # falls back to that repo checkout when a local marketplace manifest is found.
46
+ REPO_ROOT_IN_CONTAINER = Path("/workspace")
47
+
48
+
49
+ def _read_jsonl(path: Path) -> list[dict[str, object]]:
50
+ if not path.exists():
51
+ return []
52
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
53
+
54
+
55
+ def _expect_hook_command(command: str, profile: str) -> None:
56
+ if "init hook ensure" not in command:
57
+ raise AssertionError(f"missing 'init hook ensure' in: {command}")
58
+ if f"--profile {profile}" not in command:
59
+ raise AssertionError(f"missing '--profile {profile}' in: {command}")
60
+
61
+
62
+ def _read_manifest(home: Path, profile: str) -> dict[str, object]:
63
+ path = home / ".headroom" / "deploy" / profile / "manifest.json"
64
+ if not path.exists():
65
+ raise AssertionError(f"Expected manifest at {path}")
66
+ return json.loads(path.read_text(encoding="utf-8"))
67
+
68
+
69
+ # ----- existing-flow assertions (ported verbatim from the old run.py) ---------
70
+
71
+
72
+ def _verify_claude_local(ctx: CaseContext) -> None:
73
+ settings_path = ctx.project / ".claude" / "settings.local.json"
74
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
75
+ if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:9011":
76
+ raise AssertionError(
77
+ f"Claude local settings should point at port 9011, got "
78
+ f"{settings['env']['ANTHROPIC_BASE_URL']!r}"
79
+ )
80
+ session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
81
+ pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
82
+ profile = init_cli._local_profile(ctx.project)
83
+ _expect_hook_command(session_start, profile)
84
+ _expect_hook_command(pre_tool, profile)
85
+
86
+ manifest = _read_manifest(ctx.home, profile)
87
+ if "claude" not in manifest["targets"]:
88
+ raise AssertionError(
89
+ f"Claude init should register the claude target, got {manifest['targets']}"
90
+ )
91
+
92
+ claude_calls = [
93
+ record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "claude"
94
+ ]
95
+ expected = [
96
+ ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)],
97
+ ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"],
98
+ ]
99
+ if claude_calls != expected:
100
+ raise AssertionError(f"Unexpected Claude install commands: {claude_calls}")
101
+
102
+
103
+ def _verify_copilot_global(ctx: CaseContext) -> None:
104
+ config = json.loads((ctx.home / ".copilot" / "config.json").read_text(encoding="utf-8"))
105
+ if "SessionStart" not in config["hooks"]:
106
+ raise AssertionError("Copilot config missing SessionStart hooks")
107
+ if "PreToolUse" not in config["hooks"]:
108
+ raise AssertionError("Copilot config missing PreToolUse hooks")
109
+ session_start = config["hooks"]["SessionStart"][0]["command"]
110
+ _expect_hook_command(session_start, "init-user")
111
+
112
+ for shell_file in (ctx.home / ".bashrc", ctx.home / ".zshrc", ctx.home / ".profile"):
113
+ content = shell_file.read_text(encoding="utf-8")
114
+ for literal in (
115
+ 'export COPILOT_PROVIDER_TYPE="openai"',
116
+ 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"',
117
+ 'export COPILOT_PROVIDER_WIRE_API="completions"',
118
+ ):
119
+ if literal not in content:
120
+ raise AssertionError(f"{shell_file.name} missing {literal!r}")
121
+
122
+ copilot_calls = [
123
+ record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "copilot"
124
+ ]
125
+ expected = [
126
+ ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)],
127
+ ["plugin", "install", "headroom@headroom-marketplace"],
128
+ ]
129
+ if copilot_calls != expected:
130
+ raise AssertionError(f"Unexpected Copilot install commands: {copilot_calls}")
131
+
132
+
133
+ def _verify_codex_local(ctx: CaseContext) -> None:
134
+ config = (ctx.project / ".codex" / "config.toml").read_text(encoding="utf-8")
135
+ hooks = json.loads((ctx.project / ".codex" / "hooks.json").read_text(encoding="utf-8"))
136
+ profile = init_cli._local_profile(ctx.project)
137
+
138
+ if 'base_url = "http://127.0.0.1:9012/v1"' not in config:
139
+ raise AssertionError("Codex config should point at the requested proxy port (9012)")
140
+ if config.count("[features]") != 1:
141
+ raise AssertionError("Codex config should keep a single [features] table")
142
+ if "codex_hooks = true" not in config:
143
+ raise AssertionError("Codex config should enable codex_hooks")
144
+ command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
145
+ _expect_hook_command(command, profile)
146
+
147
+ manifest = _read_manifest(ctx.home, profile)
148
+ targets = manifest["targets"]
149
+ if set(targets) != {"claude", "codex"}:
150
+ raise AssertionError(f"Unexpected merged targets: {targets}")
151
+
152
+
153
+ # ----- new cases (issue #245 fix + per-subcommand coverage) -------------------
154
+
155
+
156
+ def _verify_claude_global(ctx: CaseContext) -> None:
157
+ settings = json.loads((ctx.home / ".claude" / "settings.json").read_text(encoding="utf-8"))
158
+ if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787":
159
+ raise AssertionError(
160
+ f"Claude user settings should default to port 8787, got "
161
+ f"{settings['env']['ANTHROPIC_BASE_URL']!r}"
162
+ )
163
+ _expect_hook_command(
164
+ settings["hooks"]["SessionStart"][0]["hooks"][0]["command"],
165
+ init_cli._GLOBAL_PROFILE,
166
+ )
167
+
168
+
169
+ def _verify_codex_global(ctx: CaseContext) -> None:
170
+ config = (ctx.home / ".codex" / "config.toml").read_text(encoding="utf-8")
171
+ if 'base_url = "http://127.0.0.1:8787/v1"' not in config:
172
+ raise AssertionError("Codex user config should point at port 8787 by default")
173
+ if "codex_hooks = true" not in config:
174
+ raise AssertionError("Codex user config should enable codex_hooks")
175
+ hooks = json.loads((ctx.home / ".codex" / "hooks.json").read_text(encoding="utf-8"))
176
+ _expect_hook_command(
177
+ hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"],
178
+ init_cli._GLOBAL_PROFILE,
179
+ )
180
+
181
+
182
+ # ----- case tables ------------------------------------------------------------
183
+
184
+
185
+ def existing_sequence_cases() -> list[Case]:
186
+ """Preserves the original run.py scenario in one shared scratch."""
187
+
188
+ return [
189
+ Case(
190
+ name="seq_claude_local",
191
+ argv=["init", "--port", "9011", "claude"],
192
+ shims={"claude": "record-args", "copilot": "record-args"},
193
+ expected_exit=0,
194
+ expected_stdout_contains=["Configured Claude Code (local scope)"],
195
+ extra_assertions=[_verify_claude_local],
196
+ ),
197
+ Case(
198
+ name="seq_copilot_global",
199
+ argv=["init", "-g", "--port", "9005", "--backend", "openai", "copilot"],
200
+ shims={}, # reuse shims from prior case in the sequence
201
+ expected_exit=0,
202
+ expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"],
203
+ extra_assertions=[_verify_copilot_global],
204
+ ),
205
+ Case(
206
+ name="seq_codex_local",
207
+ argv=["init", "--port", "9012", "codex"],
208
+ shims={},
209
+ expected_exit=0,
210
+ expected_stdout_contains=["Configured Codex (local scope)"],
211
+ extra_assertions=[_verify_codex_local],
212
+ ),
213
+ ]
214
+
215
+
216
+ def bare_init_g_cases() -> list[Case]:
217
+ """Bare ``headroom init -g`` — the direct coverage of issue #245."""
218
+
219
+ return [
220
+ Case(
221
+ name="bare_init_g_no_shims",
222
+ argv=["init", "-g"],
223
+ shims={}, # nothing on PATH
224
+ expected_exit=1,
225
+ expected_stderr_contains=[
226
+ # every target should be listed so the user knows what was tried
227
+ "claude",
228
+ "codex",
229
+ "copilot",
230
+ "openclaw",
231
+ # concrete escape hatch — exactly what the user should type next
232
+ "headroom init -g claude",
233
+ # confirm -g itself is still the right flag
234
+ "-g",
235
+ ],
236
+ ),
237
+ Case(
238
+ name="bare_init_g_with_all_shims",
239
+ argv=["init", "-g"],
240
+ shims={
241
+ "claude": "record-args",
242
+ "codex": "noop",
243
+ "copilot": "record-args",
244
+ "openclaw": "noop",
245
+ },
246
+ expected_exit=0,
247
+ expected_stdout_contains=[
248
+ "Configured Claude Code (user scope)",
249
+ "Configured GitHub Copilot CLI (user scope)",
250
+ "Configured Codex (user scope)",
251
+ ],
252
+ ),
253
+ ]
254
+
255
+
256
+ def per_subcommand_cases() -> list[Case]:
257
+ """One case per ``headroom init -g <agent>`` with only that agent's shim."""
258
+
259
+ return [
260
+ Case(
261
+ name="init_g_claude_explicit",
262
+ argv=["init", "-g", "claude"],
263
+ shims={"claude": "record-args"},
264
+ expected_exit=0,
265
+ expected_stdout_contains=["Configured Claude Code (user scope)"],
266
+ expected_files=["{home}/.claude/settings.json"],
267
+ extra_assertions=[_verify_claude_global],
268
+ ),
269
+ Case(
270
+ name="init_g_codex_explicit",
271
+ argv=["init", "-g", "codex"],
272
+ shims={"codex": "noop"},
273
+ expected_exit=0,
274
+ expected_stdout_contains=["Configured Codex (user scope)"],
275
+ expected_files=[
276
+ "{home}/.codex/config.toml",
277
+ "{home}/.codex/hooks.json",
278
+ ],
279
+ extra_assertions=[_verify_codex_global],
280
+ ),
281
+ Case(
282
+ name="init_g_copilot_explicit",
283
+ argv=["init", "-g", "copilot"],
284
+ shims={"copilot": "record-args"},
285
+ expected_exit=0,
286
+ expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"],
287
+ expected_files=["{home}/.copilot/config.json"],
288
+ ),
289
+ # openclaw delegates to `headroom wrap openclaw` which has its own
290
+ # (more expensive) init path and isn't stubbable with a simple shim.
291
+ # We assert it fails fast with a clear error when not installed, and
292
+ # rely on the `bare_init_g_with_all_shims` case (which uses a noop
293
+ # openclaw shim + claude/codex/copilot shims) to cover the success
294
+ # path alongside the other agents.
295
+ Case(
296
+ name="init_g_openclaw_missing",
297
+ argv=["init", "-g", "openclaw"],
298
+ shims={},
299
+ expected_exit=1,
300
+ ),
301
+ ]
302
+
303
+
304
+ def verbose_cases() -> list[Case]:
305
+ """Verbose flag smoke tests — debug lines should appear on stderr."""
306
+
307
+ return [
308
+ Case(
309
+ name="init_verbose_no_shims",
310
+ argv=["init", "-v", "-g"],
311
+ shims={},
312
+ expected_exit=1,
313
+ expected_stderr_contains=[
314
+ # A few structural markers from the verbose log. Kept loose so
315
+ # minor wording tweaks don't break the test.
316
+ "detect_init_targets",
317
+ "claude",
318
+ "global_scope=True",
319
+ ],
320
+ ),
321
+ ]
322
+
323
+
324
+ def main() -> None:
325
+ rc = 0
326
+ rc |= run_case_sequence(existing_sequence_cases(), label="existing-sequence")
327
+ rc |= run_cases(bare_init_g_cases())
328
+ rc |= run_cases(per_subcommand_cases())
329
+ rc |= run_cases(verbose_cases())
330
+ if rc != 0:
331
+ raise SystemExit(rc)
332
+ print("[e2e] init e2e completed successfully", flush=True)
333
+
334
+
335
+ if __name__ == "__main__":
336
+ main()
headroom/cli/init.py CHANGED
@@ -1,679 +1,817 @@
1
- """Durable agent initialization commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import shlex
8
- import shutil
9
- import subprocess
10
- from hashlib import sha1
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- import click
15
-
16
- from headroom.install.models import ConfigScope, InstallPreset, RuntimeKind, SupervisorKind
17
- from headroom.install.paths import claude_settings_path, codex_config_path, validate_profile_name
18
- from headroom.install.planner import build_manifest
19
- from headroom.install.providers import _apply_unix_env_scope, _apply_windows_env_scope
20
- from headroom.install.runtime import (
21
- resolve_headroom_command,
22
- start_detached_agent,
23
- start_persistent_docker,
24
- stop_runtime,
25
- wait_ready,
26
- )
27
- from headroom.install.state import load_manifest, save_manifest
28
- from headroom.install.supervisors import start_supervisor
29
-
30
- from .main import main
31
-
32
- _GLOBAL_PROFILE = "init-user"
33
- _CLAUDE_HOOK_MARKER = "headroom-init-claude"
34
- _COPILOT_HOOK_MARKER = "headroom-init-copilot"
35
- _CODEX_HOOK_MARKER = "headroom-init-codex"
36
- _CODEX_PROVIDER_MARKER_START = "# --- Headroom init provider ---"
37
- _CODEX_PROVIDER_MARKER_END = "# --- end Headroom init provider ---"
38
- _CODEX_FEATURE_MARKER_START = "# --- Headroom init features ---"
39
- _CODEX_FEATURE_MARKER_END = "# --- end Headroom init features ---"
40
- _SUPPORTED_TARGETS = ("claude", "copilot", "codex", "openclaw")
41
- _LOCAL_TARGETS = {"claude", "codex"}
42
- _GLOBAL_TARGETS = {"claude", "copilot", "codex", "openclaw"}
43
-
44
-
45
- def _command_string(parts: list[str]) -> str:
46
- if os.name == "nt":
47
- return subprocess.list2cmdline(parts)
48
- return shlex.join(parts)
49
-
50
-
51
- def _hook_command(*parts: str) -> str:
52
- return _command_string([*resolve_headroom_command(), "init", "hook", "ensure", *parts])
53
-
54
-
55
- def _powershell_matcher() -> str:
56
- return "Bash|PowerShell" if os.name == "nt" else "Bash"
57
-
58
-
59
- def _local_profile(cwd: Path | None = None) -> str:
60
- root = (cwd or Path.cwd()).resolve()
61
- slug = "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in root.name.lower()).strip(
62
- "-"
63
- )
64
- digest = sha1(str(root).encode("utf-8")).hexdigest()[:8]
65
- return validate_profile_name(f"init-{slug or 'repo'}-{digest}")
66
-
67
-
68
- def _runtime_profile(global_scope: bool, cwd: Path | None = None) -> str:
69
- return _GLOBAL_PROFILE if global_scope else _local_profile(cwd)
70
-
71
-
72
- def _copilot_config_path() -> Path:
73
- return Path.home() / ".copilot" / "config.json"
74
-
75
-
76
- def _codex_hooks_path(global_scope: bool) -> Path:
77
- return (Path.home() if global_scope else Path.cwd()) / ".codex" / "hooks.json"
78
-
79
-
80
- def _claude_scope_path(global_scope: bool) -> Path:
81
- if global_scope:
82
- return claude_settings_path()
83
- return Path.cwd() / ".claude" / "settings.local.json"
84
-
85
-
86
- def _codex_scope_path(global_scope: bool) -> Path:
87
- if global_scope:
88
- return codex_config_path()
89
- return Path.cwd() / ".codex" / "config.toml"
90
-
91
-
92
- def _json_file(path: Path) -> dict[str, Any]:
93
- if not path.exists():
94
- return {}
95
- content = path.read_text(encoding="utf-8").strip()
96
- if not content:
97
- return {}
98
- payload = json.loads(content)
99
- return payload if isinstance(payload, dict) else {}
100
-
101
-
102
- def _write_json(path: Path, payload: dict[str, Any]) -> None:
103
- path.parent.mkdir(parents=True, exist_ok=True)
104
- path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
105
-
106
-
107
- def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
108
- payload = _json_file(path)
109
- env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
110
- env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
111
- payload["env"] = env_map
112
-
113
- hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
114
- command = _hook_command("--profile", profile)
115
- for event, matcher in (
116
- ("SessionStart", "startup|resume"),
117
- ("PreToolUse", _powershell_matcher()),
118
- ):
119
- entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else []
120
- retained: list[dict[str, Any]] = []
121
- for entry in entries:
122
- if not isinstance(entry, dict):
123
- retained.append(entry)
124
- continue
125
- hook_items = entry.get("hooks")
126
- if not isinstance(hook_items, list):
127
- retained.append(entry)
128
- continue
129
- has_headroom = any(
130
- isinstance(item, dict)
131
- and item.get("command")
132
- and _CLAUDE_HOOK_MARKER in str(item.get("command"))
133
- for item in hook_items
134
- )
135
- if not has_headroom:
136
- retained.append(entry)
137
- retained.append(
138
- {
139
- "matcher": matcher,
140
- "hooks": [
141
- {
142
- "type": "command",
143
- "command": f"{command} --marker {_CLAUDE_HOOK_MARKER}",
144
- "timeout": 15,
145
- }
146
- ],
147
- }
148
- )
149
- hooks[event] = retained
150
- payload["hooks"] = hooks
151
- _write_json(path, payload)
152
-
153
-
154
- def _ensure_copilot_hooks(path: Path, profile: str) -> None:
155
- payload = _json_file(path)
156
- hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
157
- command = f"{_hook_command('--profile', profile)} --marker {_COPILOT_HOOK_MARKER}"
158
- for event in ("SessionStart", "PreToolUse"):
159
- entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else []
160
- retained = [
161
- entry
162
- for entry in entries
163
- if not (
164
- isinstance(entry, dict) and _COPILOT_HOOK_MARKER in str(entry.get("command", ""))
165
- )
166
- ]
167
- retained.append({"type": "command", "command": command, "cwd": ".", "timeout": 15})
168
- hooks[event] = retained
169
- payload["hooks"] = hooks
170
- _write_json(path, payload)
171
-
172
-
173
- def _replace_marker_block(content: str, marker_start: str, marker_end: str, block: str) -> str:
174
- if marker_start in content and marker_end in content:
175
- start = content.index(marker_start)
176
- end = content.index(marker_end) + len(marker_end)
177
- content = content[:start].rstrip() + "\n\n" + content[end:].lstrip()
178
- return (content.rstrip() + "\n\n" + block.strip() + "\n").lstrip()
179
-
180
-
181
- def _ensure_codex_provider(path: Path, port: int) -> None:
182
- block = (
183
- f"{_CODEX_PROVIDER_MARKER_START}\n"
184
- 'model_provider = "headroom"\n\n'
185
- "[model_providers.headroom]\n"
186
- 'name = "Headroom init proxy"\n'
187
- f'base_url = "http://127.0.0.1:{port}/v1"\n'
188
- 'env_key = "OPENAI_API_KEY"\n'
189
- "requires_openai_auth = true\n"
190
- "supports_websockets = true\n"
191
- f"{_CODEX_PROVIDER_MARKER_END}"
192
- )
193
- content = path.read_text(encoding="utf-8") if path.exists() else ""
194
- content = _replace_marker_block(
195
- content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block
196
- )
197
- path.parent.mkdir(parents=True, exist_ok=True)
198
- path.write_text(content, encoding="utf-8")
199
-
200
-
201
- def _ensure_codex_feature_flag(path: Path) -> None:
202
- content = path.read_text(encoding="utf-8") if path.exists() else ""
203
- if _CODEX_FEATURE_MARKER_START in content and _CODEX_FEATURE_MARKER_END in content:
204
- block = f"{_CODEX_FEATURE_MARKER_START}\ncodex_hooks = true\n{_CODEX_FEATURE_MARKER_END}"
205
- content = _replace_marker_block(
206
- content,
207
- _CODEX_FEATURE_MARKER_START,
208
- _CODEX_FEATURE_MARKER_END,
209
- block,
210
- )
211
- elif "[features]" in content:
212
- lines = content.splitlines()
213
- inserted = False
214
- for index, line in enumerate(lines):
215
- if line.strip() != "[features]":
216
- continue
217
- section_end = index + 1
218
- while section_end < len(lines) and not (
219
- lines[section_end].startswith("[") and lines[section_end].endswith("]")
220
- ):
221
- if "codex_hooks" in lines[section_end]:
222
- inserted = True
223
- break
224
- section_end += 1
225
- if not inserted:
226
- lines[index + 1 : index + 1] = [
227
- _CODEX_FEATURE_MARKER_START,
228
- "codex_hooks = true",
229
- _CODEX_FEATURE_MARKER_END,
230
- ]
231
- inserted = True
232
- break
233
- content = "\n".join(lines).rstrip() + "\n"
234
- if not inserted:
235
- content = (
236
- content.rstrip()
237
- + "\n\n[features]\n"
238
- + _CODEX_FEATURE_MARKER_START
239
- + "\n"
240
- + "codex_hooks = true\n"
241
- + _CODEX_FEATURE_MARKER_END
242
- + "\n"
243
- )
244
- else:
245
- content = (
246
- content.rstrip()
247
- + "\n\n[features]\n"
248
- + _CODEX_FEATURE_MARKER_START
249
- + "\n"
250
- + "codex_hooks = true\n"
251
- + _CODEX_FEATURE_MARKER_END
252
- + "\n"
253
- ).lstrip()
254
- path.parent.mkdir(parents=True, exist_ok=True)
255
- path.write_text(content, encoding="utf-8")
256
-
257
-
258
- def _ensure_codex_hooks(path: Path, profile: str) -> None:
259
- command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}"
260
- payload = {
261
- "hooks": {
262
- "SessionStart": [
263
- {
264
- "matcher": "startup|resume",
265
- "hooks": [{"type": "command", "command": command, "timeout": 15}],
266
- }
267
- ],
268
- "PreToolUse": [
269
- {
270
- "matcher": "Bash",
271
- "hooks": [{"type": "command", "command": command, "timeout": 15}],
272
- }
273
- ],
274
- }
275
- }
276
- path.parent.mkdir(parents=True, exist_ok=True)
277
- path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
278
-
279
-
280
- def _manifest_changed(
281
- existing: Any,
282
- *,
283
- port: int,
284
- backend: str,
285
- anyllm_provider: str | None,
286
- region: str | None,
287
- memory: bool,
288
- ) -> bool:
289
- return any(
290
- [
291
- getattr(existing, "port", port) != port,
292
- getattr(existing, "backend", backend) != backend,
293
- getattr(existing, "anyllm_provider", anyllm_provider) != anyllm_provider,
294
- getattr(existing, "region", region) != region,
295
- getattr(existing, "memory_enabled", memory) != memory,
296
- ]
297
- )
298
-
299
-
300
- def _ensure_runtime_manifest(
301
- *,
302
- global_scope: bool,
303
- targets: list[str],
304
- port: int,
305
- backend: str,
306
- anyllm_provider: str | None,
307
- region: str | None,
308
- memory: bool,
309
- ) -> str:
310
- profile = _runtime_profile(global_scope)
311
- existing = load_manifest(profile)
312
- merged_targets = sorted(set(existing.targets if existing else []).union(targets))
313
- manifest = build_manifest(
314
- profile=profile,
315
- preset=InstallPreset.PERSISTENT_TASK.value,
316
- runtime_kind=RuntimeKind.PYTHON.value,
317
- scope=ConfigScope.USER.value,
318
- provider_mode="manual",
319
- targets=merged_targets,
320
- port=port,
321
- backend=backend,
322
- anyllm_provider=anyllm_provider,
323
- region=region,
324
- proxy_mode="token",
325
- memory_enabled=memory,
326
- telemetry_enabled=True,
327
- image="ghcr.io/chopratejas/headroom:latest",
328
- )
329
- manifest.supervisor_kind = SupervisorKind.NONE.value
330
- manifest.artifacts = []
331
- manifest.mutations = existing.mutations if existing else []
332
- if existing is not None and _manifest_changed(
333
- existing,
334
- port=port,
335
- backend=backend,
336
- anyllm_provider=anyllm_provider,
337
- region=region,
338
- memory=memory,
339
- ):
340
- try:
341
- stop_runtime(existing)
342
- except Exception:
343
- pass
344
- save_manifest(manifest)
345
- return profile
346
-
347
-
348
- def _env_manifest(values: dict[str, str]) -> Any:
349
- return build_manifest(
350
- profile="init-env",
351
- preset=InstallPreset.PERSISTENT_TASK.value,
352
- runtime_kind=RuntimeKind.PYTHON.value,
353
- scope=ConfigScope.USER.value,
354
- provider_mode="manual",
355
- targets=["copilot"],
356
- port=8787,
357
- backend="anthropic",
358
- anyllm_provider=None,
359
- region=None,
360
- proxy_mode="token",
361
- memory_enabled=False,
362
- telemetry_enabled=True,
363
- image="ghcr.io/chopratejas/headroom:latest",
364
- )
365
-
366
-
367
- def _apply_user_env(values: dict[str, str]) -> None:
368
- manifest = _env_manifest(values)
369
- manifest.base_env = {}
370
- manifest.tool_envs = {"copilot": values}
371
- if os.name == "nt":
372
- _apply_windows_env_scope(manifest)
373
- else:
374
- _apply_unix_env_scope(manifest)
375
-
376
-
377
- def _resolve_copilot_env(port: int, backend: str) -> dict[str, str]:
378
- if backend == "anthropic":
379
- return {
380
- "COPILOT_PROVIDER_TYPE": "anthropic",
381
- "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
382
- }
383
- return {
384
- "COPILOT_PROVIDER_TYPE": "openai",
385
- "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
386
- "COPILOT_PROVIDER_WIRE_API": "completions",
387
- }
388
-
389
-
390
- def _marketplace_source() -> str:
391
- override = os.environ.get("HEADROOM_MARKETPLACE_SOURCE")
392
- if override:
393
- return override
394
- repo_root = Path(__file__).resolve().parents[2]
395
- if (repo_root / ".claude-plugin" / "marketplace.json").exists():
396
- return str(repo_root)
397
- return "chopratejas/headroom"
398
-
399
-
400
- def _run_checked(command: list[str], *, action: str) -> None:
401
- result = subprocess.run(
402
- command,
403
- capture_output=True,
404
- text=True,
405
- encoding="utf-8",
406
- errors="replace",
407
- )
408
- if result.returncode == 0:
409
- return
410
- detail = "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
411
- if "already" in detail.lower() or "exists" in detail.lower():
412
- return
413
- raise click.ClickException(f"{action} failed: {detail or result.returncode}")
414
-
415
-
416
- def _install_claude_marketplace(scope: str) -> None:
417
- claude_bin = shutil.which("claude")
418
- if not claude_bin:
419
- raise click.ClickException("'claude' not found in PATH. Install Claude Code first.")
420
- source = _marketplace_source()
421
- _run_checked(
422
- [claude_bin, "plugin", "marketplace", "add", source], action="claude marketplace add"
423
- )
424
- _run_checked(
425
- [claude_bin, "plugin", "install", "headroom@headroom-marketplace", "--scope", scope],
426
- action="claude plugin install",
427
- )
428
-
429
-
430
- def _install_copilot_marketplace() -> None:
431
- copilot_bin = shutil.which("copilot")
432
- if not copilot_bin:
433
- raise click.ClickException("'copilot' not found in PATH. Install GitHub Copilot CLI first.")
434
- source = _marketplace_source()
435
- _run_checked(
436
- [copilot_bin, "plugin", "marketplace", "add", source],
437
- action="copilot marketplace add",
438
- )
439
- _run_checked(
440
- [copilot_bin, "plugin", "install", "headroom@headroom-marketplace"],
441
- action="copilot plugin install",
442
- )
443
-
444
-
445
- def _ensure_profile_running(profile: str) -> None:
446
- manifest = load_manifest(profile)
447
- if manifest is None:
448
- return
449
- if wait_ready(manifest, timeout_seconds=1):
450
- return
451
- try:
452
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
453
- start_persistent_docker(manifest)
454
- elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
455
- start_supervisor(manifest)
456
- else:
457
- start_detached_agent(manifest.profile)
458
- wait_ready(manifest, timeout_seconds=45)
459
- except Exception:
460
- return
461
-
462
-
463
- def detect_init_targets(global_scope: bool) -> list[str]:
464
- allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS
465
- detected: list[str] = []
466
- for target in _SUPPORTED_TARGETS:
467
- if target not in allowed:
468
- continue
469
- if shutil.which(target):
470
- detected.append(target)
471
- return detected
472
-
473
-
474
- def _init_claude(*, global_scope: bool, profile: str, port: int) -> None:
475
- _ensure_claude_hooks(_claude_scope_path(global_scope), profile, port)
476
- _install_claude_marketplace("user" if global_scope else "local")
477
- click.echo(f"Configured Claude Code ({'user' if global_scope else 'local'} scope).")
478
- click.echo("Restart Claude Code to activate Headroom hooks and provider routing.")
479
-
480
-
481
- def _init_copilot(*, global_scope: bool, profile: str, port: int, backend: str) -> None:
482
- if not global_scope:
483
- raise click.ClickException(
484
- "Copilot durable init currently requires -g (current-user scope)."
485
- )
486
- _ensure_copilot_hooks(_copilot_config_path(), profile)
487
- _apply_user_env(_resolve_copilot_env(port, backend))
488
- _install_copilot_marketplace()
489
- click.echo("Configured GitHub Copilot CLI (user scope).")
490
- click.echo("Restart Copilot CLI to activate Headroom hooks and provider routing.")
491
-
492
-
493
- def _init_codex(*, global_scope: bool, profile: str, port: int) -> None:
494
- config_path = _codex_scope_path(global_scope)
495
- _ensure_codex_provider(config_path, port)
496
- _ensure_codex_feature_flag(config_path)
497
- _ensure_codex_hooks(_codex_hooks_path(global_scope), profile)
498
- click.echo(f"Configured Codex ({'user' if global_scope else 'local'} scope).")
499
- if os.name == "nt":
500
- click.echo(
501
- "Codex hooks are currently disabled upstream on Windows; provider routing was still installed."
502
- )
503
- click.echo("Restart Codex to activate Headroom configuration.")
504
-
505
-
506
- def _init_openclaw(*, global_scope: bool, port: int) -> None:
507
- if not global_scope:
508
- raise click.ClickException(
509
- "OpenClaw durable init currently requires -g (current-user scope)."
510
- )
511
- command = [*resolve_headroom_command(), "wrap", "openclaw", "--proxy-port", str(port)]
512
- result = subprocess.run(command)
513
- if result.returncode != 0:
514
- raise SystemExit(result.returncode)
515
-
516
-
517
- def _run_init_targets(
518
- *,
519
- targets: list[str],
520
- global_scope: bool,
521
- port: int,
522
- backend: str,
523
- anyllm_provider: str | None,
524
- region: str | None,
525
- memory: bool,
526
- ) -> None:
527
- runtime_targets = [target for target in targets if target != "openclaw"]
528
- profile = _ensure_runtime_manifest(
529
- global_scope=global_scope,
530
- targets=runtime_targets,
531
- port=port,
532
- backend=backend,
533
- anyllm_provider=anyllm_provider,
534
- region=region,
535
- memory=memory,
536
- )
537
- for target in targets:
538
- if target == "claude":
539
- _init_claude(global_scope=global_scope, profile=profile, port=port)
540
- elif target == "copilot":
541
- _init_copilot(global_scope=global_scope, profile=profile, port=port, backend=backend)
542
- elif target == "codex":
543
- _init_codex(global_scope=global_scope, profile=profile, port=port)
544
- elif target == "openclaw":
545
- _init_openclaw(global_scope=global_scope, port=port)
546
-
547
-
548
- @main.group(invoke_without_command=True)
549
- @click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.")
550
- @click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.")
551
- @click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.")
552
- @click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.")
553
- @click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
554
- @click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
555
- @click.pass_context
556
- def init(
557
- ctx: click.Context,
558
- global_scope: bool,
559
- port: int,
560
- backend: str,
561
- anyllm_provider: str | None,
562
- region: str | None,
563
- memory: bool,
564
- ) -> None:
565
- """Install durable Headroom integrations for supported agents."""
566
- if ctx.invoked_subcommand is not None:
567
- ctx.obj = {
568
- "global_scope": global_scope,
569
- "port": port,
570
- "backend": backend,
571
- "anyllm_provider": anyllm_provider,
572
- "region": region,
573
- "memory": memory,
574
- }
575
- return
576
-
577
- targets = detect_init_targets(global_scope)
578
- if not targets:
579
- scope_label = "user" if global_scope else "local"
580
- raise click.ClickException(
581
- f"No supported {scope_label} init targets were auto-detected. Specify one explicitly."
582
- )
583
- _run_init_targets(
584
- targets=targets,
585
- global_scope=global_scope,
586
- port=port,
587
- backend=backend,
588
- anyllm_provider=anyllm_provider,
589
- region=region,
590
- memory=memory,
591
- )
592
-
593
-
594
- def _ctx_value(ctx: click.Context, key: str) -> Any:
595
- return (ctx.obj or {}).get(key)
596
-
597
-
598
- @init.command("claude")
599
- @click.pass_context
600
- def init_claude(ctx: click.Context) -> None:
601
- """Install Claude Code durable hooks and provider routing."""
602
- _run_init_targets(
603
- targets=["claude"],
604
- global_scope=bool(_ctx_value(ctx, "global_scope")),
605
- port=int(_ctx_value(ctx, "port") or 8787),
606
- backend=str(_ctx_value(ctx, "backend") or "anthropic"),
607
- anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
608
- region=_ctx_value(ctx, "region"),
609
- memory=bool(_ctx_value(ctx, "memory")),
610
- )
611
-
612
-
613
- @init.command("copilot")
614
- @click.pass_context
615
- def init_copilot(ctx: click.Context) -> None:
616
- """Install GitHub Copilot CLI durable hooks and provider routing."""
617
- _run_init_targets(
618
- targets=["copilot"],
619
- global_scope=bool(_ctx_value(ctx, "global_scope")),
620
- port=int(_ctx_value(ctx, "port") or 8787),
621
- backend=str(_ctx_value(ctx, "backend") or "anthropic"),
622
- anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
623
- region=_ctx_value(ctx, "region"),
624
- memory=bool(_ctx_value(ctx, "memory")),
625
- )
626
-
627
-
628
- @init.command("codex")
629
- @click.pass_context
630
- def init_codex(ctx: click.Context) -> None:
631
- """Install Codex durable hooks and provider routing."""
632
- _run_init_targets(
633
- targets=["codex"],
634
- global_scope=bool(_ctx_value(ctx, "global_scope")),
635
- port=int(_ctx_value(ctx, "port") or 8787),
636
- backend=str(_ctx_value(ctx, "backend") or "anthropic"),
637
- anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
638
- region=_ctx_value(ctx, "region"),
639
- memory=bool(_ctx_value(ctx, "memory")),
640
- )
641
-
642
-
643
- @init.command("openclaw")
644
- @click.pass_context
645
- def init_openclaw(ctx: click.Context) -> None:
646
- """Install the durable OpenClaw Headroom plugin."""
647
- _run_init_targets(
648
- targets=["openclaw"],
649
- global_scope=bool(_ctx_value(ctx, "global_scope")),
650
- port=int(_ctx_value(ctx, "port") or 8787),
651
- backend=str(_ctx_value(ctx, "backend") or "anthropic"),
652
- anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
653
- region=_ctx_value(ctx, "region"),
654
- memory=bool(_ctx_value(ctx, "memory")),
655
- )
656
-
657
-
658
- @init.group("hook", hidden=True)
659
- def init_hook() -> None:
660
- """Internal hook helpers."""
661
-
662
-
663
- @init_hook.command("ensure")
664
- @click.option("--profile", default=None, help="Explicit deployment profile to ensure.")
665
- @click.option("--marker", default=None, hidden=True)
666
- def init_hook_ensure(profile: str | None, marker: str | None) -> None:
667
- """Best-effort ensure used by installed agent hooks."""
668
- del marker
669
- profiles: list[str] = []
670
- if profile:
671
- profiles.append(profile)
672
- else:
673
- local_profile = _local_profile()
674
- if load_manifest(local_profile) is not None:
675
- profiles.append(local_profile)
676
- elif load_manifest(_GLOBAL_PROFILE) is not None:
677
- profiles.append(_GLOBAL_PROFILE)
678
- for name in profiles:
679
- _ensure_profile_running(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Durable agent initialization commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from hashlib import sha1
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ from headroom.install.models import ConfigScope, InstallPreset, RuntimeKind, SupervisorKind
19
+ from headroom.install.paths import claude_settings_path, codex_config_path, validate_profile_name
20
+ from headroom.install.planner import build_manifest
21
+ from headroom.install.providers import _apply_unix_env_scope, _apply_windows_env_scope
22
+ from headroom.install.runtime import (
23
+ resolve_headroom_command,
24
+ start_detached_agent,
25
+ start_persistent_docker,
26
+ stop_runtime,
27
+ wait_ready,
28
+ )
29
+ from headroom.install.state import load_manifest, save_manifest
30
+ from headroom.install.supervisors import start_supervisor
31
+
32
+ from .main import main
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ _VERBOSE_HANDLER_ATTR = "_headroom_init_verbose_handler"
37
+
38
+ _GLOBAL_PROFILE = "init-user"
39
+ _CLAUDE_HOOK_MARKER = "headroom-init-claude"
40
+ _COPILOT_HOOK_MARKER = "headroom-init-copilot"
41
+ _CODEX_HOOK_MARKER = "headroom-init-codex"
42
+ _CODEX_PROVIDER_MARKER_START = "# --- Headroom init provider ---"
43
+ _CODEX_PROVIDER_MARKER_END = "# --- end Headroom init provider ---"
44
+ _CODEX_FEATURE_MARKER_START = "# --- Headroom init features ---"
45
+ _CODEX_FEATURE_MARKER_END = "# --- end Headroom init features ---"
46
+ _SUPPORTED_TARGETS = ("claude", "copilot", "codex", "openclaw")
47
+ _LOCAL_TARGETS = {"claude", "codex"}
48
+ _GLOBAL_TARGETS = {"claude", "copilot", "codex", "openclaw"}
49
+
50
+
51
+ def _command_string(parts: list[str]) -> str:
52
+ if os.name == "nt":
53
+ return subprocess.list2cmdline(parts)
54
+ return shlex.join(parts)
55
+
56
+
57
+ def _hook_command(*parts: str) -> str:
58
+ return _command_string([*resolve_headroom_command(), "init", "hook", "ensure", *parts])
59
+
60
+
61
+ def _powershell_matcher() -> str:
62
+ return "Bash|PowerShell" if os.name == "nt" else "Bash"
63
+
64
+
65
+ def _enable_verbose_logging() -> None:
66
+ """Attach a stderr handler to the init logger at DEBUG level.
67
+
68
+ Idempotent: calling this multiple times in one process (e.g. when nested
69
+ subcommands are invoked) leaves exactly one handler attached. Does NOT
70
+ mutate stdout; all verbose output goes to stderr so ``headroom init``
71
+ can still be composed in pipes that consume stdout.
72
+ """
73
+
74
+ if getattr(logger, _VERBOSE_HANDLER_ATTR, None) is not None:
75
+ return
76
+ handler = logging.StreamHandler(stream=sys.stderr)
77
+ handler.setFormatter(logging.Formatter("[headroom init] %(message)s"))
78
+ handler.setLevel(logging.DEBUG)
79
+ logger.addHandler(handler)
80
+ logger.setLevel(logging.DEBUG)
81
+ logger.propagate = False
82
+ setattr(logger, _VERBOSE_HANDLER_ATTR, handler)
83
+
84
+
85
+ def _local_profile(cwd: Path | None = None) -> str:
86
+ root = (cwd or Path.cwd()).resolve()
87
+ slug = "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in root.name.lower()).strip(
88
+ "-"
89
+ )
90
+ digest = sha1(str(root).encode("utf-8")).hexdigest()[:8]
91
+ return validate_profile_name(f"init-{slug or 'repo'}-{digest}")
92
+
93
+
94
+ def _runtime_profile(global_scope: bool, cwd: Path | None = None) -> str:
95
+ return _GLOBAL_PROFILE if global_scope else _local_profile(cwd)
96
+
97
+
98
+ def _copilot_config_path() -> Path:
99
+ return Path.home() / ".copilot" / "config.json"
100
+
101
+
102
+ def _codex_hooks_path(global_scope: bool) -> Path:
103
+ return (Path.home() if global_scope else Path.cwd()) / ".codex" / "hooks.json"
104
+
105
+
106
+ def _claude_scope_path(global_scope: bool) -> Path:
107
+ if global_scope:
108
+ return claude_settings_path()
109
+ return Path.cwd() / ".claude" / "settings.local.json"
110
+
111
+
112
+ def _codex_scope_path(global_scope: bool) -> Path:
113
+ if global_scope:
114
+ return codex_config_path()
115
+ return Path.cwd() / ".codex" / "config.toml"
116
+
117
+
118
+ def _json_file(path: Path) -> dict[str, Any]:
119
+ if not path.exists():
120
+ return {}
121
+ content = path.read_text(encoding="utf-8").strip()
122
+ if not content:
123
+ return {}
124
+ payload = json.loads(content)
125
+ return payload if isinstance(payload, dict) else {}
126
+
127
+
128
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
129
+ logger.debug("write json: %s (keys=%s)", path, sorted(payload.keys()))
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
132
+
133
+
134
+ def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
135
+ logger.debug("ensure claude hooks: %s (profile=%s, port=%s)", path, profile, port)
136
+ payload = _json_file(path)
137
+ env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
138
+ env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
139
+ payload["env"] = env_map
140
+
141
+ hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
142
+ command = _hook_command("--profile", profile)
143
+ for event, matcher in (
144
+ ("SessionStart", "startup|resume"),
145
+ ("PreToolUse", _powershell_matcher()),
146
+ ):
147
+ entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else []
148
+ retained: list[dict[str, Any]] = []
149
+ for entry in entries:
150
+ if not isinstance(entry, dict):
151
+ retained.append(entry)
152
+ continue
153
+ hook_items = entry.get("hooks")
154
+ if not isinstance(hook_items, list):
155
+ retained.append(entry)
156
+ continue
157
+ has_headroom = any(
158
+ isinstance(item, dict)
159
+ and item.get("command")
160
+ and _CLAUDE_HOOK_MARKER in str(item.get("command"))
161
+ for item in hook_items
162
+ )
163
+ if not has_headroom:
164
+ retained.append(entry)
165
+ retained.append(
166
+ {
167
+ "matcher": matcher,
168
+ "hooks": [
169
+ {
170
+ "type": "command",
171
+ "command": f"{command} --marker {_CLAUDE_HOOK_MARKER}",
172
+ "timeout": 15,
173
+ }
174
+ ],
175
+ }
176
+ )
177
+ hooks[event] = retained
178
+ payload["hooks"] = hooks
179
+ _write_json(path, payload)
180
+
181
+
182
+ def _ensure_copilot_hooks(path: Path, profile: str) -> None:
183
+ logger.debug("ensure copilot hooks: %s (profile=%s)", path, profile)
184
+ payload = _json_file(path)
185
+ hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
186
+ command = f"{_hook_command('--profile', profile)} --marker {_COPILOT_HOOK_MARKER}"
187
+ for event in ("SessionStart", "PreToolUse"):
188
+ entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else []
189
+ retained = [
190
+ entry
191
+ for entry in entries
192
+ if not (
193
+ isinstance(entry, dict) and _COPILOT_HOOK_MARKER in str(entry.get("command", ""))
194
+ )
195
+ ]
196
+ retained.append({"type": "command", "command": command, "cwd": ".", "timeout": 15})
197
+ hooks[event] = retained
198
+ payload["hooks"] = hooks
199
+ _write_json(path, payload)
200
+
201
+
202
+ def _replace_marker_block(content: str, marker_start: str, marker_end: str, block: str) -> str:
203
+ if marker_start in content and marker_end in content:
204
+ start = content.index(marker_start)
205
+ end = content.index(marker_end) + len(marker_end)
206
+ content = content[:start].rstrip() + "\n\n" + content[end:].lstrip()
207
+ return (content.rstrip() + "\n\n" + block.strip() + "\n").lstrip()
208
+
209
+
210
+ def _ensure_codex_provider(path: Path, port: int) -> None:
211
+ logger.debug("ensure codex provider block: %s (port=%s)", path, port)
212
+ block = (
213
+ f"{_CODEX_PROVIDER_MARKER_START}\n"
214
+ 'model_provider = "headroom"\n\n'
215
+ "[model_providers.headroom]\n"
216
+ 'name = "Headroom init proxy"\n'
217
+ f'base_url = "http://127.0.0.1:{port}/v1"\n'
218
+ 'env_key = "OPENAI_API_KEY"\n'
219
+ "requires_openai_auth = true\n"
220
+ "supports_websockets = true\n"
221
+ f"{_CODEX_PROVIDER_MARKER_END}"
222
+ )
223
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
224
+ content = _replace_marker_block(
225
+ content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block
226
+ )
227
+ path.parent.mkdir(parents=True, exist_ok=True)
228
+ path.write_text(content, encoding="utf-8")
229
+
230
+
231
+ def _ensure_codex_feature_flag(path: Path) -> None:
232
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
233
+ if _CODEX_FEATURE_MARKER_START in content and _CODEX_FEATURE_MARKER_END in content:
234
+ block = f"{_CODEX_FEATURE_MARKER_START}\ncodex_hooks = true\n{_CODEX_FEATURE_MARKER_END}"
235
+ content = _replace_marker_block(
236
+ content,
237
+ _CODEX_FEATURE_MARKER_START,
238
+ _CODEX_FEATURE_MARKER_END,
239
+ block,
240
+ )
241
+ elif "[features]" in content:
242
+ lines = content.splitlines()
243
+ inserted = False
244
+ for index, line in enumerate(lines):
245
+ if line.strip() != "[features]":
246
+ continue
247
+ section_end = index + 1
248
+ while section_end < len(lines) and not (
249
+ lines[section_end].startswith("[") and lines[section_end].endswith("]")
250
+ ):
251
+ if "codex_hooks" in lines[section_end]:
252
+ inserted = True
253
+ break
254
+ section_end += 1
255
+ if not inserted:
256
+ lines[index + 1 : index + 1] = [
257
+ _CODEX_FEATURE_MARKER_START,
258
+ "codex_hooks = true",
259
+ _CODEX_FEATURE_MARKER_END,
260
+ ]
261
+ inserted = True
262
+ break
263
+ content = "\n".join(lines).rstrip() + "\n"
264
+ if not inserted:
265
+ content = (
266
+ content.rstrip()
267
+ + "\n\n[features]\n"
268
+ + _CODEX_FEATURE_MARKER_START
269
+ + "\n"
270
+ + "codex_hooks = true\n"
271
+ + _CODEX_FEATURE_MARKER_END
272
+ + "\n"
273
+ )
274
+ else:
275
+ content = (
276
+ content.rstrip()
277
+ + "\n\n[features]\n"
278
+ + _CODEX_FEATURE_MARKER_START
279
+ + "\n"
280
+ + "codex_hooks = true\n"
281
+ + _CODEX_FEATURE_MARKER_END
282
+ + "\n"
283
+ ).lstrip()
284
+ path.parent.mkdir(parents=True, exist_ok=True)
285
+ path.write_text(content, encoding="utf-8")
286
+
287
+
288
+ def _ensure_codex_hooks(path: Path, profile: str) -> None:
289
+ logger.debug("ensure codex hooks: %s (profile=%s)", path, profile)
290
+ command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}"
291
+ payload = {
292
+ "hooks": {
293
+ "SessionStart": [
294
+ {
295
+ "matcher": "startup|resume",
296
+ "hooks": [{"type": "command", "command": command, "timeout": 15}],
297
+ }
298
+ ],
299
+ "PreToolUse": [
300
+ {
301
+ "matcher": "Bash",
302
+ "hooks": [{"type": "command", "command": command, "timeout": 15}],
303
+ }
304
+ ],
305
+ }
306
+ }
307
+ path.parent.mkdir(parents=True, exist_ok=True)
308
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
309
+
310
+
311
+ def _manifest_changed(
312
+ existing: Any,
313
+ *,
314
+ port: int,
315
+ backend: str,
316
+ anyllm_provider: str | None,
317
+ region: str | None,
318
+ memory: bool,
319
+ ) -> bool:
320
+ return any(
321
+ [
322
+ getattr(existing, "port", port) != port,
323
+ getattr(existing, "backend", backend) != backend,
324
+ getattr(existing, "anyllm_provider", anyllm_provider) != anyllm_provider,
325
+ getattr(existing, "region", region) != region,
326
+ getattr(existing, "memory_enabled", memory) != memory,
327
+ ]
328
+ )
329
+
330
+
331
+ def _ensure_runtime_manifest(
332
+ *,
333
+ global_scope: bool,
334
+ targets: list[str],
335
+ port: int,
336
+ backend: str,
337
+ anyllm_provider: str | None,
338
+ region: str | None,
339
+ memory: bool,
340
+ ) -> str:
341
+ profile = _runtime_profile(global_scope)
342
+ existing = load_manifest(profile)
343
+ merged_targets = sorted(set(existing.targets if existing else []).union(targets))
344
+ manifest = build_manifest(
345
+ profile=profile,
346
+ preset=InstallPreset.PERSISTENT_TASK.value,
347
+ runtime_kind=RuntimeKind.PYTHON.value,
348
+ scope=ConfigScope.USER.value,
349
+ provider_mode="manual",
350
+ targets=merged_targets,
351
+ port=port,
352
+ backend=backend,
353
+ anyllm_provider=anyllm_provider,
354
+ region=region,
355
+ proxy_mode="token",
356
+ memory_enabled=memory,
357
+ telemetry_enabled=True,
358
+ image="ghcr.io/chopratejas/headroom:latest",
359
+ )
360
+ manifest.supervisor_kind = SupervisorKind.NONE.value
361
+ manifest.artifacts = []
362
+ manifest.mutations = existing.mutations if existing else []
363
+ if existing is not None and _manifest_changed(
364
+ existing,
365
+ port=port,
366
+ backend=backend,
367
+ anyllm_provider=anyllm_provider,
368
+ region=region,
369
+ memory=memory,
370
+ ):
371
+ try:
372
+ stop_runtime(existing)
373
+ except Exception:
374
+ pass
375
+ save_manifest(manifest)
376
+ return profile
377
+
378
+
379
+ def _env_manifest(values: dict[str, str]) -> Any:
380
+ return build_manifest(
381
+ profile="init-env",
382
+ preset=InstallPreset.PERSISTENT_TASK.value,
383
+ runtime_kind=RuntimeKind.PYTHON.value,
384
+ scope=ConfigScope.USER.value,
385
+ provider_mode="manual",
386
+ targets=["copilot"],
387
+ port=8787,
388
+ backend="anthropic",
389
+ anyllm_provider=None,
390
+ region=None,
391
+ proxy_mode="token",
392
+ memory_enabled=False,
393
+ telemetry_enabled=True,
394
+ image="ghcr.io/chopratejas/headroom:latest",
395
+ )
396
+
397
+
398
+ def _apply_user_env(values: dict[str, str]) -> None:
399
+ manifest = _env_manifest(values)
400
+ manifest.base_env = {}
401
+ manifest.tool_envs = {"copilot": values}
402
+ scope = "windows" if os.name == "nt" else "unix"
403
+ logger.debug("apply user env scope=%s keys=%s", scope, sorted(values.keys()))
404
+ if os.name == "nt":
405
+ _apply_windows_env_scope(manifest)
406
+ else:
407
+ _apply_unix_env_scope(manifest)
408
+
409
+
410
+ def _resolve_copilot_env(port: int, backend: str) -> dict[str, str]:
411
+ if backend == "anthropic":
412
+ return {
413
+ "COPILOT_PROVIDER_TYPE": "anthropic",
414
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
415
+ }
416
+ return {
417
+ "COPILOT_PROVIDER_TYPE": "openai",
418
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
419
+ "COPILOT_PROVIDER_WIRE_API": "completions",
420
+ }
421
+
422
+
423
+ def _marketplace_source() -> str:
424
+ override = os.environ.get("HEADROOM_MARKETPLACE_SOURCE")
425
+ if override:
426
+ return override
427
+ repo_root = Path(__file__).resolve().parents[2]
428
+ if (repo_root / ".claude-plugin" / "marketplace.json").exists():
429
+ return str(repo_root)
430
+ return "chopratejas/headroom"
431
+
432
+
433
+ def _run_checked(command: list[str], *, action: str) -> None:
434
+ logger.debug("subprocess [%s]: %s", action, _command_string(command))
435
+ result = subprocess.run(
436
+ command,
437
+ capture_output=True,
438
+ text=True,
439
+ encoding="utf-8",
440
+ errors="replace",
441
+ )
442
+ logger.debug(
443
+ "subprocess [%s] exit=%s stdout=%r stderr=%r",
444
+ action,
445
+ result.returncode,
446
+ result.stdout[:200],
447
+ result.stderr[:200],
448
+ )
449
+ if result.returncode == 0:
450
+ return
451
+ detail = "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part)
452
+ if "already" in detail.lower() or "exists" in detail.lower():
453
+ logger.debug(
454
+ "subprocess [%s] non-zero exit tolerated ('already'/'exists' detected)", action
455
+ )
456
+ return
457
+ raise click.ClickException(f"{action} failed: {detail or result.returncode}")
458
+
459
+
460
+ def _install_claude_marketplace(scope: str) -> None:
461
+ claude_bin = shutil.which("claude")
462
+ if not claude_bin:
463
+ raise click.ClickException("'claude' not found in PATH. Install Claude Code first.")
464
+ source = _marketplace_source()
465
+ _run_checked(
466
+ [claude_bin, "plugin", "marketplace", "add", source], action="claude marketplace add"
467
+ )
468
+ _run_checked(
469
+ [claude_bin, "plugin", "install", "headroom@headroom-marketplace", "--scope", scope],
470
+ action="claude plugin install",
471
+ )
472
+
473
+
474
+ def _install_copilot_marketplace() -> None:
475
+ copilot_bin = shutil.which("copilot")
476
+ if not copilot_bin:
477
+ raise click.ClickException("'copilot' not found in PATH. Install GitHub Copilot CLI first.")
478
+ source = _marketplace_source()
479
+ _run_checked(
480
+ [copilot_bin, "plugin", "marketplace", "add", source],
481
+ action="copilot marketplace add",
482
+ )
483
+ _run_checked(
484
+ [copilot_bin, "plugin", "install", "headroom@headroom-marketplace"],
485
+ action="copilot plugin install",
486
+ )
487
+
488
+
489
+ def _ensure_profile_running(profile: str) -> None:
490
+ manifest = load_manifest(profile)
491
+ if manifest is None:
492
+ return
493
+ if wait_ready(manifest, timeout_seconds=1):
494
+ return
495
+ try:
496
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
497
+ start_persistent_docker(manifest)
498
+ elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
499
+ start_supervisor(manifest)
500
+ else:
501
+ start_detached_agent(manifest.profile)
502
+ wait_ready(manifest, timeout_seconds=45)
503
+ except Exception:
504
+ return
505
+
506
+
507
+ def _probe_init_targets(global_scope: bool) -> list[tuple[str, str | None]]:
508
+ """Return ``[(target, which_result)]`` for every in-scope supported target.
509
+
510
+ ``which_result`` is the absolute path reported by :func:`shutil.which`, or
511
+ ``None`` when the binary is not on PATH. Callers use the list both to
512
+ build an auto-detected target list and to produce a diagnostic error
513
+ message when nothing was found.
514
+ """
515
+
516
+ allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS
517
+ logger.debug(
518
+ "detect_init_targets: global_scope=%s allowed=%s",
519
+ global_scope,
520
+ sorted(allowed),
521
+ )
522
+ probes: list[tuple[str, str | None]] = []
523
+ for target in _SUPPORTED_TARGETS:
524
+ if target not in allowed:
525
+ continue
526
+ path = shutil.which(target)
527
+ logger.debug("detect_init_targets: shutil.which(%r) -> %s", target, path or "None")
528
+ probes.append((target, path))
529
+ return probes
530
+
531
+
532
+ def detect_init_targets(global_scope: bool) -> list[str]:
533
+ """Return agent names in scope for which a binary was found on PATH."""
534
+
535
+ return [name for name, path in _probe_init_targets(global_scope) if path]
536
+
537
+
538
+ def _format_empty_detection_error(global_scope: bool) -> str:
539
+ """Build the error message shown when no in-scope targets were detected.
540
+
541
+ Lists every agent that was probed, what ``shutil.which`` returned, and
542
+ confirms how to proceed explicitly — including that the ``-g`` / ``--global``
543
+ flag the user tried is still valid.
544
+ """
545
+
546
+ probes = _probe_init_targets(global_scope)
547
+ scope_flag = "-g" if global_scope else ""
548
+ scope_label = "user" if global_scope else "local"
549
+
550
+ lines: list[str] = [
551
+ f"No supported {scope_label}-scope agents were found on PATH.",
552
+ "",
553
+ "Headroom probed the following agents via shutil.which():",
554
+ ]
555
+ for name, path in probes:
556
+ status = f"found at {path}" if path else "not found"
557
+ lines.append(f" - {name}: {status}")
558
+
559
+ lines.extend(
560
+ [
561
+ "",
562
+ f"The {scope_flag or '--local (no flag)'} option is still supported; "
563
+ "headroom init just needs to know which agent to target.",
564
+ "Install the agent you want first, then re-run with an explicit target:",
565
+ "",
566
+ ]
567
+ )
568
+ for name, _path in probes:
569
+ flag = " -g" if global_scope else ""
570
+ lines.append(f" headroom init{flag} {name}")
571
+
572
+ lines.extend(
573
+ [
574
+ "",
575
+ "Tip: run `headroom init --help` to see all options.",
576
+ ]
577
+ )
578
+ return "\n".join(lines)
579
+
580
+
581
+ def _init_claude(*, global_scope: bool, profile: str, port: int) -> None:
582
+ _ensure_claude_hooks(_claude_scope_path(global_scope), profile, port)
583
+ _install_claude_marketplace("user" if global_scope else "local")
584
+ click.echo(f"Configured Claude Code ({'user' if global_scope else 'local'} scope).")
585
+ click.echo("Restart Claude Code to activate Headroom hooks and provider routing.")
586
+
587
+
588
+ def _init_copilot(*, global_scope: bool, profile: str, port: int, backend: str) -> None:
589
+ if not global_scope:
590
+ raise click.ClickException(
591
+ "Copilot durable init currently requires -g (current-user scope)."
592
+ )
593
+ _ensure_copilot_hooks(_copilot_config_path(), profile)
594
+ _apply_user_env(_resolve_copilot_env(port, backend))
595
+ _install_copilot_marketplace()
596
+ click.echo("Configured GitHub Copilot CLI (user scope).")
597
+ click.echo("Restart Copilot CLI to activate Headroom hooks and provider routing.")
598
+
599
+
600
+ def _init_codex(*, global_scope: bool, profile: str, port: int) -> None:
601
+ config_path = _codex_scope_path(global_scope)
602
+ _ensure_codex_provider(config_path, port)
603
+ _ensure_codex_feature_flag(config_path)
604
+ _ensure_codex_hooks(_codex_hooks_path(global_scope), profile)
605
+ click.echo(f"Configured Codex ({'user' if global_scope else 'local'} scope).")
606
+ if os.name == "nt":
607
+ click.echo(
608
+ "Codex hooks are currently disabled upstream on Windows; provider routing was still installed."
609
+ )
610
+ click.echo("Restart Codex to activate Headroom configuration.")
611
+
612
+
613
+ def _init_openclaw(*, global_scope: bool, port: int) -> None:
614
+ if not global_scope:
615
+ raise click.ClickException(
616
+ "OpenClaw durable init currently requires -g (current-user scope)."
617
+ )
618
+ command = [*resolve_headroom_command(), "wrap", "openclaw", "--proxy-port", str(port)]
619
+ result = subprocess.run(command)
620
+ if result.returncode != 0:
621
+ raise SystemExit(result.returncode)
622
+
623
+
624
+ def _run_init_targets(
625
+ *,
626
+ targets: list[str],
627
+ global_scope: bool,
628
+ port: int,
629
+ backend: str,
630
+ anyllm_provider: str | None,
631
+ region: str | None,
632
+ memory: bool,
633
+ ) -> None:
634
+ logger.debug(
635
+ "run_init_targets: targets=%s global_scope=%s port=%s backend=%s memory=%s",
636
+ targets,
637
+ global_scope,
638
+ port,
639
+ backend,
640
+ memory,
641
+ )
642
+ runtime_targets = [target for target in targets if target != "openclaw"]
643
+ profile = _ensure_runtime_manifest(
644
+ global_scope=global_scope,
645
+ targets=runtime_targets,
646
+ port=port,
647
+ backend=backend,
648
+ anyllm_provider=anyllm_provider,
649
+ region=region,
650
+ memory=memory,
651
+ )
652
+ logger.debug("run_init_targets: using profile=%s", profile)
653
+ for target in targets:
654
+ logger.debug("run_init_targets: dispatching -> %s", target)
655
+ if target == "claude":
656
+ _init_claude(global_scope=global_scope, profile=profile, port=port)
657
+ elif target == "copilot":
658
+ _init_copilot(global_scope=global_scope, profile=profile, port=port, backend=backend)
659
+ elif target == "codex":
660
+ _init_codex(global_scope=global_scope, profile=profile, port=port)
661
+ elif target == "openclaw":
662
+ _init_openclaw(global_scope=global_scope, port=port)
663
+
664
+
665
+ @main.group(invoke_without_command=True)
666
+ @click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.")
667
+ @click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.")
668
+ @click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.")
669
+ @click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.")
670
+ @click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
671
+ @click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
672
+ @click.option(
673
+ "-v",
674
+ "--verbose",
675
+ is_flag=True,
676
+ help="Emit debug-level diagnostics to stderr (flag values, shutil.which results, "
677
+ "file paths touched, subprocess invocations and exit codes).",
678
+ )
679
+ @click.pass_context
680
+ def init(
681
+ ctx: click.Context,
682
+ global_scope: bool,
683
+ port: int,
684
+ backend: str,
685
+ anyllm_provider: str | None,
686
+ region: str | None,
687
+ memory: bool,
688
+ verbose: bool,
689
+ ) -> None:
690
+ """Install durable Headroom integrations for supported agents."""
691
+ if verbose:
692
+ _enable_verbose_logging()
693
+ logger.debug(
694
+ "init: global_scope=%s port=%s backend=%s anyllm_provider=%s region=%s memory=%s "
695
+ "invoked_subcommand=%s",
696
+ global_scope,
697
+ port,
698
+ backend,
699
+ anyllm_provider,
700
+ region,
701
+ memory,
702
+ ctx.invoked_subcommand,
703
+ )
704
+ if ctx.invoked_subcommand is not None:
705
+ ctx.obj = {
706
+ "global_scope": global_scope,
707
+ "port": port,
708
+ "backend": backend,
709
+ "anyllm_provider": anyllm_provider,
710
+ "region": region,
711
+ "memory": memory,
712
+ "verbose": verbose,
713
+ }
714
+ return
715
+
716
+ targets = detect_init_targets(global_scope)
717
+ if not targets:
718
+ logger.debug("init: detect_init_targets returned empty; exiting with guided error")
719
+ raise click.ClickException(_format_empty_detection_error(global_scope))
720
+ logger.debug("init: detected targets=%s", targets)
721
+ _run_init_targets(
722
+ targets=targets,
723
+ global_scope=global_scope,
724
+ port=port,
725
+ backend=backend,
726
+ anyllm_provider=anyllm_provider,
727
+ region=region,
728
+ memory=memory,
729
+ )
730
+
731
+
732
+ def _ctx_value(ctx: click.Context, key: str) -> Any:
733
+ return (ctx.obj or {}).get(key)
734
+
735
+
736
+ @init.command("claude")
737
+ @click.pass_context
738
+ def init_claude(ctx: click.Context) -> None:
739
+ """Install Claude Code durable hooks and provider routing."""
740
+ _run_init_targets(
741
+ targets=["claude"],
742
+ global_scope=bool(_ctx_value(ctx, "global_scope")),
743
+ port=int(_ctx_value(ctx, "port") or 8787),
744
+ backend=str(_ctx_value(ctx, "backend") or "anthropic"),
745
+ anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
746
+ region=_ctx_value(ctx, "region"),
747
+ memory=bool(_ctx_value(ctx, "memory")),
748
+ )
749
+
750
+
751
+ @init.command("copilot")
752
+ @click.pass_context
753
+ def init_copilot(ctx: click.Context) -> None:
754
+ """Install GitHub Copilot CLI durable hooks and provider routing."""
755
+ _run_init_targets(
756
+ targets=["copilot"],
757
+ global_scope=bool(_ctx_value(ctx, "global_scope")),
758
+ port=int(_ctx_value(ctx, "port") or 8787),
759
+ backend=str(_ctx_value(ctx, "backend") or "anthropic"),
760
+ anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
761
+ region=_ctx_value(ctx, "region"),
762
+ memory=bool(_ctx_value(ctx, "memory")),
763
+ )
764
+
765
+
766
+ @init.command("codex")
767
+ @click.pass_context
768
+ def init_codex(ctx: click.Context) -> None:
769
+ """Install Codex durable hooks and provider routing."""
770
+ _run_init_targets(
771
+ targets=["codex"],
772
+ global_scope=bool(_ctx_value(ctx, "global_scope")),
773
+ port=int(_ctx_value(ctx, "port") or 8787),
774
+ backend=str(_ctx_value(ctx, "backend") or "anthropic"),
775
+ anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
776
+ region=_ctx_value(ctx, "region"),
777
+ memory=bool(_ctx_value(ctx, "memory")),
778
+ )
779
+
780
+
781
+ @init.command("openclaw")
782
+ @click.pass_context
783
+ def init_openclaw(ctx: click.Context) -> None:
784
+ """Install the durable OpenClaw Headroom plugin."""
785
+ _run_init_targets(
786
+ targets=["openclaw"],
787
+ global_scope=bool(_ctx_value(ctx, "global_scope")),
788
+ port=int(_ctx_value(ctx, "port") or 8787),
789
+ backend=str(_ctx_value(ctx, "backend") or "anthropic"),
790
+ anyllm_provider=_ctx_value(ctx, "anyllm_provider"),
791
+ region=_ctx_value(ctx, "region"),
792
+ memory=bool(_ctx_value(ctx, "memory")),
793
+ )
794
+
795
+
796
+ @init.group("hook", hidden=True)
797
+ def init_hook() -> None:
798
+ """Internal hook helpers."""
799
+
800
+
801
+ @init_hook.command("ensure")
802
+ @click.option("--profile", default=None, help="Explicit deployment profile to ensure.")
803
+ @click.option("--marker", default=None, hidden=True)
804
+ def init_hook_ensure(profile: str | None, marker: str | None) -> None:
805
+ """Best-effort ensure used by installed agent hooks."""
806
+ del marker
807
+ profiles: list[str] = []
808
+ if profile:
809
+ profiles.append(profile)
810
+ else:
811
+ local_profile = _local_profile()
812
+ if load_manifest(local_profile) is not None:
813
+ profiles.append(local_profile)
814
+ elif load_manifest(_GLOBAL_PROFILE) is not None:
815
+ profiles.append(_GLOBAL_PROFILE)
816
+ for name in profiles:
817
+ _ensure_profile_running(name)
headroom/cli/tools.py CHANGED
@@ -34,6 +34,10 @@ _PASSTHROUGH_CTX = {
34
  }
35
 
36
 
 
 
 
 
37
  def _exec_tool(tool: str, argv: Sequence[str]) -> None:
38
  try:
39
  path = binaries.resolve(tool)
@@ -58,7 +62,7 @@ def _exec_tool(tool: str, argv: Sequence[str]) -> None:
58
  # that needs to clean up on shell exit must be handled elsewhere (e.g.
59
  # the parent `headroom` process, not these thin passthroughs).
60
  cmd = [str(path), *argv]
61
- if os.name == "posix":
62
  os.execv(cmd[0], cmd) # never returns
63
  else:
64
  completed = subprocess.run(cmd, check=False)
 
34
  }
35
 
36
 
37
+ def _is_windows() -> bool:
38
+ return sys.platform.startswith("win")
39
+
40
+
41
  def _exec_tool(tool: str, argv: Sequence[str]) -> None:
42
  try:
43
  path = binaries.resolve(tool)
 
62
  # that needs to clean up on shell exit must be handled elsewhere (e.g.
63
  # the parent `headroom` process, not these thin passthroughs).
64
  cmd = [str(path), *argv]
65
+ if not _is_windows():
66
  os.execv(cmd[0], cmd) # never returns
67
  else:
68
  completed = subprocess.run(cmd, check=False)
headroom/cli/wrap.py CHANGED
The diff for this file is too large to render. See raw diff
 
headroom/compress.py CHANGED
@@ -1,347 +1,347 @@
1
- """One-function compression API for Headroom.
2
-
3
- The simplest way to use Headroom — no proxy, no config, just compress:
4
-
5
- from headroom import compress
6
-
7
- result = compress(messages, model="claude-sonnet-4-5-20250929")
8
- result.messages # Compressed messages (same format, fewer tokens)
9
- result.tokens_saved # Tokens saved
10
- result.compression_ratio # e.g., 0.35 means 65% saved
11
-
12
- Works with any LLM client, any proxy, any framework. Just compress
13
- the messages before sending them.
14
-
15
- Examples:
16
-
17
- # With Anthropic SDK
18
- from anthropic import Anthropic
19
- from headroom import compress
20
-
21
- client = Anthropic()
22
- messages = [{"role": "user", "content": huge_tool_output}]
23
- compressed = compress(messages, model="claude-sonnet-4-5-20250929")
24
- response = client.messages.create(
25
- model="claude-sonnet-4-5-20250929",
26
- messages=compressed.messages,
27
- )
28
-
29
- # With OpenAI SDK
30
- from openai import OpenAI
31
- from headroom import compress
32
-
33
- client = OpenAI()
34
- messages = [{"role": "user", "content": "analyze this"}, {"role": "tool", "content": big_data}]
35
- compressed = compress(messages, model="gpt-4o")
36
- response = client.chat.completions.create(model="gpt-4o", messages=compressed.messages)
37
-
38
- # With LiteLLM
39
- import litellm
40
- from headroom import compress
41
-
42
- messages = [...]
43
- compressed = compress(messages, model="bedrock/claude-sonnet")
44
- response = litellm.completion(model="bedrock/claude-sonnet", messages=compressed.messages)
45
-
46
- # With any HTTP client
47
- import httpx
48
- from headroom import compress
49
-
50
- compressed = compress(messages, model="claude-sonnet-4-5-20250929")
51
- httpx.post("https://api.anthropic.com/v1/messages", json={
52
- "model": "claude-sonnet-4-5-20250929",
53
- "messages": compressed.messages,
54
- })
55
- """
56
-
57
- from __future__ import annotations
58
-
59
- import logging
60
- import threading
61
- from dataclasses import dataclass, field
62
- from typing import Any
63
-
64
- from .observability import get_otel_metrics
65
- from .pipeline import PipelineExtensionManager, PipelineStage, summarize_routing_markers
66
- from .utils import extract_user_query as _extract_user_query
67
-
68
- logger = logging.getLogger(__name__)
69
-
70
-
71
- # Lazy-initialized singleton pipeline
72
- _pipeline = None
73
- _pipeline_lock = threading.Lock()
74
-
75
-
76
- @dataclass
77
- class CompressConfig:
78
- """User-facing compression options.
79
-
80
- Controls what gets compressed, how aggressively, and with which model.
81
- Pass to ``compress()`` or any integration that uses headroom.
82
-
83
- Examples::
84
-
85
- # Coding agent (default — skip user messages, protect recent)
86
- compress(messages, model="gpt-4o")
87
-
88
- # Financial document (compress everything, keep 50%)
89
- compress(messages, model="claude-opus-4-20250514",
90
- compress_user_messages=True,
91
- target_ratio=0.5,
92
- protect_recent=0,
93
- )
94
-
95
- # Aggressive (logs, search results)
96
- compress(messages, model="gpt-4o", target_ratio=0.2)
97
- """
98
-
99
- # What to compress
100
- compress_user_messages: bool = False
101
- """Compress user messages too (default: skip them for coding agents).
102
- Set True for document compression, RAG pipelines, or when user messages
103
- contain large tool outputs."""
104
-
105
- compress_system_messages: bool = True
106
- """Compress system messages (default: True).
107
- Set False to preserve system prompts exactly as-is. Useful for voice
108
- agents where tool definitions and instructions must not be altered."""
109
-
110
- protect_recent: int = 4
111
- """Don't compress the last N messages (they're the active conversation).
112
- Set 0 to compress everything."""
113
-
114
- protect_analysis_context: bool = True
115
- """Detect 'analyze'/'review' intent and protect code from compression."""
116
-
117
- # How aggressive
118
- target_ratio: float | None = None
119
- """Keep ratio for Kompress. None = model decides (~15% kept, aggressive).
120
- 0.5 = keep 50% (safe for documents). 0.7 = keep 70% (conservative).
121
- Only affects Kompress (text compression). SmartCrusher (JSON) has its
122
- own logic based on array dedup."""
123
-
124
- min_tokens_to_compress: int = 250
125
- """Minimum token count for a message to be compressed.
126
- Messages shorter than this are left unchanged. Default 250.
127
- Set lower for voice agents where turns are short."""
128
-
129
- # Model variant
130
- kompress_model: str | None = None
131
- """Kompress model ID. None = default (chopratejas/kompress-base).
132
- Set to a HuggingFace model ID for domain-specific compression.
133
- Set to 'disabled' to skip ML compression entirely
134
- (only SmartCrusher + CacheAligner will run)."""
135
-
136
-
137
- @dataclass
138
- class CompressResult:
139
- """Result of compressing messages.
140
-
141
- Attributes:
142
- messages: The compressed messages (same format as input).
143
- tokens_before: Token count before compression.
144
- tokens_after: Token count after compression.
145
- tokens_saved: Tokens removed by compression.
146
- compression_ratio: Ratio of tokens saved (0.0 = no savings, 1.0 = 100% removed).
147
- transforms_applied: List of transforms that were applied.
148
- """
149
-
150
- messages: list[dict[str, Any]]
151
- tokens_before: int = 0
152
- tokens_after: int = 0
153
- tokens_saved: int = 0
154
- compression_ratio: float = 0.0
155
- transforms_applied: list[str] = field(default_factory=list)
156
-
157
-
158
- def compress(
159
- messages: list[dict[str, Any]],
160
- model: str = "claude-sonnet-4-5-20250929",
161
- model_limit: int = 200000,
162
- optimize: bool = True,
163
- hooks: Any = None,
164
- config: CompressConfig | None = None,
165
- **kwargs: Any,
166
- ) -> CompressResult:
167
- """Compress messages using Headroom's full compression pipeline.
168
-
169
- This is the simplest way to use Headroom. No proxy, no config needed.
170
- Just pass messages and get compressed messages back.
171
-
172
- Args:
173
- messages: List of messages in Anthropic or OpenAI format.
174
- model: Model name (used for token counting and context limit).
175
- model_limit: Model's context window size in tokens.
176
- optimize: Whether to actually compress (False = passthrough for A/B testing).
177
- hooks: Optional CompressionHooks instance for custom behavior.
178
- config: Compression options (CompressConfig). Overrides defaults.
179
- **kwargs: Shorthand for CompressConfig fields. These override config:
180
- compress_user_messages, target_ratio, protect_recent,
181
- protect_analysis_context, kompress_model.
182
-
183
- Returns:
184
- CompressResult with compressed messages and metrics.
185
-
186
- Examples::
187
-
188
- # Default (coding agent)
189
- result = compress(messages, model="gpt-4o")
190
-
191
- # Financial document (keep 50%, compress everything)
192
- result = compress(messages, model="claude-opus-4-20250514",
193
- compress_user_messages=True,
194
- target_ratio=0.5,
195
- protect_recent=0,
196
- )
197
- """
198
- if not messages or not optimize:
199
- return CompressResult(messages=messages)
200
-
201
- # Build config from explicit config + kwargs
202
- cfg = config or CompressConfig()
203
- config_fields = {f.name for f in cfg.__dataclass_fields__.values()}
204
- for key, value in kwargs.items():
205
- if key in config_fields:
206
- setattr(cfg, key, value)
207
-
208
- pipeline = _get_pipeline()
209
- pipeline_extensions = PipelineExtensionManager(hooks=hooks, discover=False)
210
-
211
- try:
212
- # Compute biases from hooks if provided
213
- biases = None
214
- if hooks:
215
- from headroom.hooks import CompressContext
216
-
217
- ctx = CompressContext(model=model)
218
- messages = hooks.pre_compress(messages, ctx)
219
- biases = hooks.compute_biases(messages, ctx)
220
-
221
- received_event = pipeline_extensions.emit(
222
- PipelineStage.INPUT_RECEIVED,
223
- operation="compress",
224
- model=model,
225
- messages=messages,
226
- )
227
- if received_event.messages is not None:
228
- messages = received_event.messages
229
-
230
- # Extract user query from messages so transforms can score by
231
- # relevance. Without this, SmartCrusher selects items by statistics
232
- # alone (position, anomaly) and may drop relevant content.
233
- context = _extract_user_query(messages)
234
-
235
- result = pipeline.apply(
236
- messages=messages,
237
- model=model,
238
- model_limit=model_limit,
239
- context=context,
240
- biases=biases,
241
- # Pass CompressConfig options through to transforms
242
- compress_user_messages=cfg.compress_user_messages,
243
- compress_system_messages=cfg.compress_system_messages,
244
- target_ratio=cfg.target_ratio,
245
- protect_recent=cfg.protect_recent,
246
- protect_analysis_context=cfg.protect_analysis_context,
247
- min_tokens_to_compress=cfg.min_tokens_to_compress,
248
- kompress_model=cfg.kompress_model,
249
- )
250
-
251
- tokens_before = result.tokens_before
252
- tokens_after = result.tokens_after
253
- compressed_messages = result.messages
254
-
255
- routing_markers = summarize_routing_markers(result.transforms_applied)
256
- if routing_markers:
257
- routed_event = pipeline_extensions.emit(
258
- PipelineStage.INPUT_ROUTED,
259
- operation="compress",
260
- model=model,
261
- messages=compressed_messages,
262
- metadata={
263
- "routing_markers": routing_markers,
264
- "transforms_applied": result.transforms_applied,
265
- },
266
- )
267
- if routed_event.messages is not None:
268
- compressed_messages = routed_event.messages
269
-
270
- compressed_event = pipeline_extensions.emit(
271
- PipelineStage.INPUT_COMPRESSED,
272
- operation="compress",
273
- model=model,
274
- messages=compressed_messages,
275
- metadata={
276
- "tokens_before": tokens_before,
277
- "tokens_after": tokens_after,
278
- "transforms_applied": result.transforms_applied,
279
- },
280
- )
281
- if compressed_event.messages is not None:
282
- compressed_messages = compressed_event.messages
283
-
284
- tokens_saved = tokens_before - tokens_after
285
- ratio = tokens_saved / tokens_before if tokens_before > 0 else 0.0
286
-
287
- # Post-compress hook
288
- if hooks and tokens_saved > 0:
289
- from headroom.hooks import CompressEvent
290
-
291
- hooks.post_compress(
292
- CompressEvent(
293
- tokens_before=tokens_before,
294
- tokens_after=tokens_after,
295
- tokens_saved=tokens_saved,
296
- compression_ratio=ratio,
297
- transforms_applied=result.transforms_applied,
298
- model=model,
299
- )
300
- )
301
-
302
- return CompressResult(
303
- messages=compressed_messages,
304
- tokens_before=tokens_before,
305
- tokens_after=tokens_after,
306
- tokens_saved=tokens_saved,
307
- compression_ratio=ratio,
308
- transforms_applied=result.transforms_applied,
309
- )
310
-
311
- except Exception as e:
312
- get_otel_metrics().record_compression_failure(
313
- model=model,
314
- operation="compress",
315
- error_type=type(e).__name__,
316
- )
317
- logger.warning("Compression failed, returning original messages: %s", e)
318
- return CompressResult(
319
- messages=messages,
320
- tokens_before=0,
321
- tokens_after=0,
322
- tokens_saved=0,
323
- compression_ratio=0.0,
324
- )
325
-
326
-
327
- def _get_pipeline() -> Any:
328
- """Get or create the singleton compression pipeline."""
329
- global _pipeline
330
-
331
- if _pipeline is not None:
332
- return _pipeline
333
-
334
- with _pipeline_lock:
335
- if _pipeline is not None:
336
- return _pipeline
337
-
338
- from headroom.transforms import TransformPipeline
339
-
340
- # Default pipeline: CacheAligner → ContentRouter → IntelligentContext
341
- # CacheAligner: stabilizes prefix for provider KV cache hits
342
- # ContentRouter: routes to the right compressor per content type
343
- # (SmartCrusher for JSON, CodeCompressor for code, Kompress for text)
344
- # IntelligentContext: enforces token limits with score-based dropping
345
- _pipeline = TransformPipeline()
346
- logger.debug("Headroom compression pipeline initialized")
347
- return _pipeline
 
1
+ """One-function compression API for Headroom.
2
+
3
+ The simplest way to use Headroom — no proxy, no config, just compress:
4
+
5
+ from headroom import compress
6
+
7
+ result = compress(messages, model="claude-sonnet-4-5-20250929")
8
+ result.messages # Compressed messages (same format, fewer tokens)
9
+ result.tokens_saved # Tokens saved
10
+ result.compression_ratio # e.g., 0.35 means 65% saved
11
+
12
+ Works with any LLM client, any proxy, any framework. Just compress
13
+ the messages before sending them.
14
+
15
+ Examples:
16
+
17
+ # With Anthropic SDK
18
+ from anthropic import Anthropic
19
+ from headroom import compress
20
+
21
+ client = Anthropic()
22
+ messages = [{"role": "user", "content": huge_tool_output}]
23
+ compressed = compress(messages, model="claude-sonnet-4-5-20250929")
24
+ response = client.messages.create(
25
+ model="claude-sonnet-4-5-20250929",
26
+ messages=compressed.messages,
27
+ )
28
+
29
+ # With OpenAI SDK
30
+ from openai import OpenAI
31
+ from headroom import compress
32
+
33
+ client = OpenAI()
34
+ messages = [{"role": "user", "content": "analyze this"}, {"role": "tool", "content": big_data}]
35
+ compressed = compress(messages, model="gpt-4o")
36
+ response = client.chat.completions.create(model="gpt-4o", messages=compressed.messages)
37
+
38
+ # With LiteLLM
39
+ import litellm
40
+ from headroom import compress
41
+
42
+ messages = [...]
43
+ compressed = compress(messages, model="bedrock/claude-sonnet")
44
+ response = litellm.completion(model="bedrock/claude-sonnet", messages=compressed.messages)
45
+
46
+ # With any HTTP client
47
+ import httpx
48
+ from headroom import compress
49
+
50
+ compressed = compress(messages, model="claude-sonnet-4-5-20250929")
51
+ httpx.post("https://api.anthropic.com/v1/messages", json={
52
+ "model": "claude-sonnet-4-5-20250929",
53
+ "messages": compressed.messages,
54
+ })
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import logging
60
+ import threading
61
+ from dataclasses import dataclass, field
62
+ from typing import Any
63
+
64
+ from .observability import get_otel_metrics
65
+ from .pipeline import PipelineExtensionManager, PipelineStage, summarize_routing_markers
66
+ from .utils import extract_user_query as _extract_user_query
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ # Lazy-initialized singleton pipeline
72
+ _pipeline = None
73
+ _pipeline_lock = threading.Lock()
74
+
75
+
76
+ @dataclass
77
+ class CompressConfig:
78
+ """User-facing compression options.
79
+
80
+ Controls what gets compressed, how aggressively, and with which model.
81
+ Pass to ``compress()`` or any integration that uses headroom.
82
+
83
+ Examples::
84
+
85
+ # Coding agent (default — skip user messages, protect recent)
86
+ compress(messages, model="gpt-4o")
87
+
88
+ # Financial document (compress everything, keep 50%)
89
+ compress(messages, model="claude-opus-4-20250514",
90
+ compress_user_messages=True,
91
+ target_ratio=0.5,
92
+ protect_recent=0,
93
+ )
94
+
95
+ # Aggressive (logs, search results)
96
+ compress(messages, model="gpt-4o", target_ratio=0.2)
97
+ """
98
+
99
+ # What to compress
100
+ compress_user_messages: bool = False
101
+ """Compress user messages too (default: skip them for coding agents).
102
+ Set True for document compression, RAG pipelines, or when user messages
103
+ contain large tool outputs."""
104
+
105
+ compress_system_messages: bool = True
106
+ """Compress system messages (default: True).
107
+ Set False to preserve system prompts exactly as-is. Useful for voice
108
+ agents where tool definitions and instructions must not be altered."""
109
+
110
+ protect_recent: int = 4
111
+ """Don't compress the last N messages (they're the active conversation).
112
+ Set 0 to compress everything."""
113
+
114
+ protect_analysis_context: bool = True
115
+ """Detect 'analyze'/'review' intent and protect code from compression."""
116
+
117
+ # How aggressive
118
+ target_ratio: float | None = None
119
+ """Keep ratio for Kompress. None = model decides (~15% kept, aggressive).
120
+ 0.5 = keep 50% (safe for documents). 0.7 = keep 70% (conservative).
121
+ Only affects Kompress (text compression). SmartCrusher (JSON) has its
122
+ own logic based on array dedup."""
123
+
124
+ min_tokens_to_compress: int = 250
125
+ """Minimum token count for a message to be compressed.
126
+ Messages shorter than this are left unchanged. Default 250.
127
+ Set lower for voice agents where turns are short."""
128
+
129
+ # Model variant
130
+ kompress_model: str | None = None
131
+ """Kompress model ID. None = default (chopratejas/kompress-base).
132
+ Set to a HuggingFace model ID for domain-specific compression.
133
+ Set to 'disabled' to skip ML compression entirely
134
+ (only SmartCrusher + CacheAligner will run)."""
135
+
136
+
137
+ @dataclass
138
+ class CompressResult:
139
+ """Result of compressing messages.
140
+
141
+ Attributes:
142
+ messages: The compressed messages (same format as input).
143
+ tokens_before: Token count before compression.
144
+ tokens_after: Token count after compression.
145
+ tokens_saved: Tokens removed by compression.
146
+ compression_ratio: Ratio of tokens saved (0.0 = no savings, 1.0 = 100% removed).
147
+ transforms_applied: List of transforms that were applied.
148
+ """
149
+
150
+ messages: list[dict[str, Any]]
151
+ tokens_before: int = 0
152
+ tokens_after: int = 0
153
+ tokens_saved: int = 0
154
+ compression_ratio: float = 0.0
155
+ transforms_applied: list[str] = field(default_factory=list)
156
+
157
+
158
+ def compress(
159
+ messages: list[dict[str, Any]],
160
+ model: str = "claude-sonnet-4-5-20250929",
161
+ model_limit: int = 200000,
162
+ optimize: bool = True,
163
+ hooks: Any = None,
164
+ config: CompressConfig | None = None,
165
+ **kwargs: Any,
166
+ ) -> CompressResult:
167
+ """Compress messages using Headroom's full compression pipeline.
168
+
169
+ This is the simplest way to use Headroom. No proxy, no config needed.
170
+ Just pass messages and get compressed messages back.
171
+
172
+ Args:
173
+ messages: List of messages in Anthropic or OpenAI format.
174
+ model: Model name (used for token counting and context limit).
175
+ model_limit: Model's context window size in tokens.
176
+ optimize: Whether to actually compress (False = passthrough for A/B testing).
177
+ hooks: Optional CompressionHooks instance for custom behavior.
178
+ config: Compression options (CompressConfig). Overrides defaults.
179
+ **kwargs: Shorthand for CompressConfig fields. These override config:
180
+ compress_user_messages, target_ratio, protect_recent,
181
+ protect_analysis_context, kompress_model.
182
+
183
+ Returns:
184
+ CompressResult with compressed messages and metrics.
185
+
186
+ Examples::
187
+
188
+ # Default (coding agent)
189
+ result = compress(messages, model="gpt-4o")
190
+
191
+ # Financial document (keep 50%, compress everything)
192
+ result = compress(messages, model="claude-opus-4-20250514",
193
+ compress_user_messages=True,
194
+ target_ratio=0.5,
195
+ protect_recent=0,
196
+ )
197
+ """
198
+ if not messages or not optimize:
199
+ return CompressResult(messages=messages)
200
+
201
+ # Build config from explicit config + kwargs
202
+ cfg = config or CompressConfig()
203
+ config_fields = {f.name for f in cfg.__dataclass_fields__.values()}
204
+ for key, value in kwargs.items():
205
+ if key in config_fields:
206
+ setattr(cfg, key, value)
207
+
208
+ pipeline = _get_pipeline()
209
+ pipeline_extensions = PipelineExtensionManager(hooks=hooks, discover=False)
210
+
211
+ try:
212
+ # Compute biases from hooks if provided
213
+ biases = None
214
+ if hooks:
215
+ from headroom.hooks import CompressContext
216
+
217
+ ctx = CompressContext(model=model)
218
+ messages = hooks.pre_compress(messages, ctx)
219
+ biases = hooks.compute_biases(messages, ctx)
220
+
221
+ received_event = pipeline_extensions.emit(
222
+ PipelineStage.INPUT_RECEIVED,
223
+ operation="compress",
224
+ model=model,
225
+ messages=messages,
226
+ )
227
+ if received_event.messages is not None:
228
+ messages = received_event.messages
229
+
230
+ # Extract user query from messages so transforms can score by
231
+ # relevance. Without this, SmartCrusher selects items by statistics
232
+ # alone (position, anomaly) and may drop relevant content.
233
+ context = _extract_user_query(messages)
234
+
235
+ result = pipeline.apply(
236
+ messages=messages,
237
+ model=model,
238
+ model_limit=model_limit,
239
+ context=context,
240
+ biases=biases,
241
+ # Pass CompressConfig options through to transforms
242
+ compress_user_messages=cfg.compress_user_messages,
243
+ compress_system_messages=cfg.compress_system_messages,
244
+ target_ratio=cfg.target_ratio,
245
+ protect_recent=cfg.protect_recent,
246
+ protect_analysis_context=cfg.protect_analysis_context,
247
+ min_tokens_to_compress=cfg.min_tokens_to_compress,
248
+ kompress_model=cfg.kompress_model,
249
+ )
250
+
251
+ tokens_before = result.tokens_before
252
+ tokens_after = result.tokens_after
253
+ compressed_messages = result.messages
254
+
255
+ routing_markers = summarize_routing_markers(result.transforms_applied)
256
+ if routing_markers:
257
+ routed_event = pipeline_extensions.emit(
258
+ PipelineStage.INPUT_ROUTED,
259
+ operation="compress",
260
+ model=model,
261
+ messages=compressed_messages,
262
+ metadata={
263
+ "routing_markers": routing_markers,
264
+ "transforms_applied": result.transforms_applied,
265
+ },
266
+ )
267
+ if routed_event.messages is not None:
268
+ compressed_messages = routed_event.messages
269
+
270
+ compressed_event = pipeline_extensions.emit(
271
+ PipelineStage.INPUT_COMPRESSED,
272
+ operation="compress",
273
+ model=model,
274
+ messages=compressed_messages,
275
+ metadata={
276
+ "tokens_before": tokens_before,
277
+ "tokens_after": tokens_after,
278
+ "transforms_applied": result.transforms_applied,
279
+ },
280
+ )
281
+ if compressed_event.messages is not None:
282
+ compressed_messages = compressed_event.messages
283
+
284
+ tokens_saved = tokens_before - tokens_after
285
+ ratio = tokens_saved / tokens_before if tokens_before > 0 else 0.0
286
+
287
+ # Post-compress hook
288
+ if hooks and tokens_saved > 0:
289
+ from headroom.hooks import CompressEvent
290
+
291
+ hooks.post_compress(
292
+ CompressEvent(
293
+ tokens_before=tokens_before,
294
+ tokens_after=tokens_after,
295
+ tokens_saved=tokens_saved,
296
+ compression_ratio=ratio,
297
+ transforms_applied=result.transforms_applied,
298
+ model=model,
299
+ )
300
+ )
301
+
302
+ return CompressResult(
303
+ messages=compressed_messages,
304
+ tokens_before=tokens_before,
305
+ tokens_after=tokens_after,
306
+ tokens_saved=tokens_saved,
307
+ compression_ratio=ratio,
308
+ transforms_applied=result.transforms_applied,
309
+ )
310
+
311
+ except Exception as e:
312
+ get_otel_metrics().record_compression_failure(
313
+ model=model,
314
+ operation="compress",
315
+ error_type=type(e).__name__,
316
+ )
317
+ logger.warning("Compression failed, returning original messages: %s", e)
318
+ return CompressResult(
319
+ messages=messages,
320
+ tokens_before=0,
321
+ tokens_after=0,
322
+ tokens_saved=0,
323
+ compression_ratio=0.0,
324
+ )
325
+
326
+
327
+ def _get_pipeline() -> Any:
328
+ """Get or create the singleton compression pipeline."""
329
+ global _pipeline
330
+
331
+ if _pipeline is not None:
332
+ return _pipeline
333
+
334
+ with _pipeline_lock:
335
+ if _pipeline is not None:
336
+ return _pipeline
337
+
338
+ from headroom.transforms import TransformPipeline
339
+
340
+ # Default pipeline: CacheAligner → ContentRouter → IntelligentContext
341
+ # CacheAligner: stabilizes prefix for provider KV cache hits
342
+ # ContentRouter: routes to the right compressor per content type
343
+ # (SmartCrusher for JSON, CodeCompressor for code, Kompress for text)
344
+ # IntelligentContext: enforces token limits with score-based dropping
345
+ _pipeline = TransformPipeline()
346
+ logger.debug("Headroom compression pipeline initialized")
347
+ return _pipeline
headroom/copilot_auth.py CHANGED
@@ -1,444 +1,444 @@
1
- """GitHub Copilot OAuth discovery and API-token exchange helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import ctypes
7
- import json
8
- import logging
9
- import os
10
- import subprocess
11
- import time
12
- from ctypes import wintypes
13
- from dataclasses import dataclass
14
- from datetime import datetime
15
- from pathlib import Path
16
- from typing import Any
17
- from urllib import error as urllib_error
18
- from urllib import request as urllib_request
19
- from urllib.parse import urlparse
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- DEFAULT_API_URL = "https://api.githubcopilot.com"
24
- DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
25
- DEFAULT_GITHUB_HOST = "github.com"
26
- _TOKEN_EXPIRY_BUFFER_S = 60
27
- _DEFAULT_EDITOR_VERSION = "vscode/1.104.1"
28
- _DEFAULT_USER_AGENT = "GitHubCopilotChat/0.1"
29
-
30
- _API_TOKEN_ENV_VARS = (
31
- "GITHUB_COPILOT_API_TOKEN",
32
- "COPILOT_PROVIDER_BEARER_TOKEN",
33
- )
34
- _OAUTH_TOKEN_ENV_VARS = (
35
- "GITHUB_COPILOT_GITHUB_TOKEN",
36
- "GITHUB_COPILOT_TOKEN",
37
- "GITHUB_TOKEN",
38
- "COPILOT_GITHUB_TOKEN",
39
- )
40
- _OAUTH_TOKEN_KEYS = (
41
- "oauth_token",
42
- "oauthToken",
43
- "token",
44
- "access_token",
45
- "accessToken",
46
- )
47
- _EXPIRY_KEYS = ("expires_at", "expiresAt", "expiry", "expires")
48
-
49
-
50
- @dataclass(frozen=True)
51
- class CopilotAPIToken:
52
- """Short-lived API token exchanged from a GitHub OAuth token."""
53
-
54
- token: str
55
- expires_at: float
56
- api_url: str = DEFAULT_API_URL
57
- refresh_in: int | None = None
58
- sku: str | None = None
59
-
60
- @property
61
- def is_valid(self) -> bool:
62
- return time.time() < (self.expires_at - _TOKEN_EXPIRY_BUFFER_S)
63
-
64
-
65
- def _github_host() -> str:
66
- return (os.environ.get("GITHUB_COPILOT_HOST") or DEFAULT_GITHUB_HOST).strip().lower()
67
-
68
-
69
- def _token_exchange_url() -> str:
70
- return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip()
71
-
72
-
73
- def _should_exchange_oauth_token() -> bool:
74
- raw = os.environ.get("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "").strip().lower()
75
- return raw in {"1", "true", "yes", "on"}
76
-
77
-
78
- def _resolve_token_file_paths() -> list[Path]:
79
- override = os.environ.get("GITHUB_COPILOT_TOKEN_FILE", "").strip()
80
- if override:
81
- return [Path(override).expanduser()]
82
-
83
- paths: list[Path] = []
84
- local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
85
- if local_appdata:
86
- base = Path(local_appdata) / "github-copilot"
87
- paths.extend([base / "apps.json", base / "hosts.json"])
88
-
89
- config_base = Path.home() / ".config" / "github-copilot"
90
- paths.extend([config_base / "apps.json", config_base / "hosts.json"])
91
- return paths
92
-
93
-
94
- def _read_gh_cli_oauth_token() -> str | None:
95
- gh_bin = os.environ.get("GH_PATH", "").strip() or "gh"
96
- command = [gh_bin, "auth", "token"]
97
- host = _github_host()
98
- if host and host != DEFAULT_GITHUB_HOST:
99
- command.extend(["--hostname", host])
100
-
101
- try:
102
- result = subprocess.run(
103
- command,
104
- capture_output=True,
105
- text=True,
106
- encoding="utf-8",
107
- errors="replace",
108
- check=False,
109
- )
110
- except OSError as exc:
111
- logger.debug("Unable to invoke GitHub CLI for Copilot auth discovery: %s", exc)
112
- return None
113
-
114
- if result.returncode != 0:
115
- logger.debug("GitHub CLI auth token lookup failed with exit code %s", result.returncode)
116
- return None
117
-
118
- token = result.stdout.strip()
119
- return token or None
120
-
121
-
122
- def _read_windows_copilot_cli_oauth_token() -> str | None:
123
- if os.name != "nt":
124
- return None
125
-
126
- class FILETIME(ctypes.Structure):
127
- _fields_ = [
128
- ("dwLowDateTime", wintypes.DWORD),
129
- ("dwHighDateTime", wintypes.DWORD),
130
- ]
131
-
132
- class CREDENTIAL(ctypes.Structure):
133
- _fields_ = [
134
- ("Flags", wintypes.DWORD),
135
- ("Type", wintypes.DWORD),
136
- ("TargetName", wintypes.LPWSTR),
137
- ("Comment", wintypes.LPWSTR),
138
- ("LastWritten", FILETIME),
139
- ("CredentialBlobSize", wintypes.DWORD),
140
- ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
141
- ("Persist", wintypes.DWORD),
142
- ("AttributeCount", wintypes.DWORD),
143
- ("Attributes", wintypes.LPVOID),
144
- ("TargetAlias", wintypes.LPWSTR),
145
- ("UserName", wintypes.LPWSTR),
146
- ]
147
-
148
- cred_ptr = ctypes.POINTER(CREDENTIAL)
149
- credentials = ctypes.POINTER(cred_ptr)()
150
- count = wintypes.DWORD()
151
- win_dll = getattr(ctypes, "WinDLL", None)
152
- if win_dll is None:
153
- return None
154
-
155
- advapi32 = win_dll("Advapi32.dll")
156
- advapi32.CredEnumerateW.argtypes = [
157
- wintypes.LPCWSTR,
158
- wintypes.DWORD,
159
- ctypes.POINTER(wintypes.DWORD),
160
- ctypes.POINTER(ctypes.POINTER(cred_ptr)),
161
- ]
162
- advapi32.CredEnumerateW.restype = wintypes.BOOL
163
- advapi32.CredFree.argtypes = [wintypes.LPVOID]
164
-
165
- try:
166
- if not advapi32.CredEnumerateW(None, 0, ctypes.byref(count), ctypes.byref(credentials)):
167
- return None
168
- except OSError as exc:
169
- logger.debug("Unable to enumerate Windows credentials for Copilot auth discovery: %s", exc)
170
- return None
171
-
172
- host = _github_host().lower()
173
- service_prefixes = [f"copilot-cli/{host}:"]
174
- if "://" not in host:
175
- service_prefixes.append(f"copilot-cli/https://{host}:")
176
-
177
- try:
178
- for idx in range(count.value):
179
- credential = credentials[idx].contents
180
- target = (credential.TargetName or "").strip().lower()
181
- if not any(target.startswith(prefix) for prefix in service_prefixes):
182
- continue
183
- if credential.CredentialBlobSize <= 0 or not credential.CredentialBlob:
184
- continue
185
- blob = ctypes.string_at(credential.CredentialBlob, credential.CredentialBlobSize)
186
- token = blob.decode("utf-8", errors="replace").strip()
187
- if token:
188
- return token
189
- finally:
190
- if credentials:
191
- advapi32.CredFree(credentials)
192
-
193
- return None
194
-
195
-
196
- def _parse_expiry(value: Any) -> float | None:
197
- if value in (None, ""):
198
- return None
199
-
200
- if isinstance(value, int | float):
201
- number = float(value)
202
- if number > 10_000_000_000:
203
- return number / 1000.0
204
- return number
205
-
206
- if isinstance(value, str):
207
- raw = value.strip()
208
- if not raw:
209
- return None
210
- if raw.isdigit():
211
- return _parse_expiry(int(raw))
212
- try:
213
- normalized = raw.replace("Z", "+00:00")
214
- return datetime.fromisoformat(normalized).timestamp()
215
- except ValueError:
216
- return None
217
-
218
- return None
219
-
220
-
221
- def _entry_expired(entry: dict[str, Any]) -> bool:
222
- for key in _EXPIRY_KEYS:
223
- expiry = _parse_expiry(entry.get(key))
224
- if expiry is None:
225
- continue
226
- return time.time() >= (expiry - _TOKEN_EXPIRY_BUFFER_S)
227
- return False
228
-
229
-
230
- def _extract_oauth_token(entry: dict[str, Any]) -> str | None:
231
- if _entry_expired(entry):
232
- return None
233
-
234
- for key in _OAUTH_TOKEN_KEYS:
235
- value = entry.get(key)
236
- if isinstance(value, str) and value.strip():
237
- return value.strip()
238
-
239
- for value in entry.values():
240
- if isinstance(value, dict):
241
- nested = _extract_oauth_token(value)
242
- if nested:
243
- return nested
244
-
245
- return None
246
-
247
-
248
- def _iter_file_entries(payload: Any) -> list[tuple[str, dict[str, Any]]]:
249
- entries: list[tuple[str, dict[str, Any]]] = []
250
- if isinstance(payload, dict):
251
- for key, value in payload.items():
252
- if isinstance(value, dict):
253
- entries.append((str(key), value))
254
- elif isinstance(payload, list):
255
- for idx, value in enumerate(payload):
256
- if isinstance(value, dict):
257
- key = str(value.get("host") or value.get("githubHost") or idx)
258
- entries.append((key, value))
259
- return entries
260
-
261
-
262
- def read_cached_oauth_token() -> str | None:
263
- """Return a GitHub OAuth token for Copilot, if one is available."""
264
-
265
- for env_var in _OAUTH_TOKEN_ENV_VARS:
266
- token = os.environ.get(env_var, "").strip()
267
- if token:
268
- return token
269
-
270
- windows_copilot_token = _read_windows_copilot_cli_oauth_token()
271
- if windows_copilot_token:
272
- return windows_copilot_token
273
-
274
- gh_token = _read_gh_cli_oauth_token()
275
- if gh_token:
276
- return gh_token
277
-
278
- host = _github_host()
279
- for path in _resolve_token_file_paths():
280
- try:
281
- payload = json.loads(path.read_text(encoding="utf-8"))
282
- except FileNotFoundError:
283
- continue
284
- except Exception as exc:
285
- logger.debug("Unable to read Copilot credentials file %s: %s", path, exc)
286
- continue
287
-
288
- for key, entry in _iter_file_entries(payload):
289
- if host not in key.lower():
290
- continue
291
- cached_token = _extract_oauth_token(entry)
292
- if cached_token:
293
- return cached_token
294
-
295
- return None
296
-
297
-
298
- def resolve_client_bearer_token() -> str | None:
299
- """Return a bearer token suitable for satisfying Copilot provider auth checks."""
300
-
301
- for env_var in _API_TOKEN_ENV_VARS:
302
- token = os.environ.get(env_var, "").strip()
303
- if token:
304
- return token
305
- return read_cached_oauth_token()
306
-
307
-
308
- def has_oauth_auth() -> bool:
309
- """Return True when existing Copilot auth can be reused."""
310
-
311
- return resolve_client_bearer_token() is not None
312
-
313
-
314
- def is_copilot_api_url(url: str | None) -> bool:
315
- """Return True when the upstream URL points at GitHub Copilot."""
316
-
317
- if not url:
318
- return False
319
- parsed = urlparse(url)
320
- host = parsed.netloc.lower() or parsed.path.lower()
321
- return "githubcopilot.com" in host
322
-
323
-
324
- def build_copilot_upstream_url(base_url: str, path: str) -> str:
325
- """Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
326
-
327
- normalized_base = base_url.rstrip("/")
328
- normalized_path = path if path.startswith("/") else f"/{path}"
329
- if is_copilot_api_url(normalized_base) and normalized_path.startswith("/v1/"):
330
- normalized_path = normalized_path[3:]
331
- return f"{normalized_base}{normalized_path}"
332
-
333
-
334
- class CopilotTokenProvider:
335
- """Resolve and cache short-lived Copilot API tokens."""
336
-
337
- def __init__(self) -> None:
338
- self._lock = asyncio.Lock()
339
- self._cached: CopilotAPIToken | None = None
340
-
341
- async def get_api_token(self) -> CopilotAPIToken:
342
- explicit_api_token = os.environ.get("GITHUB_COPILOT_API_TOKEN", "").strip()
343
- if explicit_api_token:
344
- return CopilotAPIToken(
345
- token=explicit_api_token,
346
- expires_at=time.time() + 3600,
347
- api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip()
348
- or DEFAULT_API_URL,
349
- )
350
-
351
- cached = self._cached
352
- if cached is not None and cached.is_valid:
353
- return cached
354
-
355
- async with self._lock:
356
- cached = self._cached
357
- if cached is not None and cached.is_valid:
358
- return cached
359
-
360
- oauth_token = read_cached_oauth_token()
361
- if not oauth_token:
362
- raise RuntimeError("No GitHub Copilot OAuth token is available.")
363
-
364
- if not _should_exchange_oauth_token():
365
- direct_token = CopilotAPIToken(
366
- token=oauth_token,
367
- expires_at=time.time() + 3600,
368
- api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip()
369
- or DEFAULT_API_URL,
370
- )
371
- self._cached = direct_token
372
- return direct_token
373
-
374
- exchanged = await self._exchange_token(oauth_token)
375
- self._cached = exchanged
376
- return exchanged
377
-
378
- async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken:
379
- headers = {
380
- "Authorization": f"token {oauth_token}",
381
- "Accept": "application/json",
382
- "Editor-Version": os.environ.get(
383
- "GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
384
- ),
385
- "User-Agent": _DEFAULT_USER_AGENT,
386
- }
387
- payload = await asyncio.to_thread(self._exchange_token_sync, headers)
388
- token = str(payload.get("token") or "").strip()
389
- if not token:
390
- raise RuntimeError("Copilot token exchange returned an empty token.")
391
-
392
- expires_at = _parse_expiry(payload.get("expires_at")) or (time.time() + 1800)
393
- raw_endpoints = payload.get("endpoints")
394
- endpoints: dict[str, Any] = raw_endpoints if isinstance(raw_endpoints, dict) else {}
395
- api_url = str(endpoints.get("api") or DEFAULT_API_URL).strip() or DEFAULT_API_URL
396
- refresh_in = payload.get("refresh_in")
397
- sku = payload.get("sku")
398
- return CopilotAPIToken(
399
- token=token,
400
- expires_at=expires_at,
401
- api_url=api_url,
402
- refresh_in=int(refresh_in) if isinstance(refresh_in, int | float) else None,
403
- sku=str(sku) if isinstance(sku, str) and sku.strip() else None,
404
- )
405
-
406
- @staticmethod
407
- def _exchange_token_sync(headers: dict[str, str]) -> dict[str, Any]:
408
- request = urllib_request.Request(_token_exchange_url(), headers=headers, method="GET")
409
- try:
410
- with urllib_request.urlopen(request, timeout=10.0) as response:
411
- payload = json.loads(response.read().decode("utf-8"))
412
- return payload if isinstance(payload, dict) else {}
413
- except urllib_error.HTTPError as exc:
414
- body = exc.read().decode("utf-8", errors="replace")
415
- raise RuntimeError(
416
- f"Copilot token exchange failed with HTTP {exc.code}: {body}"
417
- ) from exc
418
-
419
-
420
- _provider: CopilotTokenProvider | None = None
421
-
422
-
423
- def get_copilot_token_provider() -> CopilotTokenProvider:
424
- """Return the shared Copilot token provider."""
425
-
426
- global _provider
427
- if _provider is None:
428
- _provider = CopilotTokenProvider()
429
- return _provider
430
-
431
-
432
- async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[str, str]:
433
- """Replace Authorization with a fresh Copilot API token when targeting Copilot."""
434
-
435
- resolved = dict(headers)
436
- if not is_copilot_api_url(url):
437
- return resolved
438
-
439
- token = await get_copilot_token_provider().get_api_token()
440
- for key in list(resolved):
441
- if key.lower() == "authorization":
442
- resolved.pop(key)
443
- resolved["Authorization"] = f"Bearer {token.token}"
444
- return resolved
 
1
+ """GitHub Copilot OAuth discovery and API-token exchange helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ctypes
7
+ import json
8
+ import logging
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from ctypes import wintypes
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib import error as urllib_error
18
+ from urllib import request as urllib_request
19
+ from urllib.parse import urlparse
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ DEFAULT_API_URL = "https://api.githubcopilot.com"
24
+ DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
25
+ DEFAULT_GITHUB_HOST = "github.com"
26
+ _TOKEN_EXPIRY_BUFFER_S = 60
27
+ _DEFAULT_EDITOR_VERSION = "vscode/1.104.1"
28
+ _DEFAULT_USER_AGENT = "GitHubCopilotChat/0.1"
29
+
30
+ _API_TOKEN_ENV_VARS = (
31
+ "GITHUB_COPILOT_API_TOKEN",
32
+ "COPILOT_PROVIDER_BEARER_TOKEN",
33
+ )
34
+ _OAUTH_TOKEN_ENV_VARS = (
35
+ "GITHUB_COPILOT_GITHUB_TOKEN",
36
+ "GITHUB_COPILOT_TOKEN",
37
+ "GITHUB_TOKEN",
38
+ "COPILOT_GITHUB_TOKEN",
39
+ )
40
+ _OAUTH_TOKEN_KEYS = (
41
+ "oauth_token",
42
+ "oauthToken",
43
+ "token",
44
+ "access_token",
45
+ "accessToken",
46
+ )
47
+ _EXPIRY_KEYS = ("expires_at", "expiresAt", "expiry", "expires")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class CopilotAPIToken:
52
+ """Short-lived API token exchanged from a GitHub OAuth token."""
53
+
54
+ token: str
55
+ expires_at: float
56
+ api_url: str = DEFAULT_API_URL
57
+ refresh_in: int | None = None
58
+ sku: str | None = None
59
+
60
+ @property
61
+ def is_valid(self) -> bool:
62
+ return time.time() < (self.expires_at - _TOKEN_EXPIRY_BUFFER_S)
63
+
64
+
65
+ def _github_host() -> str:
66
+ return (os.environ.get("GITHUB_COPILOT_HOST") or DEFAULT_GITHUB_HOST).strip().lower()
67
+
68
+
69
+ def _token_exchange_url() -> str:
70
+ return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip()
71
+
72
+
73
+ def _should_exchange_oauth_token() -> bool:
74
+ raw = os.environ.get("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "").strip().lower()
75
+ return raw in {"1", "true", "yes", "on"}
76
+
77
+
78
+ def _resolve_token_file_paths() -> list[Path]:
79
+ override = os.environ.get("GITHUB_COPILOT_TOKEN_FILE", "").strip()
80
+ if override:
81
+ return [Path(override).expanduser()]
82
+
83
+ paths: list[Path] = []
84
+ local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
85
+ if local_appdata:
86
+ base = Path(local_appdata) / "github-copilot"
87
+ paths.extend([base / "apps.json", base / "hosts.json"])
88
+
89
+ config_base = Path.home() / ".config" / "github-copilot"
90
+ paths.extend([config_base / "apps.json", config_base / "hosts.json"])
91
+ return paths
92
+
93
+
94
+ def _read_gh_cli_oauth_token() -> str | None:
95
+ gh_bin = os.environ.get("GH_PATH", "").strip() or "gh"
96
+ command = [gh_bin, "auth", "token"]
97
+ host = _github_host()
98
+ if host and host != DEFAULT_GITHUB_HOST:
99
+ command.extend(["--hostname", host])
100
+
101
+ try:
102
+ result = subprocess.run(
103
+ command,
104
+ capture_output=True,
105
+ text=True,
106
+ encoding="utf-8",
107
+ errors="replace",
108
+ check=False,
109
+ )
110
+ except OSError as exc:
111
+ logger.debug("Unable to invoke GitHub CLI for Copilot auth discovery: %s", exc)
112
+ return None
113
+
114
+ if result.returncode != 0:
115
+ logger.debug("GitHub CLI auth token lookup failed with exit code %s", result.returncode)
116
+ return None
117
+
118
+ token = result.stdout.strip()
119
+ return token or None
120
+
121
+
122
+ def _read_windows_copilot_cli_oauth_token() -> str | None:
123
+ if os.name != "nt":
124
+ return None
125
+
126
+ class FILETIME(ctypes.Structure):
127
+ _fields_ = [
128
+ ("dwLowDateTime", wintypes.DWORD),
129
+ ("dwHighDateTime", wintypes.DWORD),
130
+ ]
131
+
132
+ class CREDENTIAL(ctypes.Structure):
133
+ _fields_ = [
134
+ ("Flags", wintypes.DWORD),
135
+ ("Type", wintypes.DWORD),
136
+ ("TargetName", wintypes.LPWSTR),
137
+ ("Comment", wintypes.LPWSTR),
138
+ ("LastWritten", FILETIME),
139
+ ("CredentialBlobSize", wintypes.DWORD),
140
+ ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
141
+ ("Persist", wintypes.DWORD),
142
+ ("AttributeCount", wintypes.DWORD),
143
+ ("Attributes", wintypes.LPVOID),
144
+ ("TargetAlias", wintypes.LPWSTR),
145
+ ("UserName", wintypes.LPWSTR),
146
+ ]
147
+
148
+ cred_ptr = ctypes.POINTER(CREDENTIAL)
149
+ credentials = ctypes.POINTER(cred_ptr)()
150
+ count = wintypes.DWORD()
151
+ win_dll = getattr(ctypes, "WinDLL", None)
152
+ if win_dll is None:
153
+ return None
154
+
155
+ advapi32 = win_dll("Advapi32.dll")
156
+ advapi32.CredEnumerateW.argtypes = [
157
+ wintypes.LPCWSTR,
158
+ wintypes.DWORD,
159
+ ctypes.POINTER(wintypes.DWORD),
160
+ ctypes.POINTER(ctypes.POINTER(cred_ptr)),
161
+ ]
162
+ advapi32.CredEnumerateW.restype = wintypes.BOOL
163
+ advapi32.CredFree.argtypes = [wintypes.LPVOID]
164
+
165
+ try:
166
+ if not advapi32.CredEnumerateW(None, 0, ctypes.byref(count), ctypes.byref(credentials)):
167
+ return None
168
+ except OSError as exc:
169
+ logger.debug("Unable to enumerate Windows credentials for Copilot auth discovery: %s", exc)
170
+ return None
171
+
172
+ host = _github_host().lower()
173
+ service_prefixes = [f"copilot-cli/{host}:"]
174
+ if "://" not in host:
175
+ service_prefixes.append(f"copilot-cli/https://{host}:")
176
+
177
+ try:
178
+ for idx in range(count.value):
179
+ credential = credentials[idx].contents
180
+ target = (credential.TargetName or "").strip().lower()
181
+ if not any(target.startswith(prefix) for prefix in service_prefixes):
182
+ continue
183
+ if credential.CredentialBlobSize <= 0 or not credential.CredentialBlob:
184
+ continue
185
+ blob = ctypes.string_at(credential.CredentialBlob, credential.CredentialBlobSize)
186
+ token = blob.decode("utf-8", errors="replace").strip()
187
+ if token:
188
+ return token
189
+ finally:
190
+ if credentials:
191
+ advapi32.CredFree(credentials)
192
+
193
+ return None
194
+
195
+
196
+ def _parse_expiry(value: Any) -> float | None:
197
+ if value in (None, ""):
198
+ return None
199
+
200
+ if isinstance(value, int | float):
201
+ number = float(value)
202
+ if number > 10_000_000_000:
203
+ return number / 1000.0
204
+ return number
205
+
206
+ if isinstance(value, str):
207
+ raw = value.strip()
208
+ if not raw:
209
+ return None
210
+ if raw.isdigit():
211
+ return _parse_expiry(int(raw))
212
+ try:
213
+ normalized = raw.replace("Z", "+00:00")
214
+ return datetime.fromisoformat(normalized).timestamp()
215
+ except ValueError:
216
+ return None
217
+
218
+ return None
219
+
220
+
221
+ def _entry_expired(entry: dict[str, Any]) -> bool:
222
+ for key in _EXPIRY_KEYS:
223
+ expiry = _parse_expiry(entry.get(key))
224
+ if expiry is None:
225
+ continue
226
+ return time.time() >= (expiry - _TOKEN_EXPIRY_BUFFER_S)
227
+ return False
228
+
229
+
230
+ def _extract_oauth_token(entry: dict[str, Any]) -> str | None:
231
+ if _entry_expired(entry):
232
+ return None
233
+
234
+ for key in _OAUTH_TOKEN_KEYS:
235
+ value = entry.get(key)
236
+ if isinstance(value, str) and value.strip():
237
+ return value.strip()
238
+
239
+ for value in entry.values():
240
+ if isinstance(value, dict):
241
+ nested = _extract_oauth_token(value)
242
+ if nested:
243
+ return nested
244
+
245
+ return None
246
+
247
+
248
+ def _iter_file_entries(payload: Any) -> list[tuple[str, dict[str, Any]]]:
249
+ entries: list[tuple[str, dict[str, Any]]] = []
250
+ if isinstance(payload, dict):
251
+ for key, value in payload.items():
252
+ if isinstance(value, dict):
253
+ entries.append((str(key), value))
254
+ elif isinstance(payload, list):
255
+ for idx, value in enumerate(payload):
256
+ if isinstance(value, dict):
257
+ key = str(value.get("host") or value.get("githubHost") or idx)
258
+ entries.append((key, value))
259
+ return entries
260
+
261
+
262
+ def read_cached_oauth_token() -> str | None:
263
+ """Return a GitHub OAuth token for Copilot, if one is available."""
264
+
265
+ for env_var in _OAUTH_TOKEN_ENV_VARS:
266
+ token = os.environ.get(env_var, "").strip()
267
+ if token:
268
+ return token
269
+
270
+ windows_copilot_token = _read_windows_copilot_cli_oauth_token()
271
+ if windows_copilot_token:
272
+ return windows_copilot_token
273
+
274
+ gh_token = _read_gh_cli_oauth_token()
275
+ if gh_token:
276
+ return gh_token
277
+
278
+ host = _github_host()
279
+ for path in _resolve_token_file_paths():
280
+ try:
281
+ payload = json.loads(path.read_text(encoding="utf-8"))
282
+ except FileNotFoundError:
283
+ continue
284
+ except Exception as exc:
285
+ logger.debug("Unable to read Copilot credentials file %s: %s", path, exc)
286
+ continue
287
+
288
+ for key, entry in _iter_file_entries(payload):
289
+ if host not in key.lower():
290
+ continue
291
+ cached_token = _extract_oauth_token(entry)
292
+ if cached_token:
293
+ return cached_token
294
+
295
+ return None
296
+
297
+
298
+ def resolve_client_bearer_token() -> str | None:
299
+ """Return a bearer token suitable for satisfying Copilot provider auth checks."""
300
+
301
+ for env_var in _API_TOKEN_ENV_VARS:
302
+ token = os.environ.get(env_var, "").strip()
303
+ if token:
304
+ return token
305
+ return read_cached_oauth_token()
306
+
307
+
308
+ def has_oauth_auth() -> bool:
309
+ """Return True when existing Copilot auth can be reused."""
310
+
311
+ return resolve_client_bearer_token() is not None
312
+
313
+
314
+ def is_copilot_api_url(url: str | None) -> bool:
315
+ """Return True when the upstream URL points at GitHub Copilot."""
316
+
317
+ if not url:
318
+ return False
319
+ parsed = urlparse(url)
320
+ host = parsed.netloc.lower() or parsed.path.lower()
321
+ return "githubcopilot.com" in host
322
+
323
+
324
+ def build_copilot_upstream_url(base_url: str, path: str) -> str:
325
+ """Build an upstream URL, normalizing GitHub Copilot's non-/v1 path layout."""
326
+
327
+ normalized_base = base_url.rstrip("/")
328
+ normalized_path = path if path.startswith("/") else f"/{path}"
329
+ if is_copilot_api_url(normalized_base) and normalized_path.startswith("/v1/"):
330
+ normalized_path = normalized_path[3:]
331
+ return f"{normalized_base}{normalized_path}"
332
+
333
+
334
+ class CopilotTokenProvider:
335
+ """Resolve and cache short-lived Copilot API tokens."""
336
+
337
+ def __init__(self) -> None:
338
+ self._lock = asyncio.Lock()
339
+ self._cached: CopilotAPIToken | None = None
340
+
341
+ async def get_api_token(self) -> CopilotAPIToken:
342
+ explicit_api_token = os.environ.get("GITHUB_COPILOT_API_TOKEN", "").strip()
343
+ if explicit_api_token:
344
+ return CopilotAPIToken(
345
+ token=explicit_api_token,
346
+ expires_at=time.time() + 3600,
347
+ api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip()
348
+ or DEFAULT_API_URL,
349
+ )
350
+
351
+ cached = self._cached
352
+ if cached is not None and cached.is_valid:
353
+ return cached
354
+
355
+ async with self._lock:
356
+ cached = self._cached
357
+ if cached is not None and cached.is_valid:
358
+ return cached
359
+
360
+ oauth_token = read_cached_oauth_token()
361
+ if not oauth_token:
362
+ raise RuntimeError("No GitHub Copilot OAuth token is available.")
363
+
364
+ if not _should_exchange_oauth_token():
365
+ direct_token = CopilotAPIToken(
366
+ token=oauth_token,
367
+ expires_at=time.time() + 3600,
368
+ api_url=os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip()
369
+ or DEFAULT_API_URL,
370
+ )
371
+ self._cached = direct_token
372
+ return direct_token
373
+
374
+ exchanged = await self._exchange_token(oauth_token)
375
+ self._cached = exchanged
376
+ return exchanged
377
+
378
+ async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken:
379
+ headers = {
380
+ "Authorization": f"token {oauth_token}",
381
+ "Accept": "application/json",
382
+ "Editor-Version": os.environ.get(
383
+ "GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
384
+ ),
385
+ "User-Agent": _DEFAULT_USER_AGENT,
386
+ }
387
+ payload = await asyncio.to_thread(self._exchange_token_sync, headers)
388
+ token = str(payload.get("token") or "").strip()
389
+ if not token:
390
+ raise RuntimeError("Copilot token exchange returned an empty token.")
391
+
392
+ expires_at = _parse_expiry(payload.get("expires_at")) or (time.time() + 1800)
393
+ raw_endpoints = payload.get("endpoints")
394
+ endpoints: dict[str, Any] = raw_endpoints if isinstance(raw_endpoints, dict) else {}
395
+ api_url = str(endpoints.get("api") or DEFAULT_API_URL).strip() or DEFAULT_API_URL
396
+ refresh_in = payload.get("refresh_in")
397
+ sku = payload.get("sku")
398
+ return CopilotAPIToken(
399
+ token=token,
400
+ expires_at=expires_at,
401
+ api_url=api_url,
402
+ refresh_in=int(refresh_in) if isinstance(refresh_in, int | float) else None,
403
+ sku=str(sku) if isinstance(sku, str) and sku.strip() else None,
404
+ )
405
+
406
+ @staticmethod
407
+ def _exchange_token_sync(headers: dict[str, str]) -> dict[str, Any]:
408
+ request = urllib_request.Request(_token_exchange_url(), headers=headers, method="GET")
409
+ try:
410
+ with urllib_request.urlopen(request, timeout=10.0) as response:
411
+ payload = json.loads(response.read().decode("utf-8"))
412
+ return payload if isinstance(payload, dict) else {}
413
+ except urllib_error.HTTPError as exc:
414
+ body = exc.read().decode("utf-8", errors="replace")
415
+ raise RuntimeError(
416
+ f"Copilot token exchange failed with HTTP {exc.code}: {body}"
417
+ ) from exc
418
+
419
+
420
+ _provider: CopilotTokenProvider | None = None
421
+
422
+
423
+ def get_copilot_token_provider() -> CopilotTokenProvider:
424
+ """Return the shared Copilot token provider."""
425
+
426
+ global _provider
427
+ if _provider is None:
428
+ _provider = CopilotTokenProvider()
429
+ return _provider
430
+
431
+
432
+ async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[str, str]:
433
+ """Replace Authorization with a fresh Copilot API token when targeting Copilot."""
434
+
435
+ resolved = dict(headers)
436
+ if not is_copilot_api_url(url):
437
+ return resolved
438
+
439
+ token = await get_copilot_token_provider().get_api_token()
440
+ for key in list(resolved):
441
+ if key.lower() == "authorization":
442
+ resolved.pop(key)
443
+ resolved["Authorization"] = f"Bearer {token.token}"
444
+ return resolved
headroom/install/health.py CHANGED
@@ -1,28 +1,28 @@
1
- """Health helpers for persistent deployments."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import urllib.error
7
- import urllib.request
8
- from typing import Any
9
-
10
-
11
- def probe_json(url: str, timeout: float = 2.0) -> dict[str, Any] | None:
12
- """Return a JSON payload from the URL when reachable."""
13
-
14
- try:
15
- with urllib.request.urlopen(url, timeout=timeout) as response:
16
- payload = json.loads(response.read().decode("utf-8"))
17
- except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
18
- return None
19
- return payload if isinstance(payload, dict) else None
20
-
21
-
22
- def probe_ready(url: str, timeout: float = 2.0) -> bool:
23
- """Return True when the ready endpoint reports readiness."""
24
-
25
- payload = probe_json(url, timeout=timeout)
26
- if not isinstance(payload, dict):
27
- return False
28
- return bool(payload.get("ready", False) or payload.get("status") == "healthy")
 
1
+ """Health helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ def probe_json(url: str, timeout: float = 2.0) -> dict[str, Any] | None:
12
+ """Return a JSON payload from the URL when reachable."""
13
+
14
+ try:
15
+ with urllib.request.urlopen(url, timeout=timeout) as response:
16
+ payload = json.loads(response.read().decode("utf-8"))
17
+ except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
18
+ return None
19
+ return payload if isinstance(payload, dict) else None
20
+
21
+
22
+ def probe_ready(url: str, timeout: float = 2.0) -> bool:
23
+ """Return True when the ready endpoint reports readiness."""
24
+
25
+ payload = probe_json(url, timeout=timeout)
26
+ if not isinstance(payload, dict):
27
+ return False
28
+ return bool(payload.get("ready", False) or payload.get("status") == "healthy")
headroom/install/providers.py CHANGED
@@ -1,174 +1,174 @@
1
- """Tool-target configuration for persistent deployments."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- import subprocess
8
- from pathlib import Path
9
-
10
- from headroom.providers.install_registry import (
11
- apply_provider_scope_mutations,
12
- revert_provider_scope_mutation,
13
- )
14
-
15
- from .models import ConfigScope, DeploymentManifest, ManagedMutation
16
- from .paths import (
17
- unix_system_env_targets,
18
- unix_user_env_targets,
19
- )
20
-
21
- _ENV_MARKER_START = "# >>> headroom persistent env >>>"
22
- _ENV_MARKER_END = "# <<< headroom persistent env <<<"
23
- _ENV_PATTERN = re.compile(
24
- re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
25
- re.DOTALL,
26
- )
27
-
28
-
29
- def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
30
- if file_path.exists():
31
- existing = file_path.read_text()
32
- if marker in existing:
33
- return pattern.sub(block, existing)
34
- return existing.rstrip() + "\n\n" + block + "\n"
35
- return block + "\n"
36
-
37
-
38
- def _env_block(values: dict[str, str]) -> str:
39
- lines = [_ENV_MARKER_START]
40
- for name, value in values.items():
41
- lines.append(f'export {name}="{value}"')
42
- lines.append(_ENV_MARKER_END)
43
- return "\n".join(lines)
44
-
45
-
46
- def _powershell_literal(value: str) -> str:
47
- return "'" + value.replace("'", "''") + "'"
48
-
49
-
50
- def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
51
- merged = dict(manifest.base_env)
52
- for env_map in manifest.tool_envs.values():
53
- merged.update(env_map)
54
- return merged
55
-
56
-
57
- def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
58
- values = _unix_scope_values(manifest)
59
- block = _env_block(values)
60
- if manifest.scope == ConfigScope.USER.value:
61
- targets = unix_user_env_targets()
62
- else:
63
- targets = unix_system_env_targets()
64
- mutations: list[ManagedMutation] = []
65
- for path in targets:
66
- path.parent.mkdir(parents=True, exist_ok=True)
67
- merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
68
- path.write_text(merged)
69
- mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
70
- return mutations
71
-
72
-
73
- def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
74
- for mutation in mutations:
75
- if mutation.kind != "shell-block" or not mutation.path:
76
- continue
77
- path = Path(mutation.path)
78
- if not path.exists():
79
- continue
80
- content = path.read_text()
81
- if _ENV_MARKER_START not in content:
82
- continue
83
- path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
84
-
85
-
86
- def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
87
- scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
88
- merged = _unix_scope_values(manifest)
89
- mutations: list[ManagedMutation] = []
90
- for name, value in merged.items():
91
- previous = subprocess.run(
92
- [
93
- "powershell",
94
- "-NoProfile",
95
- "-Command",
96
- f"$value = [Environment]::GetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(scope_name)}); "
97
- "if ($null -eq $value) { '__HEADROOM_UNSET__' } else { $value }",
98
- ],
99
- capture_output=True,
100
- text=True,
101
- check=True,
102
- ).stdout.strip()
103
- command = [
104
- "powershell",
105
- "-NoProfile",
106
- "-Command",
107
- f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(value)},{_powershell_literal(scope_name)})",
108
- ]
109
- subprocess.run(command, check=True)
110
- mutations.append(
111
- ManagedMutation(
112
- target="env",
113
- kind="windows-env",
114
- data={
115
- "name": name,
116
- "scope": scope_name,
117
- "previous": None if previous == "__HEADROOM_UNSET__" else previous,
118
- },
119
- )
120
- )
121
- return mutations
122
-
123
-
124
- def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
125
- for mutation in mutations:
126
- if mutation.kind != "windows-env":
127
- continue
128
- name = mutation.data.get("name")
129
- if not isinstance(name, str):
130
- raise ValueError("Windows environment mutation is missing a variable name")
131
- scope_name = mutation.data.get("scope", "User")
132
- if not isinstance(scope_name, str):
133
- raise ValueError("Windows environment mutation is missing a valid scope")
134
- previous = mutation.data.get("previous")
135
- if previous is None:
136
- value_literal = "$null"
137
- else:
138
- value_literal = _powershell_literal(previous)
139
- command = [
140
- "powershell",
141
- "-NoProfile",
142
- "-Command",
143
- f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{value_literal},{_powershell_literal(scope_name)})",
144
- ]
145
- subprocess.run(command, check=True)
146
-
147
-
148
- def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
149
- """Apply provider/user/system configuration for a deployment."""
150
-
151
- mutations: list[ManagedMutation] = []
152
- if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
153
- if os.name == "nt":
154
- mutations.extend(_apply_windows_env_scope(manifest))
155
- else:
156
- mutations.extend(_apply_unix_env_scope(manifest))
157
- mutations.extend(apply_provider_scope_mutations(manifest))
158
- return mutations
159
-
160
- return [*mutations, *apply_provider_scope_mutations(manifest)]
161
-
162
-
163
- def revert_mutations(manifest: DeploymentManifest) -> None:
164
- """Undo the stored mutations for a deployment."""
165
-
166
- if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
167
- shell_mutations = [m for m in manifest.mutations if m.target == "env"]
168
- if os.name == "nt":
169
- _remove_windows_env_scope(shell_mutations)
170
- else:
171
- _remove_unix_env_scope(shell_mutations)
172
-
173
- for mutation in manifest.mutations:
174
- revert_provider_scope_mutation(manifest, mutation)
 
1
+ """Tool-target configuration for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from headroom.providers.install_registry import (
11
+ apply_provider_scope_mutations,
12
+ revert_provider_scope_mutation,
13
+ )
14
+
15
+ from .models import ConfigScope, DeploymentManifest, ManagedMutation
16
+ from .paths import (
17
+ unix_system_env_targets,
18
+ unix_user_env_targets,
19
+ )
20
+
21
+ _ENV_MARKER_START = "# >>> headroom persistent env >>>"
22
+ _ENV_MARKER_END = "# <<< headroom persistent env <<<"
23
+ _ENV_PATTERN = re.compile(
24
+ re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
25
+ re.DOTALL,
26
+ )
27
+
28
+
29
+ def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
30
+ if file_path.exists():
31
+ existing = file_path.read_text()
32
+ if marker in existing:
33
+ return pattern.sub(block, existing)
34
+ return existing.rstrip() + "\n\n" + block + "\n"
35
+ return block + "\n"
36
+
37
+
38
+ def _env_block(values: dict[str, str]) -> str:
39
+ lines = [_ENV_MARKER_START]
40
+ for name, value in values.items():
41
+ lines.append(f'export {name}="{value}"')
42
+ lines.append(_ENV_MARKER_END)
43
+ return "\n".join(lines)
44
+
45
+
46
+ def _powershell_literal(value: str) -> str:
47
+ return "'" + value.replace("'", "''") + "'"
48
+
49
+
50
+ def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
51
+ merged = dict(manifest.base_env)
52
+ for env_map in manifest.tool_envs.values():
53
+ merged.update(env_map)
54
+ return merged
55
+
56
+
57
+ def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
58
+ values = _unix_scope_values(manifest)
59
+ block = _env_block(values)
60
+ if manifest.scope == ConfigScope.USER.value:
61
+ targets = unix_user_env_targets()
62
+ else:
63
+ targets = unix_system_env_targets()
64
+ mutations: list[ManagedMutation] = []
65
+ for path in targets:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
68
+ path.write_text(merged)
69
+ mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
70
+ return mutations
71
+
72
+
73
+ def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
74
+ for mutation in mutations:
75
+ if mutation.kind != "shell-block" or not mutation.path:
76
+ continue
77
+ path = Path(mutation.path)
78
+ if not path.exists():
79
+ continue
80
+ content = path.read_text()
81
+ if _ENV_MARKER_START not in content:
82
+ continue
83
+ path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
84
+
85
+
86
+ def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
87
+ scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
88
+ merged = _unix_scope_values(manifest)
89
+ mutations: list[ManagedMutation] = []
90
+ for name, value in merged.items():
91
+ previous = subprocess.run(
92
+ [
93
+ "powershell",
94
+ "-NoProfile",
95
+ "-Command",
96
+ f"$value = [Environment]::GetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(scope_name)}); "
97
+ "if ($null -eq $value) { '__HEADROOM_UNSET__' } else { $value }",
98
+ ],
99
+ capture_output=True,
100
+ text=True,
101
+ check=True,
102
+ ).stdout.strip()
103
+ command = [
104
+ "powershell",
105
+ "-NoProfile",
106
+ "-Command",
107
+ f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{_powershell_literal(value)},{_powershell_literal(scope_name)})",
108
+ ]
109
+ subprocess.run(command, check=True)
110
+ mutations.append(
111
+ ManagedMutation(
112
+ target="env",
113
+ kind="windows-env",
114
+ data={
115
+ "name": name,
116
+ "scope": scope_name,
117
+ "previous": None if previous == "__HEADROOM_UNSET__" else previous,
118
+ },
119
+ )
120
+ )
121
+ return mutations
122
+
123
+
124
+ def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
125
+ for mutation in mutations:
126
+ if mutation.kind != "windows-env":
127
+ continue
128
+ name = mutation.data.get("name")
129
+ if not isinstance(name, str):
130
+ raise ValueError("Windows environment mutation is missing a variable name")
131
+ scope_name = mutation.data.get("scope", "User")
132
+ if not isinstance(scope_name, str):
133
+ raise ValueError("Windows environment mutation is missing a valid scope")
134
+ previous = mutation.data.get("previous")
135
+ if previous is None:
136
+ value_literal = "$null"
137
+ else:
138
+ value_literal = _powershell_literal(previous)
139
+ command = [
140
+ "powershell",
141
+ "-NoProfile",
142
+ "-Command",
143
+ f"[Environment]::SetEnvironmentVariable({_powershell_literal(name)},{value_literal},{_powershell_literal(scope_name)})",
144
+ ]
145
+ subprocess.run(command, check=True)
146
+
147
+
148
+ def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
149
+ """Apply provider/user/system configuration for a deployment."""
150
+
151
+ mutations: list[ManagedMutation] = []
152
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
153
+ if os.name == "nt":
154
+ mutations.extend(_apply_windows_env_scope(manifest))
155
+ else:
156
+ mutations.extend(_apply_unix_env_scope(manifest))
157
+ mutations.extend(apply_provider_scope_mutations(manifest))
158
+ return mutations
159
+
160
+ return [*mutations, *apply_provider_scope_mutations(manifest)]
161
+
162
+
163
+ def revert_mutations(manifest: DeploymentManifest) -> None:
164
+ """Undo the stored mutations for a deployment."""
165
+
166
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
167
+ shell_mutations = [m for m in manifest.mutations if m.target == "env"]
168
+ if os.name == "nt":
169
+ _remove_windows_env_scope(shell_mutations)
170
+ else:
171
+ _remove_unix_env_scope(shell_mutations)
172
+
173
+ for mutation in manifest.mutations:
174
+ revert_provider_scope_mutation(manifest, mutation)
headroom/install/runtime.py CHANGED
@@ -1,275 +1,279 @@
1
- """Runtime helpers for persistent deployments."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import shutil
7
- import signal
8
- import subprocess
9
- import sys
10
- import time
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- from .health import probe_ready
15
- from .models import DeploymentManifest, InstallPreset, RuntimeKind
16
- from .paths import log_path, pid_path
17
-
18
- PASSTHROUGH_ENV_PREFIXES = (
19
- "HEADROOM_",
20
- "ANTHROPIC_",
21
- "OPENAI_",
22
- "GEMINI_",
23
- "AWS_",
24
- "AZURE_",
25
- "VERTEX_",
26
- "GOOGLE_",
27
- "GOOGLE_CLOUD_",
28
- "MISTRAL_",
29
- "GROQ_",
30
- "OPENROUTER_",
31
- "XAI_",
32
- "TOGETHER_",
33
- "COHERE_",
34
- "OLLAMA_",
35
- "LITELLM_",
36
- "OTEL_",
37
- "SUPABASE_",
38
- "QDRANT_",
39
- "NEO4J_",
40
- "LANGSMITH_",
41
- )
42
-
43
-
44
- def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
45
- return {
46
- "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
47
- "HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
48
- "HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
49
- "HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
50
- "HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
51
- }
52
-
53
-
54
- def resolve_headroom_command() -> list[str]:
55
- """Resolve the most reliable command to invoke headroom."""
56
-
57
- headroom_bin = shutil.which("headroom")
58
- if headroom_bin:
59
- return [headroom_bin]
60
- return [sys.executable, "-m", "headroom.cli"]
61
-
62
-
63
- def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
64
- env = os.environ.copy()
65
- env.update(manifest.base_env)
66
- env.update(_deployment_env(manifest))
67
- return env
68
-
69
-
70
- def _ensure_host_dirs() -> None:
71
- for subdir in (".headroom", ".claude", ".codex", ".gemini"):
72
- (Path.home() / subdir).mkdir(parents=True, exist_ok=True)
73
-
74
-
75
- def _mount_source(home: str, subdir: str) -> str:
76
- if os.name == "nt":
77
- return f"{home}\\{subdir}"
78
- return f"{home}/{subdir}"
79
-
80
-
81
- def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
82
- """Build the raw foreground command that runs the proxy."""
83
-
84
- if manifest.runtime_kind == RuntimeKind.PYTHON.value:
85
- return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
86
-
87
- _ensure_host_dirs()
88
- home = str(Path.home())
89
- container_home = "/tmp/headroom-home"
90
- command = [
91
- "docker",
92
- "run",
93
- "--rm",
94
- "--name",
95
- manifest.container_name,
96
- "-p",
97
- f"127.0.0.1:{manifest.port}:{manifest.port}",
98
- "--workdir",
99
- container_home,
100
- "--env",
101
- f"HOME={container_home}",
102
- "--env",
103
- "PYTHONUNBUFFERED=1",
104
- # Canonical Headroom filesystem contract (issue #175).
105
- "--env",
106
- f"HEADROOM_WORKSPACE_DIR={container_home}/.headroom",
107
- "--env",
108
- f"HEADROOM_CONFIG_DIR={container_home}/.headroom/config",
109
- "--volume",
110
- f"{_mount_source(home, '.headroom')}:{container_home}/.headroom",
111
- "--volume",
112
- f"{_mount_source(home, '.claude')}:{container_home}/.claude",
113
- "--volume",
114
- f"{_mount_source(home, '.codex')}:{container_home}/.codex",
115
- "--volume",
116
- f"{_mount_source(home, '.gemini')}:{container_home}/.gemini",
117
- ]
118
- if os.name != "nt":
119
- getuid = getattr(os, "getuid", None)
120
- getgid = getattr(os, "getgid", None)
121
- if callable(getuid) and callable(getgid):
122
- command.extend(["--user", f"{getuid()}:{getgid()}"])
123
- runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
124
- for name, value in runtime_env.items():
125
- command.extend(["--env", f"{name}={value}"])
126
- for name in sorted(os.environ):
127
- if name.startswith(PASSTHROUGH_ENV_PREFIXES):
128
- command.extend(["--env", name])
129
- command.extend(
130
- [
131
- manifest.image,
132
- "headroom",
133
- "proxy",
134
- "--host",
135
- "0.0.0.0",
136
- *manifest.proxy_args[2:],
137
- ]
138
- )
139
- return command
140
-
141
-
142
- def _write_pid(profile: str, pid: int) -> None:
143
- path = pid_path(profile)
144
- path.parent.mkdir(parents=True, exist_ok=True)
145
- path.write_text(str(pid))
146
-
147
-
148
- def _read_pid(profile: str) -> int | None:
149
- path = pid_path(profile)
150
- if not path.exists():
151
- return None
152
- try:
153
- return int(path.read_text().strip())
154
- except ValueError:
155
- return None
156
-
157
-
158
- def _clear_pid(profile: str) -> None:
159
- path = pid_path(profile)
160
- if path.exists():
161
- path.unlink()
162
-
163
-
164
- def run_foreground(manifest: DeploymentManifest) -> int:
165
- """Run the raw runtime command in the foreground."""
166
-
167
- command = build_runtime_command(manifest)
168
- env = _runtime_env(manifest)
169
- log_file_path = log_path(manifest.profile)
170
- log_file_path.parent.mkdir(parents=True, exist_ok=True)
171
-
172
- with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
173
- proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
174
- _write_pid(manifest.profile, proc.pid)
175
-
176
- def _cleanup(signum: int | None = None, frame: Any = None) -> None:
177
- if proc.poll() is None:
178
- proc.terminate()
179
- try:
180
- proc.wait(timeout=10)
181
- except subprocess.TimeoutExpired:
182
- proc.kill()
183
-
184
- signal.signal(signal.SIGINT, _cleanup)
185
- signal.signal(signal.SIGTERM, _cleanup)
186
- try:
187
- return proc.wait()
188
- finally:
189
- _clear_pid(manifest.profile)
190
-
191
-
192
- def start_detached_agent(profile: str) -> subprocess.Popen[str]:
193
- """Start `headroom install agent run` detached for the given profile."""
194
-
195
- command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
196
- log_file_path = log_path(profile)
197
- log_file_path.parent.mkdir(parents=True, exist_ok=True)
198
- log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
199
-
200
- kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
201
- if os.name == "nt":
202
- kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
203
- subprocess, "CREATE_NEW_PROCESS_GROUP", 0
204
- )
205
- else:
206
- kwargs["start_new_session"] = True
207
- return subprocess.Popen(command, **kwargs)
208
-
209
-
210
- def start_persistent_docker(manifest: DeploymentManifest) -> None:
211
- """Start a persistent Docker container with restart policy."""
212
-
213
- command = build_runtime_command(manifest)
214
- docker_cmd = [
215
- "docker",
216
- "run",
217
- "-d",
218
- "--restart",
219
- "unless-stopped",
220
- "--name",
221
- manifest.container_name,
222
- *command[5:], # drop initial `docker run --rm --name ...`
223
- ]
224
- subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
225
- subprocess.run(docker_cmd, check=True)
226
-
227
-
228
- def stop_runtime(manifest: DeploymentManifest) -> None:
229
- """Stop the raw runtime for the deployment."""
230
-
231
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
232
- subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
233
- subprocess.run(
234
- ["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
235
- )
236
- return
237
-
238
- pid = _read_pid(manifest.profile)
239
- if pid is None:
240
- return
241
- try:
242
- os.kill(pid, signal.SIGTERM)
243
- except OSError:
244
- pass
245
- _clear_pid(manifest.profile)
246
-
247
-
248
- def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
249
- """Wait for the deployment to report ready."""
250
-
251
- for _ in range(timeout_seconds):
252
- if probe_ready(manifest.health_url):
253
- return True
254
- time.sleep(1)
255
- return False
256
-
257
-
258
- def runtime_status(manifest: DeploymentManifest) -> str:
259
- """Return a short status string for the deployment runtime."""
260
-
261
- if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
262
- result = subprocess.run(
263
- ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
264
- )
265
- if manifest.container_name in result.stdout.splitlines():
266
- return "running"
267
- return "stopped"
268
- pid = _read_pid(manifest.profile)
269
- if pid is None:
270
- return "stopped"
271
- try:
272
- os.kill(pid, 0)
273
- except OSError:
274
- return "stopped"
275
- return "running"
 
 
 
 
 
1
+ """Runtime helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .health import probe_ready
15
+ from .models import DeploymentManifest, InstallPreset, RuntimeKind
16
+ from .paths import log_path, pid_path
17
+
18
+ PASSTHROUGH_ENV_PREFIXES = (
19
+ "HEADROOM_",
20
+ "ANTHROPIC_",
21
+ "OPENAI_",
22
+ "GEMINI_",
23
+ "AWS_",
24
+ "AZURE_",
25
+ "VERTEX_",
26
+ "GOOGLE_",
27
+ "GOOGLE_CLOUD_",
28
+ "MISTRAL_",
29
+ "GROQ_",
30
+ "OPENROUTER_",
31
+ "XAI_",
32
+ "TOGETHER_",
33
+ "COHERE_",
34
+ "OLLAMA_",
35
+ "LITELLM_",
36
+ "OTEL_",
37
+ "SUPABASE_",
38
+ "QDRANT_",
39
+ "NEO4J_",
40
+ "LANGSMITH_",
41
+ )
42
+
43
+
44
+ def _is_windows() -> bool:
45
+ return sys.platform.startswith("win")
46
+
47
+
48
+ def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
49
+ return {
50
+ "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
51
+ "HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
52
+ "HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
53
+ "HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
54
+ "HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
55
+ }
56
+
57
+
58
+ def resolve_headroom_command() -> list[str]:
59
+ """Resolve the most reliable command to invoke headroom."""
60
+
61
+ headroom_bin = shutil.which("headroom")
62
+ if headroom_bin:
63
+ return [headroom_bin]
64
+ return [sys.executable, "-m", "headroom.cli"]
65
+
66
+
67
+ def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
68
+ env = os.environ.copy()
69
+ env.update(manifest.base_env)
70
+ env.update(_deployment_env(manifest))
71
+ return env
72
+
73
+
74
+ def _ensure_host_dirs() -> None:
75
+ for subdir in (".headroom", ".claude", ".codex", ".gemini"):
76
+ (Path.home() / subdir).mkdir(parents=True, exist_ok=True)
77
+
78
+
79
+ def _mount_source(home: str, subdir: str) -> str:
80
+ if _is_windows():
81
+ return f"{home}\\{subdir}"
82
+ return f"{home}/{subdir}"
83
+
84
+
85
+ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
86
+ """Build the raw foreground command that runs the proxy."""
87
+
88
+ if manifest.runtime_kind == RuntimeKind.PYTHON.value:
89
+ return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
90
+
91
+ _ensure_host_dirs()
92
+ home = str(Path.home())
93
+ container_home = "/tmp/headroom-home"
94
+ command = [
95
+ "docker",
96
+ "run",
97
+ "--rm",
98
+ "--name",
99
+ manifest.container_name,
100
+ "-p",
101
+ f"127.0.0.1:{manifest.port}:{manifest.port}",
102
+ "--workdir",
103
+ container_home,
104
+ "--env",
105
+ f"HOME={container_home}",
106
+ "--env",
107
+ "PYTHONUNBUFFERED=1",
108
+ # Canonical Headroom filesystem contract (issue #175).
109
+ "--env",
110
+ f"HEADROOM_WORKSPACE_DIR={container_home}/.headroom",
111
+ "--env",
112
+ f"HEADROOM_CONFIG_DIR={container_home}/.headroom/config",
113
+ "--volume",
114
+ f"{_mount_source(home, '.headroom')}:{container_home}/.headroom",
115
+ "--volume",
116
+ f"{_mount_source(home, '.claude')}:{container_home}/.claude",
117
+ "--volume",
118
+ f"{_mount_source(home, '.codex')}:{container_home}/.codex",
119
+ "--volume",
120
+ f"{_mount_source(home, '.gemini')}:{container_home}/.gemini",
121
+ ]
122
+ if not _is_windows():
123
+ getuid = getattr(os, "getuid", None)
124
+ getgid = getattr(os, "getgid", None)
125
+ if callable(getuid) and callable(getgid):
126
+ command.extend(["--user", f"{getuid()}:{getgid()}"])
127
+ runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
128
+ for name, value in runtime_env.items():
129
+ command.extend(["--env", f"{name}={value}"])
130
+ for name in sorted(os.environ):
131
+ if name.startswith(PASSTHROUGH_ENV_PREFIXES):
132
+ command.extend(["--env", name])
133
+ command.extend(
134
+ [
135
+ manifest.image,
136
+ "headroom",
137
+ "proxy",
138
+ "--host",
139
+ "0.0.0.0",
140
+ *manifest.proxy_args[2:],
141
+ ]
142
+ )
143
+ return command
144
+
145
+
146
+ def _write_pid(profile: str, pid: int) -> None:
147
+ path = pid_path(profile)
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ path.write_text(str(pid))
150
+
151
+
152
+ def _read_pid(profile: str) -> int | None:
153
+ path = pid_path(profile)
154
+ if not path.exists():
155
+ return None
156
+ try:
157
+ return int(path.read_text().strip())
158
+ except ValueError:
159
+ return None
160
+
161
+
162
+ def _clear_pid(profile: str) -> None:
163
+ path = pid_path(profile)
164
+ if path.exists():
165
+ path.unlink()
166
+
167
+
168
+ def run_foreground(manifest: DeploymentManifest) -> int:
169
+ """Run the raw runtime command in the foreground."""
170
+
171
+ command = build_runtime_command(manifest)
172
+ env = _runtime_env(manifest)
173
+ log_file_path = log_path(manifest.profile)
174
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
175
+
176
+ with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
177
+ proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
178
+ _write_pid(manifest.profile, proc.pid)
179
+
180
+ def _cleanup(signum: int | None = None, frame: Any = None) -> None:
181
+ if proc.poll() is None:
182
+ proc.terminate()
183
+ try:
184
+ proc.wait(timeout=10)
185
+ except subprocess.TimeoutExpired:
186
+ proc.kill()
187
+
188
+ signal.signal(signal.SIGINT, _cleanup)
189
+ signal.signal(signal.SIGTERM, _cleanup)
190
+ try:
191
+ return proc.wait()
192
+ finally:
193
+ _clear_pid(manifest.profile)
194
+
195
+
196
+ def start_detached_agent(profile: str) -> subprocess.Popen[str]:
197
+ """Start `headroom install agent run` detached for the given profile."""
198
+
199
+ command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
200
+ log_file_path = log_path(profile)
201
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
202
+ log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
203
+
204
+ kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
205
+ if _is_windows():
206
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
207
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
208
+ )
209
+ else:
210
+ kwargs["start_new_session"] = True
211
+ return subprocess.Popen(command, **kwargs)
212
+
213
+
214
+ def start_persistent_docker(manifest: DeploymentManifest) -> None:
215
+ """Start a persistent Docker container with restart policy."""
216
+
217
+ command = build_runtime_command(manifest)
218
+ docker_cmd = [
219
+ "docker",
220
+ "run",
221
+ "-d",
222
+ "--restart",
223
+ "unless-stopped",
224
+ "--name",
225
+ manifest.container_name,
226
+ *command[5:], # drop initial `docker run --rm --name ...`
227
+ ]
228
+ subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
229
+ subprocess.run(docker_cmd, check=True)
230
+
231
+
232
+ def stop_runtime(manifest: DeploymentManifest) -> None:
233
+ """Stop the raw runtime for the deployment."""
234
+
235
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
236
+ subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
237
+ subprocess.run(
238
+ ["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
239
+ )
240
+ return
241
+
242
+ pid = _read_pid(manifest.profile)
243
+ if pid is None:
244
+ return
245
+ try:
246
+ os.kill(pid, signal.SIGTERM)
247
+ except OSError:
248
+ pass
249
+ _clear_pid(manifest.profile)
250
+
251
+
252
+ def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
253
+ """Wait for the deployment to report ready."""
254
+
255
+ for _ in range(timeout_seconds):
256
+ if probe_ready(manifest.health_url):
257
+ return True
258
+ time.sleep(1)
259
+ return False
260
+
261
+
262
+ def runtime_status(manifest: DeploymentManifest) -> str:
263
+ """Return a short status string for the deployment runtime."""
264
+
265
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
266
+ result = subprocess.run(
267
+ ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
268
+ )
269
+ if manifest.container_name in result.stdout.splitlines():
270
+ return "running"
271
+ return "stopped"
272
+ pid = _read_pid(manifest.profile)
273
+ if pid is None:
274
+ return "stopped"
275
+ try:
276
+ os.kill(pid, 0)
277
+ except OSError:
278
+ return "stopped"
279
+ return "running"
headroom/install/supervisors.py CHANGED
@@ -23,6 +23,10 @@ from .paths import (
23
  from .runtime import resolve_headroom_command
24
 
25
 
 
 
 
 
26
  def _command_for_script(*parts: str) -> list[str]:
27
  return [*resolve_headroom_command(), *parts]
28
 
@@ -60,7 +64,7 @@ def _render_windows_runner(
60
  def render_runner_scripts(manifest: DeploymentManifest) -> list[ArtifactRecord]:
61
  """Render runner/watchdog scripts for the deployment profile."""
62
 
63
- if os.name == "nt":
64
  records = []
65
  records.extend(
66
  _render_windows_runner(
@@ -230,7 +234,7 @@ def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
230
  records.append(ArtifactRecord(kind="plist", path=str(plist_path)))
231
  return records
232
 
233
- if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
234
  service_bin = f'cmd.exe /c "{windows_run_cmd_path(manifest.profile)}"'
235
  subprocess.run(
236
  ["sc.exe", "create", manifest.service_name, f"binPath= {service_bin}", "start= auto"],
@@ -243,7 +247,7 @@ def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
243
  records.append(ArtifactRecord(kind="windows-service", path=manifest.service_name))
244
  return records
245
 
246
- if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.TASK.value:
247
  startup_name = f"{manifest.service_name}-startup"
248
  health_name = f"{manifest.service_name}-health"
249
  startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
@@ -308,7 +312,7 @@ def start_supervisor(manifest: DeploymentManifest) -> None:
308
  )
309
  subprocess.run(["launchctl", "kickstart", "-k", f"{domain}/{label}"], check=True)
310
  return
311
- if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
312
  subprocess.run(["sc.exe", "start", manifest.service_name], check=True)
313
 
314
 
@@ -331,7 +335,7 @@ def stop_supervisor(manifest: DeploymentManifest) -> None:
331
  )
332
  subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=True)
333
  return
334
- if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
335
  subprocess.run(["sc.exe", "stop", manifest.service_name], check=True)
336
 
337
 
@@ -392,7 +396,7 @@ def remove_supervisor(manifest: DeploymentManifest) -> None:
392
  plist_path.unlink()
393
  return
394
 
395
- if os.name == "nt":
396
  if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
397
  subprocess.run(
398
  ["sc.exe", "stop", manifest.service_name], capture_output=True, text=True
 
23
  from .runtime import resolve_headroom_command
24
 
25
 
26
+ def _is_windows() -> bool:
27
+ return sys.platform.startswith("win")
28
+
29
+
30
  def _command_for_script(*parts: str) -> list[str]:
31
  return [*resolve_headroom_command(), *parts]
32
 
 
64
  def render_runner_scripts(manifest: DeploymentManifest) -> list[ArtifactRecord]:
65
  """Render runner/watchdog scripts for the deployment profile."""
66
 
67
+ if _is_windows():
68
  records = []
69
  records.extend(
70
  _render_windows_runner(
 
234
  records.append(ArtifactRecord(kind="plist", path=str(plist_path)))
235
  return records
236
 
237
+ if _is_windows() and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
238
  service_bin = f'cmd.exe /c "{windows_run_cmd_path(manifest.profile)}"'
239
  subprocess.run(
240
  ["sc.exe", "create", manifest.service_name, f"binPath= {service_bin}", "start= auto"],
 
247
  records.append(ArtifactRecord(kind="windows-service", path=manifest.service_name))
248
  return records
249
 
250
+ if _is_windows() and manifest.supervisor_kind == SupervisorKind.TASK.value:
251
  startup_name = f"{manifest.service_name}-startup"
252
  health_name = f"{manifest.service_name}-health"
253
  startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
 
312
  )
313
  subprocess.run(["launchctl", "kickstart", "-k", f"{domain}/{label}"], check=True)
314
  return
315
+ if _is_windows() and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
316
  subprocess.run(["sc.exe", "start", manifest.service_name], check=True)
317
 
318
 
 
335
  )
336
  subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=True)
337
  return
338
+ if _is_windows() and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
339
  subprocess.run(["sc.exe", "stop", manifest.service_name], check=True)
340
 
341
 
 
396
  plist_path.unlink()
397
  return
398
 
399
+ if _is_windows():
400
  if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
401
  subprocess.run(
402
  ["sc.exe", "stop", manifest.service_name], capture_output=True, text=True
headroom/learn/analyzer.py CHANGED
@@ -29,6 +29,7 @@ from .models import (
29
  SessionEvent,
30
  ToolCall,
31
  )
 
32
 
33
  logger = logging.getLogger(__name__)
34
 
@@ -147,11 +148,51 @@ class SessionAnalyzer:
147
  # =============================================================================
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str:
151
  """Build a token-efficient text digest of all session events.
152
 
153
  The digest includes:
154
  - Project context
 
155
  - Per-session summaries with condensed event streams
156
  - Error outputs (truncated), success indicators, user messages
157
  """
@@ -173,6 +214,12 @@ def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str:
173
  lines.append(f"Tokens used: {total_tokens_in:,} in / {total_tokens_out:,} out")
174
  lines.append("")
175
 
 
 
 
 
 
 
176
  # Budget tracking — stop adding events when we approach the limit
177
  # Rough estimate: 4 chars per token
178
  char_budget = _MAX_DIGEST_TOKENS * 4
@@ -289,6 +336,24 @@ Rules:
289
  - Do NOT produce tautological rules (e.g., "use python3 not python3")
290
  - Do NOT produce rules about things that only happened once (transient errors)
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  Return ONLY valid JSON matching this schema — no other text:
293
  {
294
  "context_file_rules": [
 
29
  SessionEvent,
30
  ToolCall,
31
  )
32
+ from .writer import extract_marker_block
33
 
34
  logger = logging.getLogger(__name__)
35
 
 
148
  # =============================================================================
149
 
150
 
151
+ def _build_prior_patterns_section(project: ProjectInfo) -> str:
152
+ """Format the current marker blocks from CLAUDE.md / MEMORY.md for the LLM.
153
+
154
+ Returns "" when neither file exists nor contains a marker block. When at
155
+ least one file has a block, returns a header + labeled raw blocks so the
156
+ LLM can treat them as the starting baseline. See the "Prior Learned
157
+ Patterns" rule in _SYSTEM_PROMPT for the contract with the model.
158
+ """
159
+ parts: list[tuple[str, str]] = [] # (label, block)
160
+ candidates = (
161
+ ("CLAUDE.md (CONTEXT_FILE, project-level stable facts)", project.context_file),
162
+ ("MEMORY.md (MEMORY_FILE, session-level evolving preferences)", project.memory_file),
163
+ )
164
+ for label, path in candidates:
165
+ if path is None or not path.exists():
166
+ continue
167
+ block = extract_marker_block(path.read_text())
168
+ if block:
169
+ parts.append((label, block))
170
+
171
+ if not parts:
172
+ return ""
173
+
174
+ lines = [
175
+ "=== Prior Learned Patterns ===",
176
+ (
177
+ f"These patterns are currently written to {project.name}'s context "
178
+ f"files. They are your starting baseline — see the 'Prior Learned "
179
+ f"Patterns' rule in the system prompt for how to integrate them."
180
+ ),
181
+ "",
182
+ ]
183
+ for label, block in parts:
184
+ lines.append(f"--- From {label} ---")
185
+ lines.append(block)
186
+ lines.append("")
187
+ return "\n".join(lines)
188
+
189
+
190
  def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str:
191
  """Build a token-efficient text digest of all session events.
192
 
193
  The digest includes:
194
  - Project context
195
+ - Prior learned patterns (if any) from CLAUDE.md / MEMORY.md
196
  - Per-session summaries with condensed event streams
197
  - Error outputs (truncated), success indicators, user messages
198
  """
 
214
  lines.append(f"Tokens used: {total_tokens_in:,} in / {total_tokens_out:,} out")
215
  lines.append("")
216
 
217
+ # Prior learned patterns (if any) — gives the LLM the current baseline so
218
+ # it can produce complete updated sections instead of condensed deltas.
219
+ prior_section = _build_prior_patterns_section(project)
220
+ if prior_section:
221
+ lines.append(prior_section)
222
+
223
  # Budget tracking — stop adding events when we approach the limit
224
  # Rough estimate: 4 chars per token
225
  char_budget = _MAX_DIGEST_TOKENS * 4
 
336
  - Do NOT produce tautological rules (e.g., "use python3 not python3")
337
  - Do NOT produce rules about things that only happened once (transient errors)
338
 
339
+ Prior Learned Patterns:
340
+ - The input may contain a "Prior Learned Patterns" section showing what is
341
+ already written to the project's CLAUDE.md / MEMORY.md. Treat those as the
342
+ starting baseline for your analysis.
343
+ - When you re-emit a section heading that appears in the prior block, your
344
+ output REPLACES that prior section wholesale — so your section must be the
345
+ COMPLETE updated version:
346
+ * Preserve prior bullets that remain accurate (copy them forward)
347
+ * Revise bullets when new evidence refines them (merge, don't duplicate)
348
+ * Drop a prior bullet only when contradicted by clear new evidence
349
+ - Sections from prior runs that you do NOT re-emit are preserved automatically
350
+ by the writer, so focus only on sections where you have something to add or
351
+ change. Do NOT re-emit a prior section just to echo it verbatim — that wastes
352
+ output tokens without changing the outcome.
353
+ - Do NOT write bullets that reference prior siblings you are about to drop
354
+ (e.g., "X is ALSO large — same rule as Y, Z") unless Y and Z are also present
355
+ in your current output or preserved in the prior block.
356
+
357
  Return ONLY valid JSON matching this schema — no other text:
358
  {
359
  "context_file_rules": [
headroom/learn/writer.py CHANGED
@@ -91,6 +91,17 @@ def _build_section(recommendations: list[Recommendation]) -> str:
91
  _TOKENS_ANNOTATION_PATTERN = re.compile(r"\*~([\d,]+) tokens/session saved\*\n?")
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
94
  def _parse_prior_recommendations(existing: str) -> list[Recommendation]:
95
  """Parse recommendations out of a prior marker block.
96
 
 
91
  _TOKENS_ANNOTATION_PATTERN = re.compile(r"\*~([\d,]+) tokens/session saved\*\n?")
92
 
93
 
94
+ def extract_marker_block(file_content: str) -> str | None:
95
+ """Return the raw text of the headroom:learn marker block, or None.
96
+
97
+ Unlike _parse_prior_recommendations, this returns the block verbatim
98
+ (including the start/end markers) so it can be fed back to an LLM as
99
+ context without losing formatting. Returns None if no block is present.
100
+ """
101
+ match = _MARKER_PATTERN.search(file_content)
102
+ return match.group(0) if match else None
103
+
104
+
105
  def _parse_prior_recommendations(existing: str) -> list[Recommendation]:
106
  """Parse recommendations out of a prior marker block.
107
 
headroom/memory/adapters/embedders.py CHANGED
@@ -14,7 +14,7 @@ import asyncio
14
  import logging
15
  import os
16
  from functools import cached_property
17
- from typing import TYPE_CHECKING, Any
18
 
19
  import numpy as np
20
 
@@ -290,6 +290,7 @@ class OnnxLocalEmbedder:
290
  DEFAULT_DIMENSION = 384
291
  DEFAULT_MAX_TOKENS = 256
292
  ONNX_REPO = "Qdrant/all-MiniLM-L6-v2-onnx"
 
293
 
294
  def __init__(self, max_length: int = 256) -> None:
295
  self._max_length = max_length
@@ -330,17 +331,12 @@ class OnnxLocalEmbedder:
330
 
331
  logger.info("ONNX embedding model loaded (384-dim, no torch)")
332
 
333
- def _embed_single(self, text: str) -> np.ndarray:
334
- """Embed a single text string."""
335
- assert self._session is not None
336
- assert self._tokenizer is not None
337
-
338
- if not text or not text.strip():
339
- return np.zeros(self.DEFAULT_DIMENSION, dtype=np.float32)
340
-
341
- encoded = self._tokenizer.encode(text)
342
- input_ids = np.array([encoded.ids], dtype=np.int64)
343
- attention_mask = np.array([encoded.attention_mask], dtype=np.int64)
344
  token_type_ids = np.zeros_like(input_ids, dtype=np.int64)
345
 
346
  feeds: dict[str, np.ndarray] = {}
@@ -352,16 +348,37 @@ class OnnxLocalEmbedder:
352
  elif "token_type_ids" in name:
353
  feeds[name] = token_type_ids
354
 
355
- outputs = self._session.run(None, feeds)
356
- token_embeddings = outputs[0] # (1, seq_len, 384)
 
 
 
 
 
 
 
 
 
 
 
 
 
357
 
358
  # Mean pooling over non-padding tokens
359
  mask_expanded = attention_mask[:, :, np.newaxis].astype(np.float32)
360
  summed = np.sum(token_embeddings * mask_expanded, axis=1)
361
  counts = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
362
- embedding = summed / counts
 
 
 
 
 
 
 
363
 
364
- return _normalize_embedding(embedding[0])
 
365
 
366
  async def embed(self, text: str) -> np.ndarray:
367
  """Generate an embedding for a single text."""
@@ -369,18 +386,40 @@ class OnnxLocalEmbedder:
369
  if self._session is None:
370
  await asyncio.get_event_loop().run_in_executor(None, self._load_model)
371
 
372
- return await asyncio.get_event_loop().run_in_executor(None, self._embed_single, text)
 
 
373
 
374
  async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
375
  """Generate embeddings for multiple texts."""
 
 
 
376
  async with self._lock:
377
  if self._session is None:
378
  await asyncio.get_event_loop().run_in_executor(None, self._load_model)
379
 
380
- results = []
381
- for text in texts:
382
- emb = await asyncio.get_event_loop().run_in_executor(None, self._embed_single, text)
383
- results.append(emb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  return results
385
 
386
  @property
 
14
  import logging
15
  import os
16
  from functools import cached_property
17
+ from typing import TYPE_CHECKING, Any, cast
18
 
19
  import numpy as np
20
 
 
290
  DEFAULT_DIMENSION = 384
291
  DEFAULT_MAX_TOKENS = 256
292
  ONNX_REPO = "Qdrant/all-MiniLM-L6-v2-onnx"
293
+ MAX_BATCH_SIZE = 2
294
 
295
  def __init__(self, max_length: int = 256) -> None:
296
  self._max_length = max_length
 
331
 
332
  logger.info("ONNX embedding model loaded (384-dim, no torch)")
333
 
334
+ def _build_feeds(
335
+ self,
336
+ input_ids: np.ndarray,
337
+ attention_mask: np.ndarray,
338
+ ) -> dict[str, np.ndarray]:
339
+ """Build ONNX feeds for a token batch."""
 
 
 
 
 
340
  token_type_ids = np.zeros_like(input_ids, dtype=np.int64)
341
 
342
  feeds: dict[str, np.ndarray] = {}
 
348
  elif "token_type_ids" in name:
349
  feeds[name] = token_type_ids
350
 
351
+ return feeds
352
+
353
+ def _embed_many(self, texts: list[str]) -> np.ndarray:
354
+ """Embed multiple non-empty text strings in one ONNX pass."""
355
+ assert self._session is not None
356
+ assert self._tokenizer is not None
357
+
358
+ encodings = self._tokenizer.encode_batch(texts)
359
+ input_ids = np.array([encoding.ids for encoding in encodings], dtype=np.int64)
360
+ attention_mask = np.array(
361
+ [encoding.attention_mask for encoding in encodings], dtype=np.int64
362
+ )
363
+
364
+ outputs = self._session.run(None, self._build_feeds(input_ids, attention_mask))
365
+ token_embeddings = outputs[0] # (batch, seq_len, 384)
366
 
367
  # Mean pooling over non-padding tokens
368
  mask_expanded = attention_mask[:, :, np.newaxis].astype(np.float32)
369
  summed = np.sum(token_embeddings * mask_expanded, axis=1)
370
  counts = np.clip(mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
371
+ embeddings = summed / counts
372
+
373
+ return _normalize_embeddings_batch(embeddings)
374
+
375
+ def _embed_single(self, text: str) -> np.ndarray:
376
+ """Embed a single text string."""
377
+ if not text or not text.strip():
378
+ return np.zeros(self.DEFAULT_DIMENSION, dtype=np.float32)
379
 
380
+ embedding = self._embed_many([text])[0]
381
+ return cast(np.ndarray, embedding)
382
 
383
  async def embed(self, text: str) -> np.ndarray:
384
  """Generate an embedding for a single text."""
 
386
  if self._session is None:
387
  await asyncio.get_event_loop().run_in_executor(None, self._load_model)
388
 
389
+ loop = asyncio.get_event_loop()
390
+ embedding = await loop.run_in_executor(None, self._embed_single, text)
391
+ return cast(np.ndarray, embedding)
392
 
393
  async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
394
  """Generate embeddings for multiple texts."""
395
+ if not texts:
396
+ return []
397
+
398
  async with self._lock:
399
  if self._session is None:
400
  await asyncio.get_event_loop().run_in_executor(None, self._load_model)
401
 
402
+ non_empty_indices: list[int] = []
403
+ non_empty_texts: list[str] = []
404
+ for i, text in enumerate(texts):
405
+ if text and text.strip():
406
+ non_empty_indices.append(i)
407
+ non_empty_texts.append(text)
408
+
409
+ results: list[np.ndarray] = [
410
+ np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
411
+ ]
412
+ if not non_empty_texts:
413
+ return results
414
+
415
+ loop = asyncio.get_event_loop()
416
+ for start in range(0, len(non_empty_texts), self.MAX_BATCH_SIZE):
417
+ batch_texts = non_empty_texts[start : start + self.MAX_BATCH_SIZE]
418
+ batch_indices = non_empty_indices[start : start + self.MAX_BATCH_SIZE]
419
+ embeddings = await loop.run_in_executor(None, self._embed_many, batch_texts)
420
+ for idx, embedding in zip(batch_indices, embeddings):
421
+ results[idx] = embedding
422
+
423
  return results
424
 
425
  @property
headroom/memory/adapters/sqlite_vector.py CHANGED
@@ -24,8 +24,8 @@ import struct
24
  from dataclasses import dataclass
25
  from datetime import datetime
26
  from pathlib import Path
27
- from threading import RLock
28
- from typing import TYPE_CHECKING, Any
29
 
30
  import numpy as np
31
 
@@ -37,6 +37,8 @@ if TYPE_CHECKING:
37
 
38
  logger = logging.getLogger(__name__)
39
 
 
 
40
  # sqlite-vec availability check
41
  _SQLITE_VEC_AVAILABLE: bool | None = None
42
  _sqlite_vec_module: Any = None
@@ -226,11 +228,12 @@ class SQLiteVectorIndex:
226
  self._db_path = Path(db_path)
227
  self._page_cache_size_kb = page_cache_size_kb
228
  self._lock = RLock()
 
229
 
230
  self._init_db()
231
 
232
- def _get_conn(self) -> sqlite3.Connection:
233
- """Get a database connection with sqlite-vec loaded."""
234
  conn = sqlite3.connect(str(self._db_path))
235
  conn.row_factory = sqlite3.Row
236
 
@@ -245,6 +248,98 @@ class SQLiteVectorIndex:
245
 
246
  return conn
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  def _init_db(self) -> None:
249
  """Initialize the database schema."""
250
  with self._get_conn() as conn:
@@ -320,37 +415,21 @@ class SQLiteVectorIndex:
320
  Raises:
321
  ValueError: If memory has no embedding or wrong dimension.
322
  """
323
- if memory.embedding is None:
324
- raise ValueError(f"Memory {memory.id} has no embedding")
325
-
326
- embedding = np.asarray(memory.embedding, dtype=np.float32)
327
- if embedding.shape[0] != self._dimension:
328
- raise ValueError(
329
- f"Embedding dimension {embedding.shape[0]} does not match "
330
- f"index dimension {self._dimension}"
331
- )
332
-
333
- metadata = VectorMetadata.from_memory(memory)
334
 
335
  with self._lock:
336
  with self._get_conn() as conn:
337
- # Check if already exists
338
  existing = conn.execute(
339
  "SELECT rowid FROM vec_metadata WHERE memory_id = ?",
340
  (memory.id,),
341
  ).fetchone()
342
 
343
  if existing:
344
- # Update existing entry
345
- rowid = existing[0]
346
-
347
- # Update vector
348
  conn.execute(
349
  "UPDATE vec_embeddings SET embedding = ? WHERE rowid = ?",
350
  (self._serialize_f32(embedding), rowid),
351
  )
352
-
353
- # Update metadata
354
  conn.execute(
355
  """
356
  UPDATE vec_metadata SET
@@ -359,22 +438,9 @@ class SQLiteVectorIndex:
359
  entity_refs = ?, content = ?, metadata_json = ?
360
  WHERE rowid = ?
361
  """,
362
- (
363
- metadata.user_id,
364
- metadata.session_id,
365
- metadata.agent_id,
366
- metadata.importance,
367
- metadata.created_at.isoformat(),
368
- metadata.valid_until.isoformat() if metadata.valid_until else None,
369
- json.dumps(metadata.entity_refs),
370
- metadata.content,
371
- json.dumps(metadata.metadata or {}),
372
- rowid,
373
- ),
374
  )
375
  else:
376
- # Insert new entry
377
- # First insert metadata to get rowid
378
  cursor = conn.execute(
379
  """
380
  INSERT INTO vec_metadata (
@@ -383,22 +449,9 @@ class SQLiteVectorIndex:
383
  entity_refs, content, metadata_json
384
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
385
  """,
386
- (
387
- memory.id,
388
- metadata.user_id,
389
- metadata.session_id,
390
- metadata.agent_id,
391
- metadata.importance,
392
- metadata.created_at.isoformat(),
393
- metadata.valid_until.isoformat() if metadata.valid_until else None,
394
- json.dumps(metadata.entity_refs),
395
- metadata.content,
396
- json.dumps(metadata.metadata or {}),
397
- ),
398
  )
399
- rowid = cursor.lastrowid
400
-
401
- # Insert vector with matching rowid
402
  conn.execute(
403
  "INSERT INTO vec_embeddings (rowid, embedding) VALUES (?, ?)",
404
  (rowid, self._serialize_f32(embedding)),
@@ -415,15 +468,120 @@ class SQLiteVectorIndex:
415
  Returns:
416
  Number of memories indexed.
417
  """
418
- indexed = 0
419
  for memory in memories:
420
- if memory.embedding is not None:
421
- try:
422
- await self.index(memory)
423
- indexed += 1
424
- except ValueError:
425
- pass
426
- return indexed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
  async def remove(self, memory_id: str) -> bool:
429
  """Remove a memory from the index.
@@ -463,11 +621,30 @@ class SQLiteVectorIndex:
463
  Returns:
464
  Number removed.
465
  """
466
- removed = 0
467
- for memory_id in memory_ids:
468
- if await self.remove(memory_id):
469
- removed += 1
470
- return removed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
  async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
473
  """Search for similar vectors.
@@ -746,4 +923,5 @@ class SQLiteVectorIndex:
746
 
747
  async def close(self) -> None:
748
  """Close the index (cleanup)."""
749
- pass # Connection-per-request pattern, nothing to close
 
 
24
  from dataclasses import dataclass
25
  from datetime import datetime
26
  from pathlib import Path
27
+ from threading import RLock, get_ident
28
+ from typing import TYPE_CHECKING, Any, cast
29
 
30
  import numpy as np
31
 
 
37
 
38
  logger = logging.getLogger(__name__)
39
 
40
+ _SQLITE_QUERY_CHUNK_SIZE = 500
41
+
42
  # sqlite-vec availability check
43
  _SQLITE_VEC_AVAILABLE: bool | None = None
44
  _sqlite_vec_module: Any = None
 
228
  self._db_path = Path(db_path)
229
  self._page_cache_size_kb = page_cache_size_kb
230
  self._lock = RLock()
231
+ self._connections: dict[int, sqlite3.Connection] = {}
232
 
233
  self._init_db()
234
 
235
+ def _create_conn(self) -> sqlite3.Connection:
236
+ """Create a SQLite connection with sqlite-vec loaded."""
237
  conn = sqlite3.connect(str(self._db_path))
238
  conn.row_factory = sqlite3.Row
239
 
 
248
 
249
  return conn
250
 
251
+ def _get_conn(self) -> sqlite3.Connection:
252
+ """Get a cached per-thread SQLite connection with sqlite-vec loaded."""
253
+ thread_id = get_ident()
254
+ conn = self._connections.get(thread_id)
255
+ if conn is None:
256
+ conn = self._create_conn()
257
+ self._connections[thread_id] = conn
258
+ return conn
259
+
260
+ def _close_cached_connections(self) -> None:
261
+ """Close all cached SQLite connections."""
262
+ for conn in self._connections.values():
263
+ try:
264
+ conn.close()
265
+ except sqlite3.Error:
266
+ logger.debug("Failed to close cached sqlite-vec connection", exc_info=True)
267
+ self._connections.clear()
268
+
269
+ @staticmethod
270
+ def _chunked(items: list[Any], chunk_size: int = _SQLITE_QUERY_CHUNK_SIZE) -> list[list[Any]]:
271
+ """Split a list into SQLite-friendly chunks."""
272
+ return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
273
+
274
+ def _select_rowids_by_memory_ids(
275
+ self,
276
+ conn: sqlite3.Connection,
277
+ memory_ids: list[str],
278
+ ) -> dict[str, int]:
279
+ """Fetch rowids for the given memory IDs."""
280
+ rowids: dict[str, int] = {}
281
+ for chunk in self._chunked(memory_ids):
282
+ placeholders = ", ".join("?" for _ in chunk)
283
+ rows = conn.execute(
284
+ f"SELECT rowid, memory_id FROM vec_metadata WHERE memory_id IN ({placeholders})",
285
+ chunk,
286
+ ).fetchall()
287
+ for row in rows:
288
+ rowids[str(row["memory_id"])] = int(row["rowid"])
289
+ return rowids
290
+
291
+ def _prepare_memory_for_index(self, memory: Memory) -> tuple[np.ndarray, VectorMetadata]:
292
+ """Validate a memory and prepare it for indexing."""
293
+ if memory.embedding is None:
294
+ raise ValueError(f"Memory {memory.id} has no embedding")
295
+
296
+ embedding = np.asarray(memory.embedding, dtype=np.float32)
297
+ if embedding.shape[0] != self._dimension:
298
+ raise ValueError(
299
+ f"Embedding dimension {embedding.shape[0]} does not match "
300
+ f"index dimension {self._dimension}"
301
+ )
302
+
303
+ return embedding, VectorMetadata.from_memory(memory)
304
+
305
+ def _metadata_insert_params(self, memory_id: str, metadata: VectorMetadata) -> tuple[Any, ...]:
306
+ """Build INSERT parameters for vector metadata."""
307
+ return (
308
+ memory_id,
309
+ metadata.user_id,
310
+ metadata.session_id,
311
+ metadata.agent_id,
312
+ metadata.importance,
313
+ metadata.created_at.isoformat(),
314
+ metadata.valid_until.isoformat() if metadata.valid_until else None,
315
+ json.dumps(metadata.entity_refs),
316
+ metadata.content,
317
+ json.dumps(metadata.metadata or {}),
318
+ )
319
+
320
+ def _metadata_update_params(self, metadata: VectorMetadata, rowid: int) -> tuple[Any, ...]:
321
+ """Build UPDATE parameters for vector metadata."""
322
+ return (
323
+ metadata.user_id,
324
+ metadata.session_id,
325
+ metadata.agent_id,
326
+ metadata.importance,
327
+ metadata.created_at.isoformat(),
328
+ metadata.valid_until.isoformat() if metadata.valid_until else None,
329
+ json.dumps(metadata.entity_refs),
330
+ metadata.content,
331
+ json.dumps(metadata.metadata or {}),
332
+ rowid,
333
+ )
334
+
335
+ @staticmethod
336
+ def _cursor_lastrowid(cursor: sqlite3.Cursor) -> int:
337
+ """Return a non-null SQLite cursor lastrowid."""
338
+ rowid = cursor.lastrowid
339
+ if rowid is None:
340
+ raise RuntimeError("sqlite-vec insert did not produce a rowid")
341
+ return cast(int, rowid)
342
+
343
  def _init_db(self) -> None:
344
  """Initialize the database schema."""
345
  with self._get_conn() as conn:
 
415
  Raises:
416
  ValueError: If memory has no embedding or wrong dimension.
417
  """
418
+ embedding, metadata = self._prepare_memory_for_index(memory)
 
 
 
 
 
 
 
 
 
 
419
 
420
  with self._lock:
421
  with self._get_conn() as conn:
 
422
  existing = conn.execute(
423
  "SELECT rowid FROM vec_metadata WHERE memory_id = ?",
424
  (memory.id,),
425
  ).fetchone()
426
 
427
  if existing:
428
+ rowid = int(existing[0])
 
 
 
429
  conn.execute(
430
  "UPDATE vec_embeddings SET embedding = ? WHERE rowid = ?",
431
  (self._serialize_f32(embedding), rowid),
432
  )
 
 
433
  conn.execute(
434
  """
435
  UPDATE vec_metadata SET
 
438
  entity_refs = ?, content = ?, metadata_json = ?
439
  WHERE rowid = ?
440
  """,
441
+ self._metadata_update_params(metadata, rowid),
 
 
 
 
 
 
 
 
 
 
 
442
  )
443
  else:
 
 
444
  cursor = conn.execute(
445
  """
446
  INSERT INTO vec_metadata (
 
449
  entity_refs, content, metadata_json
450
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
451
  """,
452
+ self._metadata_insert_params(memory.id, metadata),
 
 
 
 
 
 
 
 
 
 
 
453
  )
454
+ rowid = self._cursor_lastrowid(cursor)
 
 
455
  conn.execute(
456
  "INSERT INTO vec_embeddings (rowid, embedding) VALUES (?, ?)",
457
  (rowid, self._serialize_f32(embedding)),
 
468
  Returns:
469
  Number of memories indexed.
470
  """
471
+ prepared: list[tuple[str, np.ndarray, VectorMetadata]] = []
472
  for memory in memories:
473
+ try:
474
+ embedding, metadata = self._prepare_memory_for_index(memory)
475
+ except ValueError:
476
+ continue
477
+ prepared.append((memory.id, embedding, metadata))
478
+
479
+ if not prepared:
480
+ return 0
481
+
482
+ memory_ids = [memory_id for memory_id, _, _ in prepared]
483
+
484
+ with self._lock:
485
+ with self._get_conn() as conn:
486
+ if len(set(memory_ids)) != len(memory_ids):
487
+ existing_rowids = self._select_rowids_by_memory_ids(
488
+ conn, list(dict.fromkeys(memory_ids))
489
+ )
490
+ for memory_id, embedding, metadata in prepared:
491
+ rowid = existing_rowids.get(memory_id)
492
+ if rowid is None:
493
+ cursor = conn.execute(
494
+ """
495
+ INSERT INTO vec_metadata (
496
+ memory_id, user_id, session_id, agent_id,
497
+ importance, created_at, valid_until,
498
+ entity_refs, content, metadata_json
499
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
500
+ """,
501
+ self._metadata_insert_params(memory_id, metadata),
502
+ )
503
+ rowid = self._cursor_lastrowid(cursor)
504
+ existing_rowids[memory_id] = rowid
505
+ conn.execute(
506
+ "INSERT INTO vec_embeddings (rowid, embedding) VALUES (?, ?)",
507
+ (rowid, self._serialize_f32(embedding)),
508
+ )
509
+ else:
510
+ conn.execute(
511
+ "UPDATE vec_embeddings SET embedding = ? WHERE rowid = ?",
512
+ (self._serialize_f32(embedding), rowid),
513
+ )
514
+ conn.execute(
515
+ """
516
+ UPDATE vec_metadata SET
517
+ user_id = ?, session_id = ?, agent_id = ?,
518
+ importance = ?, created_at = ?, valid_until = ?,
519
+ entity_refs = ?, content = ?, metadata_json = ?
520
+ WHERE rowid = ?
521
+ """,
522
+ self._metadata_update_params(metadata, rowid),
523
+ )
524
+
525
+ conn.commit()
526
+ return len(prepared)
527
+
528
+ existing_rowids = self._select_rowids_by_memory_ids(conn, memory_ids)
529
+ metadata_updates: list[tuple[Any, ...]] = []
530
+ vector_updates: list[tuple[bytes, int]] = []
531
+ metadata_inserts: list[tuple[Any, ...]] = []
532
+ new_memory_ids: list[str] = []
533
+ new_vectors: list[tuple[str, bytes]] = []
534
+
535
+ for memory_id, embedding, metadata in prepared:
536
+ rowid = existing_rowids.get(memory_id)
537
+ serialized = self._serialize_f32(embedding)
538
+ if rowid is None:
539
+ metadata_inserts.append(self._metadata_insert_params(memory_id, metadata))
540
+ new_memory_ids.append(memory_id)
541
+ new_vectors.append((memory_id, serialized))
542
+ else:
543
+ vector_updates.append((serialized, rowid))
544
+ metadata_updates.append(self._metadata_update_params(metadata, rowid))
545
+
546
+ if vector_updates:
547
+ conn.executemany(
548
+ "UPDATE vec_embeddings SET embedding = ? WHERE rowid = ?",
549
+ vector_updates,
550
+ )
551
+ if metadata_updates:
552
+ conn.executemany(
553
+ """
554
+ UPDATE vec_metadata SET
555
+ user_id = ?, session_id = ?, agent_id = ?,
556
+ importance = ?, created_at = ?, valid_until = ?,
557
+ entity_refs = ?, content = ?, metadata_json = ?
558
+ WHERE rowid = ?
559
+ """,
560
+ metadata_updates,
561
+ )
562
+ if metadata_inserts:
563
+ conn.executemany(
564
+ """
565
+ INSERT INTO vec_metadata (
566
+ memory_id, user_id, session_id, agent_id,
567
+ importance, created_at, valid_until,
568
+ entity_refs, content, metadata_json
569
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
570
+ """,
571
+ metadata_inserts,
572
+ )
573
+ inserted_rowids = self._select_rowids_by_memory_ids(conn, new_memory_ids)
574
+ conn.executemany(
575
+ "INSERT INTO vec_embeddings (rowid, embedding) VALUES (?, ?)",
576
+ [
577
+ (inserted_rowids[memory_id], serialized)
578
+ for memory_id, serialized in new_vectors
579
+ ],
580
+ )
581
+
582
+ conn.commit()
583
+
584
+ return len(prepared)
585
 
586
  async def remove(self, memory_id: str) -> bool:
587
  """Remove a memory from the index.
 
621
  Returns:
622
  Number removed.
623
  """
624
+ unique_ids = list(dict.fromkeys(memory_ids))
625
+ if not unique_ids:
626
+ return 0
627
+
628
+ with self._lock:
629
+ with self._get_conn() as conn:
630
+ rowids_by_memory_id = self._select_rowids_by_memory_ids(conn, unique_ids)
631
+ rowids = list(rowids_by_memory_id.values())
632
+ if not rowids:
633
+ return 0
634
+
635
+ for rowid_chunk in self._chunked(rowids):
636
+ placeholders = ", ".join("?" for _ in rowid_chunk)
637
+ conn.execute(
638
+ f"DELETE FROM vec_embeddings WHERE rowid IN ({placeholders})",
639
+ rowid_chunk,
640
+ )
641
+ conn.execute(
642
+ f"DELETE FROM vec_metadata WHERE rowid IN ({placeholders})",
643
+ rowid_chunk,
644
+ )
645
+
646
+ conn.commit()
647
+ return len(rowids)
648
 
649
  async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
650
  """Search for similar vectors.
 
923
 
924
  async def close(self) -> None:
925
  """Close the index (cleanup)."""
926
+ with self._lock:
927
+ self._close_cached_connections()
headroom/memory/mcp_server.py CHANGED
@@ -135,14 +135,16 @@ async def _warm_up_backend(backend: LocalBackend, user_id: str) -> None:
135
  if not all_memories:
136
  return
137
 
138
- indexed = 0
139
- for mem in all_memories:
140
- if mem.embedding is None:
141
- mem.embedding = await hm._embedder.embed(mem.content)
142
- await hm._store.save(mem)
143
- await hm._vector_index.index(mem)
144
- indexed += 1
 
145
 
 
146
  logger.info(f"Memory MCP: indexed {indexed} memories into vector store")
147
 
148
 
 
135
  if not all_memories:
136
  return
137
 
138
+ memories_missing_embeddings = [mem for mem in all_memories if mem.embedding is None]
139
+ if memories_missing_embeddings:
140
+ embeddings = await hm._embedder.embed_batch(
141
+ [mem.content for mem in memories_missing_embeddings]
142
+ )
143
+ for mem, embedding in zip(memories_missing_embeddings, embeddings):
144
+ mem.embedding = embedding
145
+ await hm._store.save_batch(memories_missing_embeddings)
146
 
147
+ indexed = await hm._vector_index.index_batch(all_memories)
148
  logger.info(f"Memory MCP: indexed {indexed} memories into vector store")
149
 
150
 
headroom/memory/traffic_learner.py CHANGED
@@ -22,10 +22,12 @@ import asyncio
22
  import hashlib
23
  import json
24
  import logging
 
25
  import re
26
  import sqlite3
27
  import time
28
  from dataclasses import dataclass, field
 
29
  from enum import Enum
30
  from pathlib import Path
31
  from typing import TYPE_CHECKING, Any
@@ -45,6 +47,20 @@ FLUSH_DEBOUNCE_SECONDS = 10.0
45
  # Matches POSIX paths (starts with /) and common Windows drive paths.
46
  _ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\\-]+")
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  # =============================================================================
50
  # Pattern Categories
@@ -87,10 +103,60 @@ class ExtractedPattern:
87
  entity_refs: list[str] = field(default_factory=list)
88
  metadata: dict[str, Any] = field(default_factory=dict)
89
  content_hash: str = ""
 
 
90
 
91
  def __post_init__(self) -> None:
92
  if not self.content_hash:
93
- self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  # =============================================================================
@@ -389,6 +455,7 @@ class TrafficLearner:
389
  Evidence counts are summed across duplicates.
390
  """
391
  by_hash: dict[str, ExtractedPattern] = {}
 
392
 
393
  # Persisted rows from memory.db
394
  db_path = _resolve_backend_db_path(self._backend)
@@ -404,11 +471,15 @@ class TrafficLearner:
404
  else:
405
  by_hash[p.content_hash] = p
406
 
407
- # In-memory accumulator (patterns not yet persisted)
 
 
408
  for pattern, count in self._pattern_counts.values():
409
  h = pattern.content_hash
410
  if h in by_hash:
411
- by_hash[h].evidence_count += count
 
 
412
  else:
413
  by_hash[h] = ExtractedPattern(
414
  category=pattern.category,
@@ -418,6 +489,8 @@ class TrafficLearner:
418
  entity_refs=list(pattern.entity_refs),
419
  metadata=dict(pattern.metadata),
420
  content_hash=pattern.content_hash,
 
 
421
  )
422
 
423
  return list(by_hash.values())
@@ -578,7 +651,12 @@ class TrafficLearner:
578
  content=content,
579
  importance=0.7,
580
  entity_refs=[success_path],
581
- metadata={"error_category": error_cat},
 
 
 
 
 
582
  )
583
  elif tool in ("Grep", "Glob"):
584
  error_pattern = error_entry["input"].get("pattern", "")
@@ -635,7 +713,12 @@ class TrafficLearner:
635
  content=content,
636
  importance=importance,
637
  entity_refs=entities,
638
- metadata={"error_category": error_cat, "failed_cmd": failed_short},
 
 
 
 
 
639
  )
640
 
641
  def _extract_environment(self, entry: dict[str, Any]) -> list[ExtractedPattern]:
@@ -762,6 +845,7 @@ class TrafficLearner:
762
  if self._backend is None:
763
  continue
764
 
 
765
  memory = await self._backend.save_memory(
766
  content=pattern.content,
767
  user_id=self._user_id,
@@ -770,6 +854,8 @@ class TrafficLearner:
770
  "source": "traffic_learner",
771
  "category": pattern.category.value,
772
  "evidence_count": pattern.evidence_count,
 
 
773
  **pattern.metadata,
774
  },
775
  )
@@ -796,7 +882,7 @@ class TrafficLearner:
796
  if db_path is None or not db_path.exists():
797
  return
798
 
799
- def _read() -> list[tuple[str, str]]:
800
  uri = f"file:{db_path}?mode=ro"
801
  try:
802
  conn = sqlite3.connect(uri, uri=True)
@@ -804,7 +890,7 @@ class TrafficLearner:
804
  return []
805
  try:
806
  rows = conn.execute(
807
- "SELECT id, content FROM memories "
808
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
809
  ).fetchall()
810
  except sqlite3.DatabaseError:
@@ -814,7 +900,7 @@ class TrafficLearner:
814
  conn.close()
815
  except Exception:
816
  pass
817
- return [(row[0], row[1] or "") for row in rows]
818
 
819
  try:
820
  rows = await asyncio.to_thread(_read)
@@ -822,10 +908,24 @@ class TrafficLearner:
822
  logger.debug("Traffic learner hydrate failed: %s", e)
823
  return
824
 
825
- for memory_id, content in rows:
826
  if not content:
827
  continue
828
- h = hashlib.sha256(content.encode()).hexdigest()[:16]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  self._saved_hashes.add(h)
830
  # If multiple rows share the same content (legacy duplicates),
831
  # last-wins — we only need one id to target the bump.
@@ -837,15 +937,18 @@ class TrafficLearner:
837
  if db_path is None or not db_path.exists():
838
  return
839
 
 
 
840
  def _bump() -> None:
841
  conn = sqlite3.connect(str(db_path))
842
  try:
843
  conn.execute(
844
  "UPDATE memories SET metadata = json_set("
845
  "metadata, '$.evidence_count', "
846
- "COALESCE(json_extract(metadata, '$.evidence_count'), 0) + 1"
 
847
  ") WHERE id = ?",
848
- (memory_id,),
849
  )
850
  conn.commit()
851
  finally:
@@ -1007,7 +1110,7 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1007
  try:
1008
  conn.row_factory = sqlite3.Row
1009
  rows = conn.execute(
1010
- "SELECT content, metadata, entity_refs, importance "
1011
  "FROM memories "
1012
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
1013
  ).fetchall()
@@ -1045,12 +1148,24 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1045
  except (TypeError, ValueError):
1046
  importance = 0.5
1047
 
1048
- h = hashlib.sha256(content.encode()).hexdigest()[:16]
 
 
 
 
 
 
1049
  if h in patterns:
1050
  existing = patterns[h]
1051
  existing.evidence_count += evidence
1052
  if importance > existing.importance:
1053
  existing.importance = importance
 
 
 
 
 
 
1054
  else:
1055
  patterns[h] = ExtractedPattern(
1056
  category=category,
@@ -1060,11 +1175,26 @@ def _load_persisted_patterns_from_sqlite(db_path: Path) -> list[ExtractedPattern
1060
  entity_refs=list(entity_refs),
1061
  metadata=meta,
1062
  content_hash=h,
 
 
1063
  )
1064
 
1065
  return list(patterns.values())
1066
 
1067
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1068
  def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1069
  """Group patterns by category into one Recommendation per category.
1070
 
@@ -1086,8 +1216,13 @@ def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1086
  if target_str == "context_file"
1087
  else RecommendationTarget.MEMORY_FILE
1088
  )
1089
- # Sort by evidence_count desc so the most-supported rules appear first.
1090
- items.sort(key=lambda p: p.evidence_count, reverse=True)
 
 
 
 
 
1091
  bullets = "\n".join(f"- {p.content}" for p in items)
1092
  recs.append(
1093
  Recommendation(
@@ -1099,3 +1234,99 @@ def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1099
  )
1100
  )
1101
  return recs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  import hashlib
23
  import json
24
  import logging
25
+ import os
26
  import re
27
  import sqlite3
28
  import time
29
  from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
  from enum import Enum
32
  from pathlib import Path
33
  from typing import TYPE_CHECKING, Any
 
47
  # Matches POSIX paths (starts with /) and common Windows drive paths.
48
  _ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[\w./\\\-]+")
49
 
50
+ # Error-recovery refinement: the Learned: error recovery section is capped,
51
+ # decayed, and re-validated at render time. Other categories are untouched.
52
+ _ERROR_RECOVERY_SECTION_CAP = 15
53
+ _ERROR_RECOVERY_HALF_LIFE_DAYS = 5.0
54
+ _ERROR_RECOVERY_HARD_FLOOR_DAYS = 21
55
+
56
+ # Suffixes that vary between otherwise-identical Bash recoveries. Stripping
57
+ # them before hashing collapses near-duplicates.
58
+ _BASH_VOLATILE_SUFFIX_RE = re.compile(
59
+ r"(?:\s*\|\s*(?:head|tail)\s+-n?\s*\d+"
60
+ r"|\s+-A\s*\d+|\s+-B\s*\d+|\s+-C\s*\d+"
61
+ r"|\s+2>&1|\s+2>/dev/null)+\s*$"
62
+ )
63
+
64
 
65
  # =============================================================================
66
  # Pattern Categories
 
103
  entity_refs: list[str] = field(default_factory=list)
104
  metadata: dict[str, Any] = field(default_factory=dict)
105
  content_hash: str = ""
106
+ first_seen_at: datetime | None = None
107
+ last_seen_at: datetime | None = None
108
 
109
  def __post_init__(self) -> None:
110
  if not self.content_hash:
111
+ key = _normalize_hash_key(self.category, self.content, self.metadata)
112
+ self.content_hash = hashlib.sha256(key.encode()).hexdigest()[:16]
113
+
114
+
115
+ def _normalize_hash_key(
116
+ category: PatternCategory,
117
+ content: str,
118
+ metadata: dict[str, Any],
119
+ ) -> str:
120
+ """Build the string that feeds the content hash.
121
+
122
+ Error-recovery rows are collapsed on recovery intent, not literal text:
123
+ trivial invocation differences (tail counts, pipe suffixes, full paths
124
+ that share a basename) hash to the same key. Other categories hash the
125
+ raw content for backwards compatibility.
126
+ """
127
+ if category is not PatternCategory.ERROR_RECOVERY:
128
+ return content
129
+
130
+ tool = metadata.get("tool")
131
+ if tool == "Read":
132
+ error_path = metadata.get("error_path", "")
133
+ success_path = metadata.get("success_path", "")
134
+ return (
135
+ f"error_recovery|Read|{os.path.basename(error_path)}|{os.path.basename(success_path)}"
136
+ )
137
+ if tool == "Bash":
138
+ failed = metadata.get("failed_cmd", "")
139
+ success = metadata.get("success_cmd", "")
140
+ return (
141
+ f"error_recovery|Bash|"
142
+ f"{_normalize_bash_for_hash(failed)}|{_normalize_bash_for_hash(success)}"
143
+ )
144
+ return content
145
+
146
+
147
+ def _normalize_bash_for_hash(cmd: str) -> str:
148
+ """Strip volatile suffixes and truncate at the first pipe/chain boundary."""
149
+ if not cmd:
150
+ return ""
151
+ # Drop paging, line-context flags, and redirections that vary between runs.
152
+ trimmed = _BASH_VOLATILE_SUFFIX_RE.sub("", cmd).strip()
153
+ # Cut at the first pipe or && so we hash the primary command, not the tail.
154
+ for sep in (" | ", " && "):
155
+ idx = trimmed.find(sep)
156
+ if idx != -1:
157
+ trimmed = trimmed[:idx].rstrip()
158
+ break
159
+ return trimmed
160
 
161
 
162
  # =============================================================================
 
455
  Evidence counts are summed across duplicates.
456
  """
457
  by_hash: dict[str, ExtractedPattern] = {}
458
+ now = datetime.now(timezone.utc)
459
 
460
  # Persisted rows from memory.db
461
  db_path = _resolve_backend_db_path(self._backend)
 
471
  else:
472
  by_hash[p.content_hash] = p
473
 
474
+ # In-memory accumulator (patterns not yet persisted). Re-sightings in
475
+ # this session bump last_seen_at to "now" on top of the persisted
476
+ # timestamp so recency ranking reflects live activity.
477
  for pattern, count in self._pattern_counts.values():
478
  h = pattern.content_hash
479
  if h in by_hash:
480
+ existing = by_hash[h]
481
+ existing.evidence_count += count
482
+ existing.last_seen_at = now
483
  else:
484
  by_hash[h] = ExtractedPattern(
485
  category=pattern.category,
 
489
  entity_refs=list(pattern.entity_refs),
490
  metadata=dict(pattern.metadata),
491
  content_hash=pattern.content_hash,
492
+ first_seen_at=now,
493
+ last_seen_at=now,
494
  )
495
 
496
  return list(by_hash.values())
 
651
  content=content,
652
  importance=0.7,
653
  entity_refs=[success_path],
654
+ metadata={
655
+ "error_category": error_cat,
656
+ "tool": "Read",
657
+ "error_path": error_path,
658
+ "success_path": success_path,
659
+ },
660
  )
661
  elif tool in ("Grep", "Glob"):
662
  error_pattern = error_entry["input"].get("pattern", "")
 
713
  content=content,
714
  importance=importance,
715
  entity_refs=entities,
716
+ metadata={
717
+ "error_category": error_cat,
718
+ "tool": "Bash",
719
+ "failed_cmd": failed_short,
720
+ "success_cmd": success_short,
721
+ },
722
  )
723
 
724
  def _extract_environment(self, entry: dict[str, Any]) -> list[ExtractedPattern]:
 
845
  if self._backend is None:
846
  continue
847
 
848
+ now_iso = datetime.now(timezone.utc).isoformat()
849
  memory = await self._backend.save_memory(
850
  content=pattern.content,
851
  user_id=self._user_id,
 
854
  "source": "traffic_learner",
855
  "category": pattern.category.value,
856
  "evidence_count": pattern.evidence_count,
857
+ "first_seen_at": now_iso,
858
+ "last_seen_at": now_iso,
859
  **pattern.metadata,
860
  },
861
  )
 
882
  if db_path is None or not db_path.exists():
883
  return
884
 
885
+ def _read() -> list[tuple[str, str, str]]:
886
  uri = f"file:{db_path}?mode=ro"
887
  try:
888
  conn = sqlite3.connect(uri, uri=True)
 
890
  return []
891
  try:
892
  rows = conn.execute(
893
+ "SELECT id, content, metadata FROM memories "
894
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
895
  ).fetchall()
896
  except sqlite3.DatabaseError:
 
900
  conn.close()
901
  except Exception:
902
  pass
903
+ return [(row[0], row[1] or "", row[2] or "{}") for row in rows]
904
 
905
  try:
906
  rows = await asyncio.to_thread(_read)
 
908
  logger.debug("Traffic learner hydrate failed: %s", e)
909
  return
910
 
911
+ for memory_id, content, metadata_json in rows:
912
  if not content:
913
  continue
914
+ try:
915
+ metadata = json.loads(metadata_json) if metadata_json else {}
916
+ except json.JSONDecodeError:
917
+ metadata = {}
918
+ category_value = metadata.get("category")
919
+ try:
920
+ category = PatternCategory(category_value) if category_value else None
921
+ except ValueError:
922
+ category = None
923
+ if category is None:
924
+ # Legacy row without category — fall back to literal hash.
925
+ key = content
926
+ else:
927
+ key = _normalize_hash_key(category, content, metadata)
928
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
929
  self._saved_hashes.add(h)
930
  # If multiple rows share the same content (legacy duplicates),
931
  # last-wins — we only need one id to target the bump.
 
937
  if db_path is None or not db_path.exists():
938
  return
939
 
940
+ now_iso = datetime.now(timezone.utc).isoformat()
941
+
942
  def _bump() -> None:
943
  conn = sqlite3.connect(str(db_path))
944
  try:
945
  conn.execute(
946
  "UPDATE memories SET metadata = json_set("
947
  "metadata, '$.evidence_count', "
948
+ "COALESCE(json_extract(metadata, '$.evidence_count'), 0) + 1, "
949
+ "'$.last_seen_at', ?"
950
  ") WHERE id = ?",
951
+ (now_iso, memory_id),
952
  )
953
  conn.commit()
954
  finally:
 
1110
  try:
1111
  conn.row_factory = sqlite3.Row
1112
  rows = conn.execute(
1113
+ "SELECT content, metadata, entity_refs, importance, created_at "
1114
  "FROM memories "
1115
  "WHERE json_extract(metadata, '$.source') = 'traffic_learner'"
1116
  ).fetchall()
 
1148
  except (TypeError, ValueError):
1149
  importance = 0.5
1150
 
1151
+ first_seen = _parse_iso_timestamp(meta.get("first_seen_at")) or _parse_iso_timestamp(
1152
+ row["created_at"]
1153
+ )
1154
+ last_seen = _parse_iso_timestamp(meta.get("last_seen_at")) or first_seen
1155
+
1156
+ key = _normalize_hash_key(category, content, meta)
1157
+ h = hashlib.sha256(key.encode()).hexdigest()[:16]
1158
  if h in patterns:
1159
  existing = patterns[h]
1160
  existing.evidence_count += evidence
1161
  if importance > existing.importance:
1162
  existing.importance = importance
1163
+ if last_seen and (existing.last_seen_at is None or last_seen > existing.last_seen_at):
1164
+ existing.last_seen_at = last_seen
1165
+ if first_seen and (
1166
+ existing.first_seen_at is None or first_seen < existing.first_seen_at
1167
+ ):
1168
+ existing.first_seen_at = first_seen
1169
  else:
1170
  patterns[h] = ExtractedPattern(
1171
  category=category,
 
1175
  entity_refs=list(entity_refs),
1176
  metadata=meta,
1177
  content_hash=h,
1178
+ first_seen_at=first_seen,
1179
+ last_seen_at=last_seen,
1180
  )
1181
 
1182
  return list(patterns.values())
1183
 
1184
 
1185
+ def _parse_iso_timestamp(value: Any) -> datetime | None:
1186
+ """Parse an ISO-8601 timestamp stored as TEXT. Returns None on any failure."""
1187
+ if not value or not isinstance(value, str):
1188
+ return None
1189
+ try:
1190
+ parsed = datetime.fromisoformat(value)
1191
+ except ValueError:
1192
+ return None
1193
+ if parsed.tzinfo is None:
1194
+ parsed = parsed.replace(tzinfo=timezone.utc)
1195
+ return parsed
1196
+
1197
+
1198
  def _patterns_to_recommendations(patterns: list[ExtractedPattern]) -> list:
1199
  """Group patterns by category into one Recommendation per category.
1200
 
 
1216
  if target_str == "context_file"
1217
  else RecommendationTarget.MEMORY_FILE
1218
  )
1219
+ if category is PatternCategory.ERROR_RECOVERY:
1220
+ items = _refine_error_recovery(items)
1221
+ else:
1222
+ # Sort by evidence_count desc so the most-supported rules appear first.
1223
+ items.sort(key=lambda p: p.evidence_count, reverse=True)
1224
+ if not items:
1225
+ continue
1226
  bullets = "\n".join(f"- {p.content}" for p in items)
1227
  recs.append(
1228
  Recommendation(
 
1234
  )
1235
  )
1236
  return recs
1237
+
1238
+
1239
+ def _refine_error_recovery(patterns: list[ExtractedPattern]) -> list[ExtractedPattern]:
1240
+ """Apply the render-time pipeline for error_recovery patterns.
1241
+
1242
+ Pipeline: hard-floor drop by last_seen_at, re-validate Read success
1243
+ paths against the filesystem, collapse ambiguous error_paths into a
1244
+ single "search first" hint, rank by recency-weighted evidence, and
1245
+ cap the section at _ERROR_RECOVERY_SECTION_CAP bullets.
1246
+ """
1247
+ now = datetime.now(timezone.utc)
1248
+
1249
+ # 1. Hard floor — drop rows not re-observed in the last N days.
1250
+ alive: list[ExtractedPattern] = []
1251
+ for p in patterns:
1252
+ last_seen = p.last_seen_at or p.first_seen_at
1253
+ if last_seen is None:
1254
+ # No timestamp — treat as just-seen so it survives one render.
1255
+ alive.append(p)
1256
+ continue
1257
+ age_days = (now - last_seen).total_seconds() / 86400.0
1258
+ if age_days <= _ERROR_RECOVERY_HARD_FLOOR_DAYS:
1259
+ alive.append(p)
1260
+
1261
+ # 2. Re-validate Read recoveries — drop if success_path no longer exists.
1262
+ validated: list[ExtractedPattern] = []
1263
+ for p in alive:
1264
+ if p.metadata.get("tool") == "Read":
1265
+ success_path = p.metadata.get("success_path")
1266
+ if success_path:
1267
+ try:
1268
+ if not Path(success_path).exists():
1269
+ continue
1270
+ except OSError:
1271
+ # Path check failed (permissions, etc.) — keep the row
1272
+ # rather than drop on a transient error.
1273
+ pass
1274
+ validated.append(p)
1275
+
1276
+ # 3. Collision-collapse — same error_path with >=2 distinct success_paths
1277
+ # is an ambiguity signal, not N separate lessons. Replace the group
1278
+ # with one synthesized "search first" bullet.
1279
+ read_groups: dict[str, list[ExtractedPattern]] = {}
1280
+ others: list[ExtractedPattern] = []
1281
+ for p in validated:
1282
+ if p.metadata.get("tool") == "Read" and p.metadata.get("error_path"):
1283
+ read_groups.setdefault(p.metadata["error_path"], []).append(p)
1284
+ else:
1285
+ others.append(p)
1286
+
1287
+ collapsed: list[ExtractedPattern] = list(others)
1288
+ for error_path, group in read_groups.items():
1289
+ distinct_targets = {g.metadata.get("success_path") for g in group}
1290
+ distinct_targets.discard(None)
1291
+ if len(group) >= 2 and len(distinct_targets) >= 2:
1292
+ basename = os.path.basename(error_path) or error_path
1293
+ synth_content = (
1294
+ f"Path `{basename}` has been guessed wrong repeatedly — "
1295
+ f"use Glob/Grep to locate before reading."
1296
+ )
1297
+ max_last_seen = max(
1298
+ (g.last_seen_at for g in group if g.last_seen_at),
1299
+ default=now,
1300
+ )
1301
+ collapsed.append(
1302
+ ExtractedPattern(
1303
+ category=PatternCategory.ERROR_RECOVERY,
1304
+ content=synth_content,
1305
+ importance=max(g.importance for g in group),
1306
+ evidence_count=sum(g.evidence_count for g in group),
1307
+ metadata={
1308
+ "tool": "Read",
1309
+ "error_path": error_path,
1310
+ "collapsed": True,
1311
+ },
1312
+ last_seen_at=max_last_seen,
1313
+ first_seen_at=min(
1314
+ (g.first_seen_at for g in group if g.first_seen_at),
1315
+ default=max_last_seen,
1316
+ ),
1317
+ )
1318
+ )
1319
+ else:
1320
+ collapsed.extend(group)
1321
+
1322
+ # 4. Recency-weighted score.
1323
+ def _score(p: ExtractedPattern) -> float:
1324
+ last_seen = p.last_seen_at or p.first_seen_at or now
1325
+ age_days = max(0.0, (now - last_seen).total_seconds() / 86400.0)
1326
+ decay = float(0.5 ** (age_days / _ERROR_RECOVERY_HALF_LIFE_DAYS))
1327
+ return float(p.evidence_count) * decay
1328
+
1329
+ collapsed.sort(key=_score, reverse=True)
1330
+
1331
+ # 5. Cap the section.
1332
+ return collapsed[:_ERROR_RECOVERY_SECTION_CAP]
headroom/providers/aider/install.py CHANGED
@@ -1,12 +1,12 @@
1
- """Aider install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from .runtime import build_launch_env
6
-
7
-
8
- def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
- """Build the persistent install environment for Aider."""
10
- del backend
11
- env, _lines = build_launch_env(port=port, environ={})
12
- return {key: env[key] for key in ("OPENAI_API_BASE", "ANTHROPIC_BASE_URL")}
 
1
+ """Aider install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .runtime import build_launch_env
6
+
7
+
8
+ def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
+ """Build the persistent install environment for Aider."""
10
+ del backend
11
+ env, _lines = build_launch_env(port=port, environ={})
12
+ return {key: env[key] for key in ("OPENAI_API_BASE", "ANTHROPIC_BASE_URL")}
headroom/providers/claude/install.py CHANGED
@@ -1,63 +1,63 @@
1
- """Claude install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
-
8
- from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
9
- from headroom.install.paths import claude_settings_path
10
-
11
- from .runtime import proxy_base_url
12
-
13
-
14
- def build_install_env(*, port: int, backend: str) -> dict[str, str]:
15
- """Build the persistent install environment for Claude."""
16
- del backend
17
- return {"ANTHROPIC_BASE_URL": proxy_base_url(port)}
18
-
19
-
20
- def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
21
- """Apply Claude provider-scope configuration when requested."""
22
- if manifest.scope != ConfigScope.PROVIDER.value:
23
- return None
24
-
25
- path = claude_settings_path()
26
- path.parent.mkdir(parents=True, exist_ok=True)
27
- payload: dict[str, object] = {}
28
- if path.exists():
29
- payload = json.loads(path.read_text())
30
- env = payload.get("env")
31
- env_map = dict(env) if isinstance(env, dict) else {}
32
- values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
33
- previous = {name: env_map.get(name) for name in values}
34
- env_map.update(values)
35
- payload["env"] = env_map
36
- path.write_text(json.dumps(payload, indent=2) + "\n")
37
- return ManagedMutation(
38
- target=ToolTarget.CLAUDE.value,
39
- kind="json-env",
40
- path=str(path),
41
- data={"previous": previous},
42
- )
43
-
44
-
45
- def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
46
- """Revert Claude provider-scope configuration."""
47
- if not mutation.path:
48
- return
49
- path = Path(mutation.path)
50
- if not path.exists():
51
- return
52
- payload = json.loads(path.read_text())
53
- env = payload.get("env")
54
- env_map = dict(env) if isinstance(env, dict) else {}
55
- previous: dict[str, object] = mutation.data.get("previous", {})
56
- values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
57
- for name in values:
58
- if previous.get(name) is None:
59
- env_map.pop(name, None)
60
- else:
61
- env_map[name] = previous[name]
62
- payload["env"] = env_map
63
- path.write_text(json.dumps(payload, indent=2) + "\n")
 
1
+ """Claude install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
9
+ from headroom.install.paths import claude_settings_path
10
+
11
+ from .runtime import proxy_base_url
12
+
13
+
14
+ def build_install_env(*, port: int, backend: str) -> dict[str, str]:
15
+ """Build the persistent install environment for Claude."""
16
+ del backend
17
+ return {"ANTHROPIC_BASE_URL": proxy_base_url(port)}
18
+
19
+
20
+ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
21
+ """Apply Claude provider-scope configuration when requested."""
22
+ if manifest.scope != ConfigScope.PROVIDER.value:
23
+ return None
24
+
25
+ path = claude_settings_path()
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ payload: dict[str, object] = {}
28
+ if path.exists():
29
+ payload = json.loads(path.read_text())
30
+ env = payload.get("env")
31
+ env_map = dict(env) if isinstance(env, dict) else {}
32
+ values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
33
+ previous = {name: env_map.get(name) for name in values}
34
+ env_map.update(values)
35
+ payload["env"] = env_map
36
+ path.write_text(json.dumps(payload, indent=2) + "\n")
37
+ return ManagedMutation(
38
+ target=ToolTarget.CLAUDE.value,
39
+ kind="json-env",
40
+ path=str(path),
41
+ data={"previous": previous},
42
+ )
43
+
44
+
45
+ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
46
+ """Revert Claude provider-scope configuration."""
47
+ if not mutation.path:
48
+ return
49
+ path = Path(mutation.path)
50
+ if not path.exists():
51
+ return
52
+ payload = json.loads(path.read_text())
53
+ env = payload.get("env")
54
+ env_map = dict(env) if isinstance(env, dict) else {}
55
+ previous: dict[str, object] = mutation.data.get("previous", {})
56
+ values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
57
+ for name in values:
58
+ if previous.get(name) is None:
59
+ env_map.pop(name, None)
60
+ else:
61
+ env_map[name] = previous[name]
62
+ payload["env"] = env_map
63
+ path.write_text(json.dumps(payload, indent=2) + "\n")
headroom/providers/codex/install.py CHANGED
@@ -1,68 +1,68 @@
1
- """Codex install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- from pathlib import Path
7
-
8
- from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
9
- from headroom.install.paths import codex_config_path
10
-
11
- from .runtime import proxy_base_url
12
-
13
- _CODEX_MARKER_START = "# --- Headroom persistent provider ---"
14
- _CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
15
- _CODEX_PATTERN = re.compile(
16
- re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
17
- re.DOTALL,
18
- )
19
-
20
-
21
- def build_install_env(*, port: int, backend: str) -> dict[str, str]:
22
- """Build the persistent install environment for Codex."""
23
- del backend
24
- return {"OPENAI_BASE_URL": proxy_base_url(port)}
25
-
26
-
27
- def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
28
- """Apply Codex provider-scope configuration when requested."""
29
- if manifest.scope != ConfigScope.PROVIDER.value:
30
- return None
31
-
32
- path = codex_config_path()
33
- path.parent.mkdir(parents=True, exist_ok=True)
34
- section = (
35
- f"{_CODEX_MARKER_START}\n"
36
- 'model_provider = "headroom"\n\n'
37
- "[model_providers.headroom]\n"
38
- 'name = "Headroom persistent proxy"\n'
39
- f'base_url = "{proxy_base_url(manifest.port)}"\n'
40
- 'env_key = "OPENAI_API_KEY"\n'
41
- "requires_openai_auth = true\n"
42
- "supports_websockets = true\n"
43
- f"{_CODEX_MARKER_END}\n"
44
- )
45
- if path.exists():
46
- existing = path.read_text()
47
- if _CODEX_MARKER_START in existing:
48
- merged = _CODEX_PATTERN.sub(section, existing)
49
- else:
50
- merged = existing.rstrip() + "\n\n" + section + "\n"
51
- else:
52
- merged = section + "\n"
53
- path.write_text(merged)
54
- return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
55
-
56
-
57
- def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
58
- """Revert Codex provider-scope configuration."""
59
- del manifest
60
- if not mutation.path:
61
- return
62
- path = Path(mutation.path)
63
- if not path.exists():
64
- return
65
- content = path.read_text()
66
- if _CODEX_MARKER_START not in content:
67
- return
68
- path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
 
1
+ """Codex install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
9
+ from headroom.install.paths import codex_config_path
10
+
11
+ from .runtime import proxy_base_url
12
+
13
+ _CODEX_MARKER_START = "# --- Headroom persistent provider ---"
14
+ _CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
15
+ _CODEX_PATTERN = re.compile(
16
+ re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
17
+ re.DOTALL,
18
+ )
19
+
20
+
21
+ def build_install_env(*, port: int, backend: str) -> dict[str, str]:
22
+ """Build the persistent install environment for Codex."""
23
+ del backend
24
+ return {"OPENAI_BASE_URL": proxy_base_url(port)}
25
+
26
+
27
+ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:
28
+ """Apply Codex provider-scope configuration when requested."""
29
+ if manifest.scope != ConfigScope.PROVIDER.value:
30
+ return None
31
+
32
+ path = codex_config_path()
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ section = (
35
+ f"{_CODEX_MARKER_START}\n"
36
+ 'model_provider = "headroom"\n\n'
37
+ "[model_providers.headroom]\n"
38
+ 'name = "Headroom persistent proxy"\n'
39
+ f'base_url = "{proxy_base_url(manifest.port)}"\n'
40
+ 'env_key = "OPENAI_API_KEY"\n'
41
+ "requires_openai_auth = true\n"
42
+ "supports_websockets = true\n"
43
+ f"{_CODEX_MARKER_END}\n"
44
+ )
45
+ if path.exists():
46
+ existing = path.read_text()
47
+ if _CODEX_MARKER_START in existing:
48
+ merged = _CODEX_PATTERN.sub(section, existing)
49
+ else:
50
+ merged = existing.rstrip() + "\n\n" + section + "\n"
51
+ else:
52
+ merged = section + "\n"
53
+ path.write_text(merged)
54
+ return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
55
+
56
+
57
+ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
58
+ """Revert Codex provider-scope configuration."""
59
+ del manifest
60
+ if not mutation.path:
61
+ return
62
+ path = Path(mutation.path)
63
+ if not path.exists():
64
+ return
65
+ content = path.read_text()
66
+ if _CODEX_MARKER_START not in content:
67
+ return
68
+ path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
headroom/providers/copilot/install.py CHANGED
@@ -1,25 +1,25 @@
1
- """Copilot install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from .wrap import build_launch_env, resolve_provider_type
6
-
7
-
8
- def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
- """Build the persistent install environment for Copilot."""
10
- provider_type = resolve_provider_type(backend, "auto", {"HEADROOM_BACKEND": backend})
11
- env, _lines = build_launch_env(
12
- port=port,
13
- provider_type=provider_type,
14
- wire_api=None,
15
- environ={},
16
- )
17
- return {
18
- key: env[key]
19
- for key in (
20
- "COPILOT_PROVIDER_TYPE",
21
- "COPILOT_PROVIDER_BASE_URL",
22
- "COPILOT_PROVIDER_WIRE_API",
23
- )
24
- if key in env
25
- }
 
1
+ """Copilot install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .wrap import build_launch_env, resolve_provider_type
6
+
7
+
8
+ def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
+ """Build the persistent install environment for Copilot."""
10
+ provider_type = resolve_provider_type(backend, "auto", {"HEADROOM_BACKEND": backend})
11
+ env, _lines = build_launch_env(
12
+ port=port,
13
+ provider_type=provider_type,
14
+ wire_api=None,
15
+ environ={},
16
+ )
17
+ return {
18
+ key: env[key]
19
+ for key in (
20
+ "COPILOT_PROVIDER_TYPE",
21
+ "COPILOT_PROVIDER_BASE_URL",
22
+ "COPILOT_PROVIDER_WIRE_API",
23
+ )
24
+ if key in env
25
+ }
headroom/providers/cursor/install.py CHANGED
@@ -1,15 +1,15 @@
1
- """Cursor install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from .runtime import build_proxy_targets
6
-
7
-
8
- def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
- """Build the persistent install environment for Cursor."""
10
- del backend
11
- targets = build_proxy_targets(port)
12
- return {
13
- "OPENAI_BASE_URL": targets.openai_base_url,
14
- "ANTHROPIC_BASE_URL": targets.anthropic_base_url,
15
- }
 
1
+ """Cursor install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .runtime import build_proxy_targets
6
+
7
+
8
+ def build_install_env(*, port: int, backend: str) -> dict[str, str]:
9
+ """Build the persistent install environment for Cursor."""
10
+ del backend
11
+ targets = build_proxy_targets(port)
12
+ return {
13
+ "OPENAI_BASE_URL": targets.openai_base_url,
14
+ "ANTHROPIC_BASE_URL": targets.anthropic_base_url,
15
+ }
headroom/providers/install_registry.py CHANGED
@@ -1,86 +1,86 @@
1
- """Install-time provider registry helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable
6
-
7
- from headroom.install.models import DeploymentManifest, ManagedMutation
8
- from headroom.providers.aider.install import build_install_env as _build_aider_install_env
9
- from headroom.providers.claude.install import (
10
- apply_provider_scope as _apply_claude_provider_scope,
11
- )
12
- from headroom.providers.claude.install import (
13
- build_install_env as _build_claude_install_env,
14
- )
15
- from headroom.providers.claude.install import (
16
- revert_provider_scope as _revert_claude_provider_scope,
17
- )
18
- from headroom.providers.codex.install import (
19
- apply_provider_scope as _apply_codex_provider_scope,
20
- )
21
- from headroom.providers.codex.install import build_install_env as _build_codex_install_env
22
- from headroom.providers.codex.install import (
23
- revert_provider_scope as _revert_codex_provider_scope,
24
- )
25
- from headroom.providers.copilot.install import (
26
- build_install_env as _build_copilot_install_env,
27
- )
28
- from headroom.providers.cursor.install import build_install_env as _build_cursor_install_env
29
- from headroom.providers.openclaw.install import (
30
- apply_provider_scope as _apply_openclaw_provider_scope,
31
- )
32
- from headroom.providers.openclaw.install import (
33
- revert_provider_scope as _revert_openclaw_provider_scope,
34
- )
35
-
36
- _InstallEnvBuilder = Callable[..., dict[str, str]]
37
- _ProviderScopeApplier = Callable[[DeploymentManifest], ManagedMutation | None]
38
- _ProviderScopeReverter = Callable[[ManagedMutation, DeploymentManifest], None]
39
-
40
- _ENV_BUILDERS: dict[str, _InstallEnvBuilder] = {
41
- "claude": _build_claude_install_env,
42
- "copilot": _build_copilot_install_env,
43
- "codex": _build_codex_install_env,
44
- "aider": _build_aider_install_env,
45
- "cursor": _build_cursor_install_env,
46
- }
47
-
48
- _PROVIDER_SCOPE_HANDLERS: dict[str, tuple[_ProviderScopeApplier, _ProviderScopeReverter]] = {
49
- "claude": (_apply_claude_provider_scope, _revert_claude_provider_scope),
50
- "codex": (_apply_codex_provider_scope, _revert_codex_provider_scope),
51
- "openclaw": (_apply_openclaw_provider_scope, _revert_openclaw_provider_scope),
52
- }
53
-
54
-
55
- def build_install_target_envs(
56
- port: int, backend: str, targets: list[str]
57
- ) -> dict[str, dict[str, str]]:
58
- """Build per-target install environment values via provider slices."""
59
- target_envs: dict[str, dict[str, str]] = {}
60
- for target in targets:
61
- builder = _ENV_BUILDERS.get(target)
62
- if builder is None:
63
- continue
64
- target_envs[target] = builder(port=port, backend=backend)
65
- return target_envs
66
-
67
-
68
- def apply_provider_scope_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
69
- """Apply provider-scope mutations owned by provider slices."""
70
- mutations: list[ManagedMutation] = []
71
- for target in manifest.targets:
72
- handlers = _PROVIDER_SCOPE_HANDLERS.get(target)
73
- if handlers is None:
74
- continue
75
- mutation = handlers[0](manifest)
76
- if mutation is not None:
77
- mutations.append(mutation)
78
- return mutations
79
-
80
-
81
- def revert_provider_scope_mutation(manifest: DeploymentManifest, mutation: ManagedMutation) -> None:
82
- """Revert a provider-scope mutation via the owning provider slice."""
83
- handlers = _PROVIDER_SCOPE_HANDLERS.get(mutation.target)
84
- if handlers is None:
85
- return
86
- handlers[1](mutation, manifest)
 
1
+ """Install-time provider registry helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from headroom.install.models import DeploymentManifest, ManagedMutation
8
+ from headroom.providers.aider.install import build_install_env as _build_aider_install_env
9
+ from headroom.providers.claude.install import (
10
+ apply_provider_scope as _apply_claude_provider_scope,
11
+ )
12
+ from headroom.providers.claude.install import (
13
+ build_install_env as _build_claude_install_env,
14
+ )
15
+ from headroom.providers.claude.install import (
16
+ revert_provider_scope as _revert_claude_provider_scope,
17
+ )
18
+ from headroom.providers.codex.install import (
19
+ apply_provider_scope as _apply_codex_provider_scope,
20
+ )
21
+ from headroom.providers.codex.install import build_install_env as _build_codex_install_env
22
+ from headroom.providers.codex.install import (
23
+ revert_provider_scope as _revert_codex_provider_scope,
24
+ )
25
+ from headroom.providers.copilot.install import (
26
+ build_install_env as _build_copilot_install_env,
27
+ )
28
+ from headroom.providers.cursor.install import build_install_env as _build_cursor_install_env
29
+ from headroom.providers.openclaw.install import (
30
+ apply_provider_scope as _apply_openclaw_provider_scope,
31
+ )
32
+ from headroom.providers.openclaw.install import (
33
+ revert_provider_scope as _revert_openclaw_provider_scope,
34
+ )
35
+
36
+ _InstallEnvBuilder = Callable[..., dict[str, str]]
37
+ _ProviderScopeApplier = Callable[[DeploymentManifest], ManagedMutation | None]
38
+ _ProviderScopeReverter = Callable[[ManagedMutation, DeploymentManifest], None]
39
+
40
+ _ENV_BUILDERS: dict[str, _InstallEnvBuilder] = {
41
+ "claude": _build_claude_install_env,
42
+ "copilot": _build_copilot_install_env,
43
+ "codex": _build_codex_install_env,
44
+ "aider": _build_aider_install_env,
45
+ "cursor": _build_cursor_install_env,
46
+ }
47
+
48
+ _PROVIDER_SCOPE_HANDLERS: dict[str, tuple[_ProviderScopeApplier, _ProviderScopeReverter]] = {
49
+ "claude": (_apply_claude_provider_scope, _revert_claude_provider_scope),
50
+ "codex": (_apply_codex_provider_scope, _revert_codex_provider_scope),
51
+ "openclaw": (_apply_openclaw_provider_scope, _revert_openclaw_provider_scope),
52
+ }
53
+
54
+
55
+ def build_install_target_envs(
56
+ port: int, backend: str, targets: list[str]
57
+ ) -> dict[str, dict[str, str]]:
58
+ """Build per-target install environment values via provider slices."""
59
+ target_envs: dict[str, dict[str, str]] = {}
60
+ for target in targets:
61
+ builder = _ENV_BUILDERS.get(target)
62
+ if builder is None:
63
+ continue
64
+ target_envs[target] = builder(port=port, backend=backend)
65
+ return target_envs
66
+
67
+
68
+ def apply_provider_scope_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
69
+ """Apply provider-scope mutations owned by provider slices."""
70
+ mutations: list[ManagedMutation] = []
71
+ for target in manifest.targets:
72
+ handlers = _PROVIDER_SCOPE_HANDLERS.get(target)
73
+ if handlers is None:
74
+ continue
75
+ mutation = handlers[0](manifest)
76
+ if mutation is not None:
77
+ mutations.append(mutation)
78
+ return mutations
79
+
80
+
81
+ def revert_provider_scope_mutation(manifest: DeploymentManifest, mutation: ManagedMutation) -> None:
82
+ """Revert a provider-scope mutation via the owning provider slice."""
83
+ handlers = _PROVIDER_SCOPE_HANDLERS.get(mutation.target)
84
+ if handlers is None:
85
+ return
86
+ handlers[1](mutation, manifest)
headroom/providers/openclaw/install.py CHANGED
@@ -1,50 +1,50 @@
1
- """OpenClaw install-time helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import click
6
-
7
- from headroom.install.models import DeploymentManifest, ManagedMutation, ToolTarget
8
- from headroom.install.paths import openclaw_config_path
9
- from headroom.install.runtime import resolve_headroom_command
10
-
11
-
12
- def shutil_which(name: str) -> str | None:
13
- from shutil import which
14
-
15
- return which(name)
16
-
17
-
18
- def _invoke_openclaw(command: list[str]) -> None:
19
- import subprocess
20
-
21
- subprocess.run(command, check=True)
22
-
23
-
24
- def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
25
- """Configure OpenClaw to route through the persistent proxy."""
26
- if not shutil_which("openclaw"):
27
- raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
28
- command = [
29
- *resolve_headroom_command(),
30
- "wrap",
31
- "openclaw",
32
- "--no-auto-start",
33
- "--proxy-port",
34
- str(manifest.port),
35
- ]
36
- _invoke_openclaw(command)
37
- return ManagedMutation(
38
- target=ToolTarget.OPENCLAW.value,
39
- kind="openclaw-wrap",
40
- path=str(openclaw_config_path()),
41
- )
42
-
43
-
44
- def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
45
- """Undo OpenClaw persistent proxy configuration."""
46
- del mutation, manifest
47
- if not shutil_which("openclaw"):
48
- return
49
- command = [*resolve_headroom_command(), "unwrap", "openclaw"]
50
- _invoke_openclaw(command)
 
1
+ """OpenClaw install-time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from headroom.install.models import DeploymentManifest, ManagedMutation, ToolTarget
8
+ from headroom.install.paths import openclaw_config_path
9
+ from headroom.install.runtime import resolve_headroom_command
10
+
11
+
12
+ def shutil_which(name: str) -> str | None:
13
+ from shutil import which
14
+
15
+ return which(name)
16
+
17
+
18
+ def _invoke_openclaw(command: list[str]) -> None:
19
+ import subprocess
20
+
21
+ subprocess.run(command, check=True)
22
+
23
+
24
+ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
25
+ """Configure OpenClaw to route through the persistent proxy."""
26
+ if not shutil_which("openclaw"):
27
+ raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
28
+ command = [
29
+ *resolve_headroom_command(),
30
+ "wrap",
31
+ "openclaw",
32
+ "--no-auto-start",
33
+ "--proxy-port",
34
+ str(manifest.port),
35
+ ]
36
+ _invoke_openclaw(command)
37
+ return ManagedMutation(
38
+ target=ToolTarget.OPENCLAW.value,
39
+ kind="openclaw-wrap",
40
+ path=str(openclaw_config_path()),
41
+ )
42
+
43
+
44
+ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None:
45
+ """Undo OpenClaw persistent proxy configuration."""
46
+ del mutation, manifest
47
+ if not shutil_which("openclaw"):
48
+ return
49
+ command = [*resolve_headroom_command(), "unwrap", "openclaw"]
50
+ _invoke_openclaw(command)
headroom/proxy/handlers/anthropic.py CHANGED
@@ -315,6 +315,7 @@ class AnthropicHandlerMixin:
315
  MAX_REQUEST_BODY_SIZE,
316
  _get_image_compressor,
317
  _read_request_json,
 
318
  )
319
  from headroom.proxy.models import RequestLog
320
  from headroom.proxy.modes import is_cache_mode, is_token_mode
@@ -1348,6 +1349,9 @@ class AnthropicHandlerMixin:
1348
  request_messages=body.get("messages")
1349
  if self.config.log_full_messages
1350
  else None,
 
 
 
1351
  )
1352
  )
1353
 
@@ -1818,6 +1822,9 @@ class AnthropicHandlerMixin:
1818
  request_messages=messages
1819
  if self.config.log_full_messages
1820
  else None,
 
 
 
1821
  )
1822
  )
1823
 
 
315
  MAX_REQUEST_BODY_SIZE,
316
  _get_image_compressor,
317
  _read_request_json,
318
+ compute_turn_id,
319
  )
320
  from headroom.proxy.models import RequestLog
321
  from headroom.proxy.modes import is_cache_mode, is_token_mode
 
1349
  request_messages=body.get("messages")
1350
  if self.config.log_full_messages
1351
  else None,
1352
+ turn_id=compute_turn_id(
1353
+ model, body.get("system"), body.get("messages")
1354
+ ),
1355
  )
1356
  )
1357
 
 
1822
  request_messages=messages
1823
  if self.config.log_full_messages
1824
  else None,
1825
+ turn_id=compute_turn_id(
1826
+ model, body.get("system"), body.get("messages")
1827
+ ),
1828
  )
1829
  )
1830
 
headroom/proxy/handlers/openai.py CHANGED
The diff for this file is too large to render. See raw diff
 
headroom/proxy/handlers/streaming.py CHANGED
@@ -13,7 +13,7 @@ import time
13
  from datetime import datetime
14
  from typing import TYPE_CHECKING, Any
15
 
16
- from headroom.proxy.helpers import jitter_delay_ms
17
 
18
  if TYPE_CHECKING:
19
  from fastapi.responses import Response, StreamingResponse
@@ -1044,6 +1044,9 @@ class StreamingMixin:
1044
  request_messages=body.get("messages")
1045
  if self.config.log_full_messages
1046
  else None,
 
 
 
1047
  )
1048
  )
1049
 
 
13
  from datetime import datetime
14
  from typing import TYPE_CHECKING, Any
15
 
16
+ from headroom.proxy.helpers import compute_turn_id, jitter_delay_ms
17
 
18
  if TYPE_CHECKING:
19
  from fastapi.responses import Response, StreamingResponse
 
1044
  request_messages=body.get("messages")
1045
  if self.config.log_full_messages
1046
  else None,
1047
+ turn_id=compute_turn_id(
1048
+ model, body.get("system"), body.get("messages")
1049
+ ),
1050
  )
1051
  )
1052
 
headroom/proxy/helpers.py CHANGED
@@ -8,6 +8,7 @@ Extracted from server.py for maintainability.
8
 
9
  from __future__ import annotations
10
 
 
11
  import json
12
  import logging
13
  import random
@@ -278,3 +279,86 @@ async def _read_request_json(request: Request) -> dict[str, Any]:
278
  if not isinstance(result, dict):
279
  raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
280
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  from __future__ import annotations
10
 
11
+ import hashlib
12
  import json
13
  import logging
14
  import random
 
279
  if not isinstance(result, dict):
280
  raise ValueError("Request body must be a JSON object, not " + type(result).__name__)
281
  return result
282
+
283
+
284
+ def _strip_per_call_annotations(obj: Any) -> Any:
285
+ """Remove annotations that clients mutate between calls in one agent loop.
286
+
287
+ ``cache_control`` is the main offender: clients (notably Claude Code)
288
+ move the cache breakpoint to the newest message on each call, which
289
+ means the exact same user-text message carries ``cache_control`` on
290
+ call 1 and not on call 2. Hashing the raw message dicts therefore
291
+ produces a different turn_id for every iteration of a single agent
292
+ loop, collapsing ``turn_id`` to effectively ``request_id`` and
293
+ breaking prompt-level aggregation downstream.
294
+ """
295
+ if isinstance(obj, dict):
296
+ return {k: _strip_per_call_annotations(v) for k, v in obj.items() if k != "cache_control"}
297
+ if isinstance(obj, list):
298
+ return [_strip_per_call_annotations(item) for item in obj]
299
+ return obj
300
+
301
+
302
+ def compute_turn_id(
303
+ model: str,
304
+ system: Any,
305
+ messages: list[dict[str, Any]] | None,
306
+ ) -> str | None:
307
+ """Group all agent-loop API calls triggered by a single user prompt.
308
+
309
+ A turn spans the user's text prompt plus every assistant tool-use and
310
+ user tool-result message the agent appends while executing that prompt.
311
+ Hashing the prefix up to and including the last user *text* message yields
312
+ an id that is stable across the turn but rolls over when the user sends a
313
+ new prompt.
314
+
315
+ Returns None when no user-text message is present (nothing to identify).
316
+ """
317
+ if not messages:
318
+ return None
319
+
320
+ last_text_user_idx: int | None = None
321
+ for i in range(len(messages) - 1, -1, -1):
322
+ msg = messages[i]
323
+ if not isinstance(msg, dict) or msg.get("role") != "user":
324
+ continue
325
+ content = msg.get("content")
326
+ if isinstance(content, str) and content:
327
+ last_text_user_idx = i
328
+ break
329
+ if isinstance(content, list):
330
+ has_text = any(
331
+ isinstance(block, dict) and block.get("type") == "text" for block in content
332
+ )
333
+ has_tool_result = any(
334
+ isinstance(block, dict) and block.get("type") == "tool_result" for block in content
335
+ )
336
+ # An agent-loop continuation carries tool_result blocks; only a
337
+ # fresh user turn is text-only.
338
+ if has_text and not has_tool_result:
339
+ last_text_user_idx = i
340
+ break
341
+
342
+ if last_text_user_idx is None:
343
+ return None
344
+
345
+ prefix = _strip_per_call_annotations(messages[: last_text_user_idx + 1])
346
+ try:
347
+ prefix_json = json.dumps(prefix, sort_keys=True, default=str)
348
+ except (TypeError, ValueError):
349
+ return None
350
+
351
+ h = hashlib.sha256()
352
+ h.update(model.encode("utf-8", errors="replace"))
353
+ h.update(b"\0")
354
+ if isinstance(system, str):
355
+ h.update(system.encode("utf-8", errors="replace"))
356
+ elif system is not None:
357
+ try:
358
+ normalized_system = _strip_per_call_annotations(system)
359
+ h.update(json.dumps(normalized_system, sort_keys=True, default=str).encode("utf-8"))
360
+ except (TypeError, ValueError):
361
+ pass
362
+ h.update(b"\0")
363
+ h.update(prefix_json.encode("utf-8", errors="replace"))
364
+ return h.hexdigest()[:16]
headroom/proxy/models.py CHANGED
@@ -50,6 +50,11 @@ class RequestLog:
50
  response_content: str | None = None
51
  error: str | None = None
52
 
 
 
 
 
 
53
  # NOTE (Unit 2 follow-up): stage timings and session_id were briefly
54
  # added here but are now emitted exclusively through
55
  # ``emit_stage_timings_log`` (structured log line) and Prometheus.
 
50
  response_content: str | None = None
51
  error: str | None = None
52
 
53
+ # Groups every agent-loop API call from one user prompt into a single turn.
54
+ # See ``headroom.proxy.helpers.compute_turn_id`` for the derivation. None
55
+ # when no user-text message is present in the request.
56
+ turn_id: str | None = None
57
+
58
  # NOTE (Unit 2 follow-up): stage timings and session_id were briefly
59
  # added here but are now emitted exclusively through
60
  # ``emit_stage_timings_log`` (structured log line) and Prometheus.
headroom/proxy/server.py CHANGED
The diff for this file is too large to render. See raw diff
 
headroom/release_version.py CHANGED
@@ -1,310 +1,310 @@
1
- """Release version helpers for the GitHub Actions release workflow."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- import subprocess
8
- from collections.abc import Sequence
9
- from dataclasses import dataclass, replace
10
- from pathlib import Path
11
-
12
- SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
13
- RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$")
14
- CONVENTIONAL_COMMIT_RE = re.compile(
15
- r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$"
16
- )
17
- BREAKING_CHANGE_RE = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE)
18
- FIELD_SEP = "\x1f"
19
- RECORD_SEP = "\x1e"
20
- GIT_LOG_FORMAT = "%s%x1f%b%x1e"
21
- BUMP_PRIORITY = {"patch": 0, "minor": 1, "major": 2}
22
-
23
-
24
- @dataclass(frozen=True, order=True)
25
- class SemVer:
26
- """Semantic version tuple with simple bump helpers."""
27
-
28
- major: int
29
- minor: int
30
- patch: int
31
-
32
- @classmethod
33
- def parse(cls, value: str) -> SemVer:
34
- match = SEMVER_RE.match(value)
35
- if not match:
36
- raise ValueError(f"Invalid semantic version: {value}")
37
- return cls(*(int(part) for part in match.groups()))
38
-
39
- def bump(self, level: str) -> SemVer:
40
- if level == "major":
41
- return SemVer(self.major + 1, 0, 0)
42
- if level == "minor":
43
- return SemVer(self.major, self.minor + 1, 0)
44
- if level == "patch":
45
- return SemVer(self.major, self.minor, self.patch + 1)
46
- raise ValueError(f"Unsupported bump level: {level}")
47
-
48
- def __str__(self) -> str:
49
- return f"{self.major}.{self.minor}.{self.patch}"
50
-
51
-
52
- @dataclass(frozen=True)
53
- class ReleaseVersionInfo:
54
- """Workflow outputs for release version calculation."""
55
-
56
- version: str
57
- npm_version: str
58
- canonical: str
59
- height: str
60
- bump: str
61
- previous_tag: str
62
-
63
- def as_outputs(self) -> dict[str, str]:
64
- return {
65
- "version": self.version,
66
- "npm_version": self.npm_version,
67
- "canonical": self.canonical,
68
- "height": self.height,
69
- "bump": self.bump,
70
- "previous_tag": self.previous_tag,
71
- }
72
-
73
-
74
- @dataclass(frozen=True, order=True)
75
- class ReleaseTag:
76
- """Parsed release tag metadata used for sorting and normalization."""
77
-
78
- version: SemVer
79
- legacy_height: int = -1
80
- raw: str = ""
81
-
82
-
83
- @dataclass(frozen=True)
84
- class CommitInfo:
85
- """Commit subject/body pair used for bump detection."""
86
-
87
- subject: str
88
- body: str = ""
89
-
90
-
91
- def parse_release_tag(tag: str) -> ReleaseTag:
92
- """Parse a release tag, preserving legacy fourth-component ordering."""
93
-
94
- match = RELEASE_TAG_RE.match(tag)
95
- if not match:
96
- raise ValueError(f"Invalid release tag: {tag}")
97
- major, minor, patch, extra = match.groups()
98
- return ReleaseTag(
99
- version=SemVer(int(major), int(minor), int(patch)),
100
- legacy_height=int(extra) if extra is not None else -1,
101
- raw=tag,
102
- )
103
-
104
-
105
- def normalize_release_tag(tag: str) -> SemVer:
106
- """Collapse historic 4-part release tags into their base semantic version."""
107
-
108
- return parse_release_tag(tag).version
109
-
110
-
111
- def find_latest_release_tag(tags: Sequence[str]) -> str | None:
112
- """Return the latest release tag after normalizing legacy 4-part tags."""
113
-
114
- candidates: list[ReleaseTag] = []
115
- for tag in tags:
116
- if RELEASE_TAG_RE.match(tag):
117
- candidates.append(parse_release_tag(tag))
118
- if not candidates:
119
- return None
120
- candidates.sort(reverse=True)
121
- return candidates[0].raw
122
-
123
-
124
- def _merge_summary(subject: str, body: str) -> str:
125
- """Return the first meaningful body line for merge commits."""
126
-
127
- if not subject.startswith("Merge "):
128
- return ""
129
- for line in body.splitlines():
130
- stripped = line.strip()
131
- if stripped:
132
- return stripped
133
- return ""
134
-
135
-
136
- def classify_commit_bump(commit: CommitInfo) -> str:
137
- """Classify one commit using conventional commit semantics."""
138
-
139
- merge_summary = _merge_summary(commit.subject, commit.body)
140
- candidates = [commit.subject]
141
- if merge_summary:
142
- candidates.insert(0, merge_summary)
143
-
144
- has_breaking_change = bool(BREAKING_CHANGE_RE.search(commit.body))
145
- for candidate in candidates:
146
- match = CONVENTIONAL_COMMIT_RE.match(candidate)
147
- if not match:
148
- continue
149
- if has_breaking_change or bool(match.group(3)):
150
- return "major"
151
- if match.group(1) == "feat":
152
- return "minor"
153
- return "patch"
154
-
155
- if has_breaking_change:
156
- return "major"
157
- return "patch"
158
-
159
-
160
- def determine_bump_level(commits: Sequence[CommitInfo]) -> str:
161
- """Return the highest required bump across a commit range."""
162
-
163
- level = "patch"
164
- for commit in commits:
165
- candidate = classify_commit_bump(commit)
166
- if BUMP_PRIORITY[candidate] > BUMP_PRIORITY[level]:
167
- level = candidate
168
- return level
169
-
170
-
171
- def compute_release_version(
172
- canonical_version: str,
173
- level: str,
174
- tags: Sequence[str],
175
- manual_version: str = "",
176
- ) -> ReleaseVersionInfo:
177
- """Compute the next release version from the canonical version and existing tags."""
178
-
179
- if manual_version:
180
- manual = str(SemVer.parse(manual_version))
181
- return ReleaseVersionInfo(
182
- version=manual,
183
- npm_version=manual,
184
- canonical=canonical_version,
185
- height="0",
186
- bump="manual",
187
- previous_tag="",
188
- )
189
-
190
- canonical = SemVer.parse(canonical_version)
191
- previous_tag = find_latest_release_tag(tags)
192
- current = canonical
193
- if previous_tag is not None:
194
- current = max(current, normalize_release_tag(previous_tag))
195
-
196
- next_version = str(current.bump(level))
197
- return ReleaseVersionInfo(
198
- version=next_version,
199
- npm_version=next_version,
200
- canonical=canonical_version,
201
- height="0",
202
- bump=level,
203
- previous_tag=previous_tag or "",
204
- )
205
-
206
-
207
- def get_canonical_version(root: Path) -> str:
208
- """Read the canonical project version from pyproject.toml."""
209
-
210
- try:
211
- import tomllib
212
- except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
213
- import tomli as tomllib
214
-
215
- with open(root / "pyproject.toml", "rb") as file:
216
- project = tomllib.load(file)["project"]
217
- return str(project["version"])
218
-
219
-
220
- def list_release_tags(root: Path) -> list[str]:
221
- """List release tags from the local Git checkout."""
222
-
223
- result = subprocess.run(
224
- ["git", "tag", "-l", "v*"],
225
- cwd=root,
226
- check=True,
227
- capture_output=True,
228
- text=True,
229
- )
230
- return [tag.strip() for tag in result.stdout.splitlines() if tag.strip()]
231
-
232
-
233
- def list_release_commits(root: Path, previous_tag: str) -> list[CommitInfo]:
234
- """List commit subject/body pairs since the previous release tag."""
235
-
236
- cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"]
237
- if previous_tag:
238
- cmd.append(f"{previous_tag}..HEAD")
239
- else:
240
- cmd.append("HEAD")
241
-
242
- result = subprocess.run(
243
- cmd,
244
- cwd=root,
245
- check=True,
246
- capture_output=True,
247
- text=True,
248
- )
249
-
250
- commits: list[CommitInfo] = []
251
- for raw_entry in result.stdout.split(RECORD_SEP):
252
- if not raw_entry or FIELD_SEP not in raw_entry:
253
- continue
254
- subject, body = raw_entry.split(FIELD_SEP, 1)
255
- commits.append(CommitInfo(subject=subject.strip(), body=body.strip()))
256
- return commits
257
-
258
-
259
- def commit_height_since(root: Path, previous_tag: str) -> str:
260
- """Count commits since the previous release tag for changelog/debug outputs."""
261
-
262
- if not previous_tag:
263
- return "0"
264
-
265
- result = subprocess.run(
266
- ["git", "rev-list", f"{previous_tag}..HEAD", "--count"],
267
- cwd=root,
268
- check=True,
269
- capture_output=True,
270
- text=True,
271
- )
272
- return result.stdout.strip() or "0"
273
-
274
-
275
- def write_github_outputs(info: ReleaseVersionInfo, output_path: str) -> None:
276
- """Append workflow outputs to the GitHub Actions output file."""
277
-
278
- with open(output_path, "a", encoding="utf-8") as output_file:
279
- for key, value in info.as_outputs().items():
280
- output_file.write(f"{key}={value}\n")
281
-
282
-
283
- def main() -> None:
284
- root = Path.cwd()
285
- manual_version = os.environ.get("MANUAL_VER", "").strip()
286
- tags = list_release_tags(root)
287
- previous_tag = find_latest_release_tag(tags) or ""
288
- level = os.environ.get("LEVEL", "").strip()
289
- if not level:
290
- level = determine_bump_level(list_release_commits(root, previous_tag))
291
-
292
- info = compute_release_version(
293
- canonical_version=get_canonical_version(root),
294
- level=level,
295
- tags=tags,
296
- manual_version=manual_version,
297
- )
298
- info = replace(info, height=commit_height_since(root, info.previous_tag))
299
-
300
- output_path = os.environ.get("GITHUB_OUTPUT", "").strip()
301
- if output_path:
302
- write_github_outputs(info, output_path)
303
- return
304
-
305
- for key, value in info.as_outputs().items():
306
- print(f"{key}={value}")
307
-
308
-
309
- if __name__ == "__main__":
310
- main()
 
1
+ """Release version helpers for the GitHub Actions release workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+
12
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
13
+ RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$")
14
+ CONVENTIONAL_COMMIT_RE = re.compile(
15
+ r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$"
16
+ )
17
+ BREAKING_CHANGE_RE = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE)
18
+ FIELD_SEP = "\x1f"
19
+ RECORD_SEP = "\x1e"
20
+ GIT_LOG_FORMAT = "%s%x1f%b%x1e"
21
+ BUMP_PRIORITY = {"patch": 0, "minor": 1, "major": 2}
22
+
23
+
24
+ @dataclass(frozen=True, order=True)
25
+ class SemVer:
26
+ """Semantic version tuple with simple bump helpers."""
27
+
28
+ major: int
29
+ minor: int
30
+ patch: int
31
+
32
+ @classmethod
33
+ def parse(cls, value: str) -> SemVer:
34
+ match = SEMVER_RE.match(value)
35
+ if not match:
36
+ raise ValueError(f"Invalid semantic version: {value}")
37
+ return cls(*(int(part) for part in match.groups()))
38
+
39
+ def bump(self, level: str) -> SemVer:
40
+ if level == "major":
41
+ return SemVer(self.major + 1, 0, 0)
42
+ if level == "minor":
43
+ return SemVer(self.major, self.minor + 1, 0)
44
+ if level == "patch":
45
+ return SemVer(self.major, self.minor, self.patch + 1)
46
+ raise ValueError(f"Unsupported bump level: {level}")
47
+
48
+ def __str__(self) -> str:
49
+ return f"{self.major}.{self.minor}.{self.patch}"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ReleaseVersionInfo:
54
+ """Workflow outputs for release version calculation."""
55
+
56
+ version: str
57
+ npm_version: str
58
+ canonical: str
59
+ height: str
60
+ bump: str
61
+ previous_tag: str
62
+
63
+ def as_outputs(self) -> dict[str, str]:
64
+ return {
65
+ "version": self.version,
66
+ "npm_version": self.npm_version,
67
+ "canonical": self.canonical,
68
+ "height": self.height,
69
+ "bump": self.bump,
70
+ "previous_tag": self.previous_tag,
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True, order=True)
75
+ class ReleaseTag:
76
+ """Parsed release tag metadata used for sorting and normalization."""
77
+
78
+ version: SemVer
79
+ legacy_height: int = -1
80
+ raw: str = ""
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class CommitInfo:
85
+ """Commit subject/body pair used for bump detection."""
86
+
87
+ subject: str
88
+ body: str = ""
89
+
90
+
91
+ def parse_release_tag(tag: str) -> ReleaseTag:
92
+ """Parse a release tag, preserving legacy fourth-component ordering."""
93
+
94
+ match = RELEASE_TAG_RE.match(tag)
95
+ if not match:
96
+ raise ValueError(f"Invalid release tag: {tag}")
97
+ major, minor, patch, extra = match.groups()
98
+ return ReleaseTag(
99
+ version=SemVer(int(major), int(minor), int(patch)),
100
+ legacy_height=int(extra) if extra is not None else -1,
101
+ raw=tag,
102
+ )
103
+
104
+
105
+ def normalize_release_tag(tag: str) -> SemVer:
106
+ """Collapse historic 4-part release tags into their base semantic version."""
107
+
108
+ return parse_release_tag(tag).version
109
+
110
+
111
+ def find_latest_release_tag(tags: Sequence[str]) -> str | None:
112
+ """Return the latest release tag after normalizing legacy 4-part tags."""
113
+
114
+ candidates: list[ReleaseTag] = []
115
+ for tag in tags:
116
+ if RELEASE_TAG_RE.match(tag):
117
+ candidates.append(parse_release_tag(tag))
118
+ if not candidates:
119
+ return None
120
+ candidates.sort(reverse=True)
121
+ return candidates[0].raw
122
+
123
+
124
+ def _merge_summary(subject: str, body: str) -> str:
125
+ """Return the first meaningful body line for merge commits."""
126
+
127
+ if not subject.startswith("Merge "):
128
+ return ""
129
+ for line in body.splitlines():
130
+ stripped = line.strip()
131
+ if stripped:
132
+ return stripped
133
+ return ""
134
+
135
+
136
+ def classify_commit_bump(commit: CommitInfo) -> str:
137
+ """Classify one commit using conventional commit semantics."""
138
+
139
+ merge_summary = _merge_summary(commit.subject, commit.body)
140
+ candidates = [commit.subject]
141
+ if merge_summary:
142
+ candidates.insert(0, merge_summary)
143
+
144
+ has_breaking_change = bool(BREAKING_CHANGE_RE.search(commit.body))
145
+ for candidate in candidates:
146
+ match = CONVENTIONAL_COMMIT_RE.match(candidate)
147
+ if not match:
148
+ continue
149
+ if has_breaking_change or bool(match.group(3)):
150
+ return "major"
151
+ if match.group(1) == "feat":
152
+ return "minor"
153
+ return "patch"
154
+
155
+ if has_breaking_change:
156
+ return "major"
157
+ return "patch"
158
+
159
+
160
+ def determine_bump_level(commits: Sequence[CommitInfo]) -> str:
161
+ """Return the highest required bump across a commit range."""
162
+
163
+ level = "patch"
164
+ for commit in commits:
165
+ candidate = classify_commit_bump(commit)
166
+ if BUMP_PRIORITY[candidate] > BUMP_PRIORITY[level]:
167
+ level = candidate
168
+ return level
169
+
170
+
171
+ def compute_release_version(
172
+ canonical_version: str,
173
+ level: str,
174
+ tags: Sequence[str],
175
+ manual_version: str = "",
176
+ ) -> ReleaseVersionInfo:
177
+ """Compute the next release version from the canonical version and existing tags."""
178
+
179
+ if manual_version:
180
+ manual = str(SemVer.parse(manual_version))
181
+ return ReleaseVersionInfo(
182
+ version=manual,
183
+ npm_version=manual,
184
+ canonical=canonical_version,
185
+ height="0",
186
+ bump="manual",
187
+ previous_tag="",
188
+ )
189
+
190
+ canonical = SemVer.parse(canonical_version)
191
+ previous_tag = find_latest_release_tag(tags)
192
+ current = canonical
193
+ if previous_tag is not None:
194
+ current = max(current, normalize_release_tag(previous_tag))
195
+
196
+ next_version = str(current.bump(level))
197
+ return ReleaseVersionInfo(
198
+ version=next_version,
199
+ npm_version=next_version,
200
+ canonical=canonical_version,
201
+ height="0",
202
+ bump=level,
203
+ previous_tag=previous_tag or "",
204
+ )
205
+
206
+
207
+ def get_canonical_version(root: Path) -> str:
208
+ """Read the canonical project version from pyproject.toml."""
209
+
210
+ try:
211
+ import tomllib
212
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
213
+ import tomli as tomllib
214
+
215
+ with open(root / "pyproject.toml", "rb") as file:
216
+ project = tomllib.load(file)["project"]
217
+ return str(project["version"])
218
+
219
+
220
+ def list_release_tags(root: Path) -> list[str]:
221
+ """List release tags from the local Git checkout."""
222
+
223
+ result = subprocess.run(
224
+ ["git", "tag", "-l", "v*"],
225
+ cwd=root,
226
+ check=True,
227
+ capture_output=True,
228
+ text=True,
229
+ )
230
+ return [tag.strip() for tag in result.stdout.splitlines() if tag.strip()]
231
+
232
+
233
+ def list_release_commits(root: Path, previous_tag: str) -> list[CommitInfo]:
234
+ """List commit subject/body pairs since the previous release tag."""
235
+
236
+ cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"]
237
+ if previous_tag:
238
+ cmd.append(f"{previous_tag}..HEAD")
239
+ else:
240
+ cmd.append("HEAD")
241
+
242
+ result = subprocess.run(
243
+ cmd,
244
+ cwd=root,
245
+ check=True,
246
+ capture_output=True,
247
+ text=True,
248
+ )
249
+
250
+ commits: list[CommitInfo] = []
251
+ for raw_entry in result.stdout.split(RECORD_SEP):
252
+ if not raw_entry or FIELD_SEP not in raw_entry:
253
+ continue
254
+ subject, body = raw_entry.split(FIELD_SEP, 1)
255
+ commits.append(CommitInfo(subject=subject.strip(), body=body.strip()))
256
+ return commits
257
+
258
+
259
+ def commit_height_since(root: Path, previous_tag: str) -> str:
260
+ """Count commits since the previous release tag for changelog/debug outputs."""
261
+
262
+ if not previous_tag:
263
+ return "0"
264
+
265
+ result = subprocess.run(
266
+ ["git", "rev-list", f"{previous_tag}..HEAD", "--count"],
267
+ cwd=root,
268
+ check=True,
269
+ capture_output=True,
270
+ text=True,
271
+ )
272
+ return result.stdout.strip() or "0"
273
+
274
+
275
+ def write_github_outputs(info: ReleaseVersionInfo, output_path: str) -> None:
276
+ """Append workflow outputs to the GitHub Actions output file."""
277
+
278
+ with open(output_path, "a", encoding="utf-8") as output_file:
279
+ for key, value in info.as_outputs().items():
280
+ output_file.write(f"{key}={value}\n")
281
+
282
+
283
+ def main() -> None:
284
+ root = Path.cwd()
285
+ manual_version = os.environ.get("MANUAL_VER", "").strip()
286
+ tags = list_release_tags(root)
287
+ previous_tag = find_latest_release_tag(tags) or ""
288
+ level = os.environ.get("LEVEL", "").strip()
289
+ if not level:
290
+ level = determine_bump_level(list_release_commits(root, previous_tag))
291
+
292
+ info = compute_release_version(
293
+ canonical_version=get_canonical_version(root),
294
+ level=level,
295
+ tags=tags,
296
+ manual_version=manual_version,
297
+ )
298
+ info = replace(info, height=commit_height_since(root, info.previous_tag))
299
+
300
+ output_path = os.environ.get("GITHUB_OUTPUT", "").strip()
301
+ if output_path:
302
+ write_github_outputs(info, output_path)
303
+ return
304
+
305
+ for key, value in info.as_outputs().items():
306
+ print(f"{key}={value}")
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
headroom/subscription/__init__.py CHANGED
@@ -1,72 +1,72 @@
1
- """Subscription window tracking for Anthropic Claude Code accounts and Codex rate limits."""
2
-
3
- from headroom.subscription.base import (
4
- QuotaTracker,
5
- QuotaTrackerRegistry,
6
- get_quota_registry,
7
- reset_quota_registry,
8
- )
9
- from headroom.subscription.client import SubscriptionClient, read_cached_oauth_token
10
- from headroom.subscription.codex_rate_limits import (
11
- CodexCreditsSnapshot,
12
- CodexRateLimitSnapshot,
13
- CodexRateLimitState,
14
- CodexRateLimitWindow,
15
- get_codex_rate_limit_state,
16
- parse_codex_rate_limits,
17
- )
18
- from headroom.subscription.copilot_quota import (
19
- CopilotQuotaCategory,
20
- CopilotQuotaSnapshot,
21
- CopilotQuotaState,
22
- discover_github_token,
23
- get_copilot_quota_tracker,
24
- parse_copilot_quota,
25
- )
26
- from headroom.subscription.models import (
27
- ExtraUsage,
28
- HeadroomContribution,
29
- RateLimitWindow,
30
- SubscriptionSnapshot,
31
- SubscriptionState,
32
- WindowDiscrepancy,
33
- WindowTokens,
34
- )
35
- from headroom.subscription.tracker import (
36
- SubscriptionTracker,
37
- configure_subscription_tracker,
38
- get_subscription_tracker,
39
- shutdown_subscription_tracker,
40
- )
41
-
42
- __all__ = [
43
- "CodexCreditsSnapshot",
44
- "CodexRateLimitSnapshot",
45
- "CodexRateLimitState",
46
- "CodexRateLimitWindow",
47
- "CopilotQuotaCategory",
48
- "CopilotQuotaSnapshot",
49
- "CopilotQuotaState",
50
- "ExtraUsage",
51
- "HeadroomContribution",
52
- "QuotaTracker",
53
- "QuotaTrackerRegistry",
54
- "RateLimitWindow",
55
- "SubscriptionClient",
56
- "SubscriptionSnapshot",
57
- "SubscriptionState",
58
- "SubscriptionTracker",
59
- "WindowDiscrepancy",
60
- "WindowTokens",
61
- "configure_subscription_tracker",
62
- "discover_github_token",
63
- "get_codex_rate_limit_state",
64
- "get_copilot_quota_tracker",
65
- "get_quota_registry",
66
- "get_subscription_tracker",
67
- "parse_codex_rate_limits",
68
- "parse_copilot_quota",
69
- "read_cached_oauth_token",
70
- "reset_quota_registry",
71
- "shutdown_subscription_tracker",
72
- ]
 
1
+ """Subscription window tracking for Anthropic Claude Code accounts and Codex rate limits."""
2
+
3
+ from headroom.subscription.base import (
4
+ QuotaTracker,
5
+ QuotaTrackerRegistry,
6
+ get_quota_registry,
7
+ reset_quota_registry,
8
+ )
9
+ from headroom.subscription.client import SubscriptionClient, read_cached_oauth_token
10
+ from headroom.subscription.codex_rate_limits import (
11
+ CodexCreditsSnapshot,
12
+ CodexRateLimitSnapshot,
13
+ CodexRateLimitState,
14
+ CodexRateLimitWindow,
15
+ get_codex_rate_limit_state,
16
+ parse_codex_rate_limits,
17
+ )
18
+ from headroom.subscription.copilot_quota import (
19
+ CopilotQuotaCategory,
20
+ CopilotQuotaSnapshot,
21
+ CopilotQuotaState,
22
+ discover_github_token,
23
+ get_copilot_quota_tracker,
24
+ parse_copilot_quota,
25
+ )
26
+ from headroom.subscription.models import (
27
+ ExtraUsage,
28
+ HeadroomContribution,
29
+ RateLimitWindow,
30
+ SubscriptionSnapshot,
31
+ SubscriptionState,
32
+ WindowDiscrepancy,
33
+ WindowTokens,
34
+ )
35
+ from headroom.subscription.tracker import (
36
+ SubscriptionTracker,
37
+ configure_subscription_tracker,
38
+ get_subscription_tracker,
39
+ shutdown_subscription_tracker,
40
+ )
41
+
42
+ __all__ = [
43
+ "CodexCreditsSnapshot",
44
+ "CodexRateLimitSnapshot",
45
+ "CodexRateLimitState",
46
+ "CodexRateLimitWindow",
47
+ "CopilotQuotaCategory",
48
+ "CopilotQuotaSnapshot",
49
+ "CopilotQuotaState",
50
+ "ExtraUsage",
51
+ "HeadroomContribution",
52
+ "QuotaTracker",
53
+ "QuotaTrackerRegistry",
54
+ "RateLimitWindow",
55
+ "SubscriptionClient",
56
+ "SubscriptionSnapshot",
57
+ "SubscriptionState",
58
+ "SubscriptionTracker",
59
+ "WindowDiscrepancy",
60
+ "WindowTokens",
61
+ "configure_subscription_tracker",
62
+ "discover_github_token",
63
+ "get_codex_rate_limit_state",
64
+ "get_copilot_quota_tracker",
65
+ "get_quota_registry",
66
+ "get_subscription_tracker",
67
+ "parse_codex_rate_limits",
68
+ "parse_copilot_quota",
69
+ "read_cached_oauth_token",
70
+ "reset_quota_registry",
71
+ "shutdown_subscription_tracker",
72
+ ]
headroom/subscription/base.py CHANGED
@@ -1,230 +1,230 @@
1
- """Base abstractions for pluggable AI-tool quota / rate-limit trackers.
2
-
3
- Every provider tracker (Anthropic, Codex, Copilot, …) inherits from
4
- :class:`QuotaTracker` and is registered with the process-global
5
- :class:`QuotaTrackerRegistry`. ``server.py`` only interacts with the
6
- registry — adding a new provider requires *zero* changes to the server.
7
-
8
- Quick-start for a new provider::
9
-
10
- from headroom.subscription.base import QuotaTracker, get_quota_registry
11
-
12
- class GeminiQuotaTracker(QuotaTracker):
13
- key = "gemini_quota"
14
- label = "Google Gemini"
15
-
16
- def is_available(self) -> bool:
17
- return bool(os.environ.get("GOOGLE_API_KEY"))
18
-
19
- async def start(self) -> None: ... # launch background poll
20
- async def stop(self) -> None: ... # cancel poll task
21
-
22
- def get_stats(self) -> dict | None:
23
- return ... # serialisable dict or None if no data yet
24
-
25
- get_quota_registry().register(GeminiQuotaTracker())
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import abc
31
- import logging
32
- from threading import Lock
33
- from typing import Any
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
-
38
- class QuotaTracker(abc.ABC):
39
- """Abstract base for a single AI-tool quota / rate-limit tracker.
40
-
41
- Subclasses must define :attr:`key`, :attr:`label`, and
42
- :meth:`get_stats`. All other methods have sensible defaults.
43
- """
44
-
45
- # ------------------------------------------------------------------ #
46
- # Class-level identity — subclasses should override as class attributes
47
- # ------------------------------------------------------------------ #
48
-
49
- @property
50
- @abc.abstractmethod
51
- def key(self) -> str:
52
- """Stats key used in ``/stats`` and the dashboard.
53
-
54
- Must be unique across all registered trackers.
55
- Examples: ``"subscription_window"``, ``"codex_rate_limits"``.
56
- """
57
-
58
- @property
59
- @abc.abstractmethod
60
- def label(self) -> str:
61
- """Human-readable name for log messages.
62
-
63
- Example: ``"Anthropic Claude Code"``.
64
- """
65
-
66
- # ------------------------------------------------------------------ #
67
- # Availability gate
68
- # ------------------------------------------------------------------ #
69
-
70
- def is_available(self) -> bool:
71
- """Return ``True`` if this tracker should be activated.
72
-
73
- Override to gate on environment variables, config flags, etc.
74
- The registry calls this before :meth:`start` and skips trackers
75
- that return ``False``. Default: always available.
76
- """
77
- return True
78
-
79
- # ------------------------------------------------------------------ #
80
- # Lifecycle — default no-ops (suitable for passive/header-based trackers)
81
- # ------------------------------------------------------------------ #
82
-
83
- async def start(self) -> None: # noqa: B027
84
- """Start background polling. No-op for passive trackers."""
85
-
86
- async def stop(self) -> None: # noqa: B027
87
- """Stop background polling. No-op for passive trackers."""
88
-
89
- # ------------------------------------------------------------------ #
90
- # Stats
91
- # ------------------------------------------------------------------ #
92
-
93
- @abc.abstractmethod
94
- def get_stats(self) -> dict[str, Any] | None:
95
- """Return the current snapshot as a serialisable dict, or ``None``.
96
-
97
- ``None`` means "no data yet" and causes the key to be omitted from
98
- ``/stats`` rather than appearing as ``null``.
99
- """
100
-
101
-
102
- # --------------------------------------------------------------------------- #
103
- # Registry
104
- # --------------------------------------------------------------------------- #
105
-
106
-
107
- class QuotaTrackerRegistry:
108
- """Process-global registry of all :class:`QuotaTracker` instances.
109
-
110
- Typical usage::
111
-
112
- registry = get_quota_registry()
113
- registry.register(SubscriptionTracker(...))
114
- registry.register(get_codex_rate_limit_state())
115
- registry.register(get_copilot_quota_tracker())
116
-
117
- # server startup
118
- await registry.start_all()
119
-
120
- # /stats assembly
121
- stats.update(registry.get_all_stats())
122
-
123
- # server shutdown
124
- await registry.stop_all()
125
- """
126
-
127
- def __init__(self) -> None:
128
- self._trackers: list[QuotaTracker] = []
129
- self._lock = Lock()
130
-
131
- # ------------------------------------------------------------------ #
132
- # Registration
133
- # ------------------------------------------------------------------ #
134
-
135
- def register(self, tracker: QuotaTracker) -> None:
136
- """Register a tracker. Duplicate keys are rejected."""
137
- with self._lock:
138
- existing_keys = {t.key for t in self._trackers}
139
- if tracker.key in existing_keys:
140
- raise ValueError(
141
- f"A tracker with key '{tracker.key}' is already registered. "
142
- "Each tracker must have a unique key."
143
- )
144
- self._trackers.append(tracker)
145
-
146
- def get(self, key: str) -> QuotaTracker | None:
147
- """Return the registered tracker for *key*, or ``None``."""
148
- with self._lock:
149
- for t in self._trackers:
150
- if t.key == key:
151
- return t
152
- return None
153
-
154
- @property
155
- def trackers(self) -> list[QuotaTracker]:
156
- """Read-only snapshot of the registered tracker list."""
157
- with self._lock:
158
- return list(self._trackers)
159
-
160
- # ------------------------------------------------------------------ #
161
- # Lifecycle
162
- # ------------------------------------------------------------------ #
163
-
164
- async def start_all(self) -> None:
165
- """Start every available tracker and log its status."""
166
- for tracker in self.trackers:
167
- if tracker.is_available():
168
- await tracker.start()
169
- logger.info("%s quota tracking: ENABLED", tracker.label)
170
- else:
171
- logger.info("%s quota tracking: DISABLED (not available)", tracker.label)
172
-
173
- async def stop_all(self) -> None:
174
- """Stop all registered trackers (regardless of availability)."""
175
- for tracker in self.trackers:
176
- try:
177
- await tracker.stop()
178
- except Exception as exc: # noqa: BLE001
179
- logger.warning("Error stopping %s tracker: %s", tracker.label, exc)
180
-
181
- # ------------------------------------------------------------------ #
182
- # Stats
183
- # ------------------------------------------------------------------ #
184
-
185
- def get_all_stats(self) -> dict[str, dict[str, Any] | None]:
186
- """Return ``{key: stats_dict}`` for every available tracker.
187
-
188
- Trackers that are unavailable or return ``None`` are excluded.
189
- """
190
- result: dict[str, dict[str, Any] | None] = {}
191
- for tracker in self.trackers:
192
- if not tracker.is_available():
193
- continue
194
- stats = tracker.get_stats()
195
- if stats is not None:
196
- result[tracker.key] = stats
197
- return result
198
-
199
- def get_stats(self, key: str) -> dict[str, Any] | None:
200
- """Return stats for a single tracker by key, or ``None``."""
201
- tracker = self.get(key)
202
- return tracker.get_stats() if tracker is not None else None
203
-
204
-
205
- # --------------------------------------------------------------------------- #
206
- # Process-global singleton
207
- # --------------------------------------------------------------------------- #
208
-
209
- _registry: QuotaTrackerRegistry | None = None
210
- _registry_lock = Lock()
211
-
212
-
213
- def get_quota_registry() -> QuotaTrackerRegistry:
214
- """Return the process-global :class:`QuotaTrackerRegistry` singleton."""
215
- global _registry
216
- if _registry is None:
217
- with _registry_lock:
218
- if _registry is None:
219
- _registry = QuotaTrackerRegistry()
220
- return _registry
221
-
222
-
223
- def reset_quota_registry() -> None:
224
- """Replace the global registry with a fresh empty instance.
225
-
226
- Intended for use in tests only.
227
- """
228
- global _registry
229
- with _registry_lock:
230
- _registry = QuotaTrackerRegistry()
 
1
+ """Base abstractions for pluggable AI-tool quota / rate-limit trackers.
2
+
3
+ Every provider tracker (Anthropic, Codex, Copilot, …) inherits from
4
+ :class:`QuotaTracker` and is registered with the process-global
5
+ :class:`QuotaTrackerRegistry`. ``server.py`` only interacts with the
6
+ registry — adding a new provider requires *zero* changes to the server.
7
+
8
+ Quick-start for a new provider::
9
+
10
+ from headroom.subscription.base import QuotaTracker, get_quota_registry
11
+
12
+ class GeminiQuotaTracker(QuotaTracker):
13
+ key = "gemini_quota"
14
+ label = "Google Gemini"
15
+
16
+ def is_available(self) -> bool:
17
+ return bool(os.environ.get("GOOGLE_API_KEY"))
18
+
19
+ async def start(self) -> None: ... # launch background poll
20
+ async def stop(self) -> None: ... # cancel poll task
21
+
22
+ def get_stats(self) -> dict | None:
23
+ return ... # serialisable dict or None if no data yet
24
+
25
+ get_quota_registry().register(GeminiQuotaTracker())
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import abc
31
+ import logging
32
+ from threading import Lock
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class QuotaTracker(abc.ABC):
39
+ """Abstract base for a single AI-tool quota / rate-limit tracker.
40
+
41
+ Subclasses must define :attr:`key`, :attr:`label`, and
42
+ :meth:`get_stats`. All other methods have sensible defaults.
43
+ """
44
+
45
+ # ------------------------------------------------------------------ #
46
+ # Class-level identity — subclasses should override as class attributes
47
+ # ------------------------------------------------------------------ #
48
+
49
+ @property
50
+ @abc.abstractmethod
51
+ def key(self) -> str:
52
+ """Stats key used in ``/stats`` and the dashboard.
53
+
54
+ Must be unique across all registered trackers.
55
+ Examples: ``"subscription_window"``, ``"codex_rate_limits"``.
56
+ """
57
+
58
+ @property
59
+ @abc.abstractmethod
60
+ def label(self) -> str:
61
+ """Human-readable name for log messages.
62
+
63
+ Example: ``"Anthropic Claude Code"``.
64
+ """
65
+
66
+ # ------------------------------------------------------------------ #
67
+ # Availability gate
68
+ # ------------------------------------------------------------------ #
69
+
70
+ def is_available(self) -> bool:
71
+ """Return ``True`` if this tracker should be activated.
72
+
73
+ Override to gate on environment variables, config flags, etc.
74
+ The registry calls this before :meth:`start` and skips trackers
75
+ that return ``False``. Default: always available.
76
+ """
77
+ return True
78
+
79
+ # ------------------------------------------------------------------ #
80
+ # Lifecycle — default no-ops (suitable for passive/header-based trackers)
81
+ # ------------------------------------------------------------------ #
82
+
83
+ async def start(self) -> None: # noqa: B027
84
+ """Start background polling. No-op for passive trackers."""
85
+
86
+ async def stop(self) -> None: # noqa: B027
87
+ """Stop background polling. No-op for passive trackers."""
88
+
89
+ # ------------------------------------------------------------------ #
90
+ # Stats
91
+ # ------------------------------------------------------------------ #
92
+
93
+ @abc.abstractmethod
94
+ def get_stats(self) -> dict[str, Any] | None:
95
+ """Return the current snapshot as a serialisable dict, or ``None``.
96
+
97
+ ``None`` means "no data yet" and causes the key to be omitted from
98
+ ``/stats`` rather than appearing as ``null``.
99
+ """
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Registry
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ class QuotaTrackerRegistry:
108
+ """Process-global registry of all :class:`QuotaTracker` instances.
109
+
110
+ Typical usage::
111
+
112
+ registry = get_quota_registry()
113
+ registry.register(SubscriptionTracker(...))
114
+ registry.register(get_codex_rate_limit_state())
115
+ registry.register(get_copilot_quota_tracker())
116
+
117
+ # server startup
118
+ await registry.start_all()
119
+
120
+ # /stats assembly
121
+ stats.update(registry.get_all_stats())
122
+
123
+ # server shutdown
124
+ await registry.stop_all()
125
+ """
126
+
127
+ def __init__(self) -> None:
128
+ self._trackers: list[QuotaTracker] = []
129
+ self._lock = Lock()
130
+
131
+ # ------------------------------------------------------------------ #
132
+ # Registration
133
+ # ------------------------------------------------------------------ #
134
+
135
+ def register(self, tracker: QuotaTracker) -> None:
136
+ """Register a tracker. Duplicate keys are rejected."""
137
+ with self._lock:
138
+ existing_keys = {t.key for t in self._trackers}
139
+ if tracker.key in existing_keys:
140
+ raise ValueError(
141
+ f"A tracker with key '{tracker.key}' is already registered. "
142
+ "Each tracker must have a unique key."
143
+ )
144
+ self._trackers.append(tracker)
145
+
146
+ def get(self, key: str) -> QuotaTracker | None:
147
+ """Return the registered tracker for *key*, or ``None``."""
148
+ with self._lock:
149
+ for t in self._trackers:
150
+ if t.key == key:
151
+ return t
152
+ return None
153
+
154
+ @property
155
+ def trackers(self) -> list[QuotaTracker]:
156
+ """Read-only snapshot of the registered tracker list."""
157
+ with self._lock:
158
+ return list(self._trackers)
159
+
160
+ # ------------------------------------------------------------------ #
161
+ # Lifecycle
162
+ # ------------------------------------------------------------------ #
163
+
164
+ async def start_all(self) -> None:
165
+ """Start every available tracker and log its status."""
166
+ for tracker in self.trackers:
167
+ if tracker.is_available():
168
+ await tracker.start()
169
+ logger.info("%s quota tracking: ENABLED", tracker.label)
170
+ else:
171
+ logger.info("%s quota tracking: DISABLED (not available)", tracker.label)
172
+
173
+ async def stop_all(self) -> None:
174
+ """Stop all registered trackers (regardless of availability)."""
175
+ for tracker in self.trackers:
176
+ try:
177
+ await tracker.stop()
178
+ except Exception as exc: # noqa: BLE001
179
+ logger.warning("Error stopping %s tracker: %s", tracker.label, exc)
180
+
181
+ # ------------------------------------------------------------------ #
182
+ # Stats
183
+ # ------------------------------------------------------------------ #
184
+
185
+ def get_all_stats(self) -> dict[str, dict[str, Any] | None]:
186
+ """Return ``{key: stats_dict}`` for every available tracker.
187
+
188
+ Trackers that are unavailable or return ``None`` are excluded.
189
+ """
190
+ result: dict[str, dict[str, Any] | None] = {}
191
+ for tracker in self.trackers:
192
+ if not tracker.is_available():
193
+ continue
194
+ stats = tracker.get_stats()
195
+ if stats is not None:
196
+ result[tracker.key] = stats
197
+ return result
198
+
199
+ def get_stats(self, key: str) -> dict[str, Any] | None:
200
+ """Return stats for a single tracker by key, or ``None``."""
201
+ tracker = self.get(key)
202
+ return tracker.get_stats() if tracker is not None else None
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # Process-global singleton
207
+ # --------------------------------------------------------------------------- #
208
+
209
+ _registry: QuotaTrackerRegistry | None = None
210
+ _registry_lock = Lock()
211
+
212
+
213
+ def get_quota_registry() -> QuotaTrackerRegistry:
214
+ """Return the process-global :class:`QuotaTrackerRegistry` singleton."""
215
+ global _registry
216
+ if _registry is None:
217
+ with _registry_lock:
218
+ if _registry is None:
219
+ _registry = QuotaTrackerRegistry()
220
+ return _registry
221
+
222
+
223
+ def reset_quota_registry() -> None:
224
+ """Replace the global registry with a fresh empty instance.
225
+
226
+ Intended for use in tests only.
227
+ """
228
+ global _registry
229
+ with _registry_lock:
230
+ _registry = QuotaTrackerRegistry()