Spaces:
Running
Running
Merge remote-tracking branch 'origin/main' into rust-rewrite
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .git-blame-ignore-revs +10 -0
- .gitattributes +1 -0
- .github/actions/headroom-e2e-setup/action.yml +68 -0
- .github/workflows/init-native-e2e.yml +132 -0
- CHANGELOG.md +50 -0
- README.md +1 -0
- docs/package-lock.json +1453 -153
- docs/package.json +2 -2
- e2e/__init__.py +7 -0
- e2e/_lib/__init__.py +35 -0
- e2e/_lib/assertions.py +43 -0
- e2e/_lib/harness.py +336 -0
- e2e/_lib/make_shim.ps1 +24 -0
- e2e/_lib/make_shim.sh +28 -0
- e2e/_lib/path_env.py +54 -0
- e2e/_lib/paths.py +47 -0
- e2e/_lib/shims.py +96 -0
- e2e/init/Dockerfile +6 -1
- e2e/init/run.py +336 -236
- headroom/cli/init.py +817 -679
- headroom/cli/tools.py +5 -1
- headroom/cli/wrap.py +0 -0
- headroom/compress.py +347 -347
- headroom/copilot_auth.py +444 -444
- headroom/install/health.py +28 -28
- headroom/install/providers.py +174 -174
- headroom/install/runtime.py +279 -275
- headroom/install/supervisors.py +10 -6
- headroom/learn/analyzer.py +65 -0
- headroom/learn/writer.py +11 -0
- headroom/memory/adapters/embedders.py +60 -21
- headroom/memory/adapters/sqlite_vector.py +243 -65
- headroom/memory/mcp_server.py +9 -7
- headroom/memory/traffic_learner.py +247 -16
- headroom/providers/aider/install.py +12 -12
- headroom/providers/claude/install.py +63 -63
- headroom/providers/codex/install.py +68 -68
- headroom/providers/copilot/install.py +25 -25
- headroom/providers/cursor/install.py +15 -15
- headroom/providers/install_registry.py +86 -86
- headroom/providers/openclaw/install.py +50 -50
- headroom/proxy/handlers/anthropic.py +7 -0
- headroom/proxy/handlers/openai.py +0 -0
- headroom/proxy/handlers/streaming.py +4 -1
- headroom/proxy/helpers.py +84 -0
- headroom/proxy/models.py +5 -0
- headroom/proxy/server.py +0 -0
- headroom/release_version.py +310 -310
- headroom/subscription/__init__.py +72 -72
- 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 |
[](https://github.com/chopratejas/headroom/actions/workflows/ci.yml)
|
|
|
|
| 8 |
[](https://pypi.org/project/headroom-ai/)
|
| 9 |
[](https://www.npmjs.com/package/headroom-ai)
|
| 10 |
[](https://huggingface.co/chopratejas/kompress-base)
|
|
|
|
| 5 |
**Compress everything your AI agent reads. Same answers, fraction of the tokens.**
|
| 6 |
|
| 7 |
[](https://github.com/chopratejas/headroom/actions/workflows/ci.yml)
|
| 8 |
+
[](https://app.codecov.io/gh/chopratejas/headroom)
|
| 9 |
[](https://pypi.org/project/headroom-ai/)
|
| 10 |
[](https://www.npmjs.com/package/headroom-ai)
|
| 11 |
[](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.
|
| 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.
|
| 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/@
|
| 204 |
-
"version": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
"license": "MIT",
|
| 206 |
-
"
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
}
|
| 209 |
},
|
| 210 |
-
"node_modules/@
|
| 211 |
-
"version": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
"license": "MIT",
|
| 213 |
-
"
|
| 214 |
-
|
| 215 |
-
"
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
},
|
| 218 |
-
"node_modules/@
|
| 219 |
-
"version": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"license": "MIT",
|
| 221 |
-
"
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
"
|
| 227 |
}
|
| 228 |
},
|
| 229 |
-
"node_modules/@
|
| 230 |
-
"version": "0.
|
| 231 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
},
|
| 233 |
-
"node_modules/@
|
| 234 |
-
"version": "
|
| 235 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
},
|
| 237 |
-
"node_modules/@
|
| 238 |
-
"version": "0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
"license": "MIT",
|
| 240 |
-
"
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
}
|
| 243 |
},
|
| 244 |
-
"node_modules/@
|
| 245 |
-
"version": "0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
"license": "MIT",
|
| 247 |
-
"
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
},
|
| 253 |
-
"
|
| 254 |
-
"
|
| 255 |
-
"optional": true
|
| 256 |
-
}
|
| 257 |
}
|
| 258 |
},
|
| 259 |
-
"node_modules/@img/
|
| 260 |
-
"version": "
|
| 261 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
"optional": true,
|
|
|
|
|
|
|
|
|
|
| 263 |
"engines": {
|
| 264 |
-
"node": ">=
|
|
|
|
|
|
|
|
|
|
| 265 |
}
|
| 266 |
},
|
| 267 |
-
"node_modules/@img/sharp-
|
| 268 |
"version": "0.34.5",
|
|
|
|
|
|
|
| 269 |
"cpu": [
|
| 270 |
-
"
|
| 271 |
],
|
| 272 |
-
"license": "Apache-2.0",
|
| 273 |
"optional": true,
|
| 274 |
"os": [
|
| 275 |
-
"
|
| 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-
|
| 288 |
-
"version": "
|
|
|
|
|
|
|
| 289 |
"cpu": [
|
| 290 |
-
"
|
| 291 |
],
|
| 292 |
-
"license": "LGPL-3.0-or-later",
|
| 293 |
"optional": true,
|
| 294 |
"os": [
|
| 295 |
-
"
|
| 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.
|
|
|
|
|
|
|
| 378 |
"license": "MIT"
|
| 379 |
},
|
| 380 |
"node_modules/@next/swc-darwin-arm64": {
|
| 381 |
-
"version": "16.2.
|
|
|
|
|
|
|
| 382 |
"cpu": [
|
| 383 |
"arm64"
|
| 384 |
],
|
|
@@ -392,12 +1224,13 @@
|
|
| 392 |
}
|
| 393 |
},
|
| 394 |
"node_modules/@next/swc-darwin-x64": {
|
| 395 |
-
"version": "16.2.
|
| 396 |
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.
|
| 397 |
-
"integrity": "sha512-
|
| 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.
|
| 411 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.
|
| 412 |
-
"integrity": "sha512-
|
| 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.
|
| 426 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.
|
| 427 |
-
"integrity": "sha512-
|
| 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.
|
| 441 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.
|
| 442 |
-
"integrity": "sha512-
|
| 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.
|
| 456 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.
|
| 457 |
-
"integrity": "sha512-
|
| 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.
|
| 471 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.
|
| 472 |
-
"integrity": "sha512-
|
| 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.
|
| 486 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.
|
| 487 |
-
"integrity": "sha512-
|
| 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 |
-
"
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
"
|
| 1434 |
-
"
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
},
|
| 1438 |
-
"node_modules/@
|
| 1439 |
-
"version": "
|
| 1440 |
-
"
|
|
|
|
|
|
|
|
|
|
| 1441 |
"dependencies": {
|
| 1442 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
}
|
| 1444 |
},
|
| 1445 |
-
"node_modules/@tailwindcss/
|
| 1446 |
-
"version": "
|
| 1447 |
"dev": true,
|
|
|
|
| 1448 |
"license": "MIT",
|
|
|
|
| 1449 |
"dependencies": {
|
| 1450 |
-
"
|
| 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-
|
| 1482 |
"version": "4.2.2",
|
|
|
|
|
|
|
| 1483 |
"cpu": [
|
| 1484 |
-
"
|
| 1485 |
],
|
| 1486 |
"dev": true,
|
| 1487 |
"license": "MIT",
|
| 1488 |
"optional": true,
|
| 1489 |
"os": [
|
| 1490 |
-
"
|
| 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.
|
|
|
|
|
|
|
| 4110 |
"license": "MIT",
|
| 4111 |
-
"peer": true,
|
| 4112 |
"dependencies": {
|
| 4113 |
-
"@next/env": "16.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.
|
| 4128 |
-
"@next/swc-darwin-x64": "16.2.
|
| 4129 |
-
"@next/swc-linux-arm64-gnu": "16.2.
|
| 4130 |
-
"@next/swc-linux-arm64-musl": "16.2.
|
| 4131 |
-
"@next/swc-linux-x64-gnu": "16.2.
|
| 4132 |
-
"@next/swc-linux-x64-musl": "16.2.
|
| 4133 |
-
"@next/swc-win32-arm64-msvc": "16.2.
|
| 4134 |
-
"@next/swc-win32-x64-msvc": "16.2.
|
| 4135 |
"sharp": "^0.34.5"
|
| 4136 |
},
|
| 4137 |
"peerDependencies": {
|
|
@@ -4305,7 +5610,9 @@
|
|
| 4305 |
}
|
| 4306 |
},
|
| 4307 |
"node_modules/postcss": {
|
| 4308 |
-
"version": "8.5.
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def
|
| 50 |
-
if not
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def
|
| 134 |
-
config =
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
from
|
| 13 |
-
|
| 14 |
-
import
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
from headroom.install.
|
| 19 |
-
from headroom.install.
|
| 20 |
-
from headroom.install.
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
from .
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
if
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
return
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
if not
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
if
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
+
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
) ->
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
"
|
| 386 |
-
"
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
if
|
| 412 |
-
return
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
if
|
| 450 |
-
return
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
def
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
)
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
def
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
)
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
@
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 45 |
-
return
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
"
|
| 97 |
-
|
| 98 |
-
"--
|
| 99 |
-
|
| 100 |
-
"-
|
| 101 |
-
f"
|
| 102 |
-
"--
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
"
|
| 106 |
-
|
| 107 |
-
"
|
| 108 |
-
|
| 109 |
-
"--
|
| 110 |
-
f"{
|
| 111 |
-
"--
|
| 112 |
-
f"{
|
| 113 |
-
"--volume",
|
| 114 |
-
f"{_mount_source(home, '.
|
| 115 |
-
"--volume",
|
| 116 |
-
f"{_mount_source(home, '.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
path
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
return None
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
kwargs["
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
"
|
| 220 |
-
"
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
return
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
return "stopped"
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 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 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 233 |
-
"""
|
| 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 |
-
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 419 |
for memory in memories:
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
| 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 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
mem.
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1090 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|