mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Three independent contract-pattern follow-ons bundled into one PR. Same frozen-dataclass + factory + apply_to_tags + Rust-portable shape that PR #473 / #477 / #483 established. ## (1) MemoryRanker + RecencyBoostRanker Pre-this-PR Headroom ranked memory candidates by pure cosine similarity. Every other memory system we surveyed (Letta, Mem0, Cognee, Supermemory) re-ranks beyond cosine. * ``MemoryRanker`` Protocol — pluggable re-ranker; future PRs add source-weight + access-count rankers behind the same interface. * ``RecencyBoostRanker`` — first concrete impl. Final score is ``cosine × exp(-age_days / decay_days)``. Default decay 30 days (half-life ~21 days; 60-day-old factor 0.135, 90-day-old 0.050). * ``MemoryCandidate`` — backend-agnostic frozen value type that flows through the ranker. ``MemoryCandidate.from_backend_result`` adapter converts the existing ``MemoryResult`` shape (with nested ``memory.created_at``) into the ranker's flatter form. * Wired into ``memory_handler.search_and_format_context`` as an optional ``ranker=`` kwarg — backwards-compat: ``None`` (default) preserves the pure-cosine path identically. Defensive: * ``created_at=None`` → factor 1.0 (recency-neutral, back-compat with legacy rows / migrating backends) * Negative age (clock skew) → clamped to factor 1.0 (a future-dated row can't outrank a real fresh memory) * Sort is stable on ties — same input → same output every turn, so consecutive turns inject memories in the same order (prefix-cache friendly) Performance: O(N) over candidates where N=top_k≈10. One ``math.exp`` per candidate. Sub-microsecond. Zero new I/O. ## (2) ImageCompressionDecision Mirror of :class:`CompressionDecision` for image compression. Two sites today (``openai.py:1203``, ``anthropic.py:868``) gate inline; both already respect bypass (no Gemini-class drift bug like text compression had), but consolidating into a value type: * Locks bypass-respect via AST contract test — future sites can't drift on it * Surfaces ``image_skip_reason`` in ``RequestOutcome.tags`` for dashboard slicing (same observability surface as ``passthrough_reason`` and ``memory_skip_reason``) * Same Rust-port shape as the other decision types Precedence: ``bypass_header`` > ``image_optimize_disabled`` > ``no_messages`` > ``should_compress=True``. Anthropic's extra ``is_cache_mode`` check stays inline because it's Anthropic-specific (openai/gemini don't have it). Documented in a code comment. ## (3) Branch-aware sync-plugin-versions hook Pre-this-fix the pre-commit ``sync-plugin-versions`` hook ran on every commit and bumped manifests to the predicted-next-release version. Every PR ended up carrying the prediction as collateral ("Why are we bumping ``.claude-plugin/marketplace.json`` — we should not, right??" - user, on PR #483). Fix: the hook is now a NO-OP unless EITHER: * We're on the ``main`` branch, OR * ``HEADROOM_SYNC_VERSIONS=1`` is set explicitly (release workflow) On feature branches the hook prints a single line explaining the skip and exits cleanly. The release workflow opts in via the env var; behaviour on main / at release time is unchanged. ## Test coverage * 16 new tests on ``MemoryRanker`` / ``RecencyBoostRanker`` (frozen, equal cosine wins by recency, decay configurable, NULL timestamp neutral, no-mutation contract, Rust-port shape) * 17 new tests on ``ImageCompressionDecision`` (frozen, all 3 skip reasons, precedence, observability fields, apply_to_tags) * 1 new AST invariant test (extends ``test_handler_outcome_tag_invariant.py``) — locks "no raw ``if self.config.image_optimize and messages and not _bypass:`` conjunction in any handler" All existing memory + cache-stability + handler tests pass (203 ✓). ``make ci-precheck`` clean. ## Rust portability All three new value types port cleanly to frozen Rust structs + pure functions. Same migration pattern as ``CompressionDecision`` (already locked in for the SmartCrusher Rust port). ## Zero-regression contract * Default ``ranker=None`` → memory_handler behaves identically to pre-this-PR (pure cosine; no perf change) * Image decision migration is identity at the bypass/optimize/messages gate — no behaviour change, just contract consolidation * Hook fix is no-op on feature branches (less churn) and unchanged on main (release flow preserved)
112 lines
3.3 KiB
Python
112 lines
3.3 KiB
Python
"""Sync plugin manifest versions to the repo's computed release semver.
|
|
|
|
Branch-aware: by default this script is a NO-OP on feature branches.
|
|
Pre-this-fix it ran on every commit and bumped the manifests to the
|
|
PREDICTED next release version, which polluted every PR with version-
|
|
bump noise (the prediction advanced as commits landed; each PR ended
|
|
up carrying the bump as collateral).
|
|
|
|
Sync now only runs when EITHER:
|
|
* We're on the ``main`` branch, OR
|
|
* ``HEADROOM_SYNC_VERSIONS=1`` is set explicitly (release workflow)
|
|
|
|
Result: feature-branch PRs no longer carry manifest bumps; the
|
|
release workflow still gets a canonical sync at publish time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from headroom.release_version import ( # noqa: E402
|
|
compute_release_version,
|
|
determine_bump_level,
|
|
find_latest_release_tag,
|
|
get_canonical_version,
|
|
list_release_commits,
|
|
list_release_tags,
|
|
)
|
|
|
|
|
|
def compute_repo_semver(root: Path) -> str:
|
|
"""Return the npm-style semver for the repo's next release."""
|
|
tags = list_release_tags(root)
|
|
previous_tag = find_latest_release_tag(tags) or ""
|
|
level = determine_bump_level(list_release_commits(root, previous_tag))
|
|
info = compute_release_version(
|
|
canonical_version=get_canonical_version(root),
|
|
level=level,
|
|
tags=tags,
|
|
)
|
|
return info.npm_version
|
|
|
|
|
|
def _current_branch(root: Path) -> str | None:
|
|
"""Return the current git branch name, or None if git isn't usable."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except (FileNotFoundError, OSError):
|
|
return None
|
|
if result.returncode != 0:
|
|
return None
|
|
return result.stdout.strip() or None
|
|
|
|
|
|
def _should_sync(root: Path) -> bool:
|
|
"""Decide whether to actually run the sync.
|
|
|
|
Release workflow opts in via ``HEADROOM_SYNC_VERSIONS=1``; otherwise
|
|
we only sync on ``main`` (where the next-release prediction
|
|
legitimately lives). On feature branches we no-op — the prediction
|
|
would just create PR-level noise.
|
|
"""
|
|
if os.environ.get("HEADROOM_SYNC_VERSIONS") == "1":
|
|
return True
|
|
branch = _current_branch(root)
|
|
if branch is None:
|
|
# Git unavailable or detached HEAD — safest default is no-op.
|
|
return False
|
|
return branch == "main"
|
|
|
|
|
|
def main() -> None:
|
|
root = ROOT
|
|
if not _should_sync(root):
|
|
# Quiet no-op on feature branches. Print a single line so
|
|
# pre-commit users see the reason if they look.
|
|
branch = _current_branch(root) or "<unknown>"
|
|
print(
|
|
f"sync-plugin-versions: skipping on branch '{branch}' (set HEADROOM_SYNC_VERSIONS=1 to force)"
|
|
)
|
|
return
|
|
version = compute_repo_semver(root)
|
|
subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(root / "scripts" / "version-sync.py"),
|
|
"--root",
|
|
str(root),
|
|
"--version",
|
|
version,
|
|
"--plugin-manifests-only",
|
|
],
|
|
cwd=root,
|
|
check=True,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|