acm-context-management 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: acm-context-management
3
+ Version: 0.2.0
4
+ Summary: Context-management gateway + MCP server that brings the ACM techniques (trimming, summarisation, image-recall, memory) into any IDE
5
+ Project-URL: Homepage, https://github.com/HarishanA21/agentic_context_management
6
+ Project-URL: Repository, https://github.com/HarishanA21/agentic_context_management
7
+ Author: ACM
8
+ License: MIT
9
+ Keywords: claude-code,context-management,cursor,gateway,ides,llm,mcp,vscode
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: fastapi>=0.110
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: langchain-core>=0.3
22
+ Requires-Dist: langchain>=0.3
23
+ Requires-Dist: mcp>=1.2
24
+ Requires-Dist: pillow>=10
25
+ Requires-Dist: pydantic>=2
26
+ Requires-Dist: python-dotenv>=1.0
27
+ Requires-Dist: uvicorn[standard]>=0.29
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # ACM Extension — context management for any IDE
35
+
36
+ This folder is **self-contained**. It turns the context-management engine that
37
+ already powers the website (in [`../backend`](../backend)) into something usable
38
+ from VSCode, Cursor, Antigravity, Claude Code, and Windsurf — **without changing
39
+ a single line of the website's code.**
40
+
41
+ It reaches the IDEs through three surfaces (see
42
+ [`../EXTENSION_PLAN.md`](../EXTENSION_PLAN.md) for the why):
43
+
44
+ | Folder | Surface | What it is |
45
+ |---|---|---|
46
+ | `acm_gateway/` | **Gateway** | A local OpenAI/Anthropic-compatible proxy. The IDE talks to it as if it were the AI; it applies every technique on the wire, then forwards to the real provider. Full feature parity. |
47
+ | `acm_mcp/` | **MCP** | An MCP server exposing `remember` / `recall` / `compact` / `set_profile` tools. Works in *every* MCP-capable IDE. |
48
+ | `adapters/` | **Hooks + config** | Per-IDE glue: Claude Code hooks, Cursor `mcp.json`, VSCode notes. |
49
+ | `acm_engine/` | — | Thin bridge that imports the techniques from `../backend` so there is **one** source of truth. |
50
+
51
+ ## How it reuses the website's engine
52
+
53
+ `acm_engine/` puts `../backend` on `sys.path` and re-exports the pure technique
54
+ functions (`trim_tool_results`, `summarise_old_messages`,
55
+ `sliding_window_trim`, `evict_stale_images`, `annotate_cache_breakpoints`) plus
56
+ the `Profile` schema. Nothing in `../backend` is edited. The website and the
57
+ extension share the same code; fix a bug once, both benefit.
58
+
59
+ ## Quick start (gateway)
60
+
61
+ ```bash
62
+ cd extension
63
+ uv venv --python 3.11
64
+ source .venv/bin/activate
65
+ uv pip install -e .
66
+
67
+ # point it at any OpenAI-compatible upstream (OpenRouter shown)
68
+ export ACM_UPSTREAM_BASE_URL=https://openrouter.ai/api/v1
69
+ export ACM_UPSTREAM_API_KEY=sk-or-v1-...
70
+
71
+ acm-gateway # serves http://127.0.0.1:8807
72
+ ```
73
+
74
+ Then tell the IDE the AI lives at `http://127.0.0.1:8807/v1` (Cursor: custom
75
+ OpenAI base URL; Claude Code: `ANTHROPIC_BASE_URL`).
76
+
77
+ ## Config
78
+
79
+ A single [`config/acm.config.example.json`](config/acm.config.example.json)
80
+ holds the active profile (which techniques are on). Copy it to
81
+ `config/acm.config.json` and edit, or set `ACM_CONFIG` to its path. The schema
82
+ is the website's `Profile` model — same presets (`minimal`, `long_chat`,
83
+ `power_research`, `cheap_long`, `visual_recall`).
84
+
85
+ ## Status
86
+
87
+ - **Gateway** — runnable. OpenAI (`/v1/chat/completions`) **and** Anthropic
88
+ (`/v1/messages`) surfaces; full technique pipeline (trim / summarise / sliding
89
+ window / image-evict / cache breakpoints / **visual method** — rasterise big
90
+ tool outputs to images); **manual message removal** (cascade-safe drop-list,
91
+ persisted to `~/.acm/dropped.json`); **multi-provider routing** (OpenAI /
92
+ OpenRouter / Google / Azure on the OpenAI surface, Anthropic native; creds in
93
+ `~/.acm/providers.json`; route by `x-acm-provider` header, `slug/model`
94
+ prefix, or default — Bedrock via OpenRouter since it needs AWS SigV4); control
95
+ plane (`/status`, `/profile`, `/memory`, `/compact`, `/conversations`,
96
+ `/messages*`, `/providers*`).
97
+ - **MCP server** — runnable: memory (`remember`/`recall`), `compact`,
98
+ `set_profile`/`status`, manual removal (`list_messages`/`drop_message`/
99
+ `restore_message`), providers (`list_providers`/`add_provider`/
100
+ `set_default_provider`), **sub-agent** (`spawn_subagent`), and **JIT retrieval**
101
+ (`find_files`/`read_slice`/`grep_files`).
102
+ - **Claude Code** — gateway + hooks (trim + memory) + MCP. Done.
103
+ - **Cursor** — gateway + hooks (capture + **compact-on-stop**) + MCP. Done.
104
+ - **VSCode** — full extension (Language Model Tools + `@acm` chat participant +
105
+ webview settings) talking to the gateway; MCP drop-in for Antigravity /
106
+ Windsurf. TypeScript is written but not yet `tsc`-compiled here (needs
107
+ `npm install` with `@types/vscode`).
108
+
109
+ Remaining work is marked with `# TODO(acm)` / `TODO(acm)`: orphan-`tool_use`
110
+ pruning on the Anthropic path, React components in the VSCode webview, and
111
+ auto-starting the gateway from the extension.
@@ -0,0 +1,30 @@
1
+ acm_engine/__init__.py,sha256=sbBeugN4NF3NDkRm_N4rUe2lkWVRZv_jOWMkflnSmsQ,4167
2
+ acm_engine/_vendor/cache_layout.py,sha256=R5QTIudjbb4hE_JxXMFmXwEy49rMgmc9cYhDbdElWTs,9004
3
+ acm_engine/_vendor/context_editing.py,sha256=VG1EgNazNTrKxy_v6ZKtAOI3GPgvQc6bem-5Cstw2Mg,33463
4
+ acm_engine/_vendor/context_profiles.py,sha256=UYyqBT4n82KXzdjLP_WqWcnREz3fCVb3ulPqbL5raLM,21699
5
+ acm_engine/_vendor/relevance.py,sha256=_jmO4r3VHybkh3jp7IcW0vIaeM7jeEKKtE29WhEqwjs,24931
6
+ acm_engine/_vendor/relevance_encoder.py,sha256=6eVtzbv5U8HEwkpbYvdoSO_FQ1ywmCnH3nBAadm6Lcw,9377
7
+ acm_engine/_vendor/visual_tool/__init__.py,sha256=HjQMBbuMAITqaMDjHbDAl5U_iwjsAUUbYXC5vujuamk,379
8
+ acm_engine/_vendor/visual_tool/indexer.py,sha256=Y8fiVwYGAW491b_fKXs6LSJDmEVY2rZGpuTm0YzfxFY,4518
9
+ acm_engine/_vendor/visual_tool/rasterizer.py,sha256=PlrYhGpARasA82RWlEtRcsxf4dczPXcTYTZg2k7Fruo,8082
10
+ acm_gateway/__init__.py,sha256=g8Q4-CcprvonNmxH2D42Sk5NbEIbzTm7q3AmAT_2vuk,204
11
+ acm_gateway/__main__.py,sha256=6v5YKAOOLgtC-v0_g_SRyo7fqB8Vtf4KoUpjUK-a4Sk,485
12
+ acm_gateway/app.py,sha256=rcjMA4__AQKzSAlDPPVD2uv6fXbJmdO7zKtttHBolC8,48025
13
+ acm_gateway/config.py,sha256=SGVrqSHifiocJdN1tTBs9VKzONlvZB_3lEn6BbxX1bo,6756
14
+ acm_gateway/context_windows.py,sha256=FiqK4nplhJWRkSS0mpjl5hrkYx3Oq4UxzuuTkgMnb44,8533
15
+ acm_gateway/droplist.py,sha256=IOI6gH9MpZ5NTK5WC7S6SfLVbCFsm4sUp6VlhLC-yr4,20237
16
+ acm_gateway/events.py,sha256=le7PaKs7oGOThL8JgYJ7_JbvRM_8jkQmT_Xc-DGp6h8,3248
17
+ acm_gateway/pipeline.py,sha256=emI2SGfYiv6SVzAgySpKk1Lk29oMZNPSXBOO87-zekc,7252
18
+ acm_gateway/providers_store.py,sha256=8uouaxwIjB9xZaABKXtfKwiGHIgc720sG6jLMFxFTus,8273
19
+ acm_gateway/translate.py,sha256=B0UyKMJpZ4J5_FHGJr5_ezdN5PQrKxIFa55sJa5MalU,4364
20
+ acm_gateway/translate_anthropic.py,sha256=knMrQX3OBhQGHWiaNeNBONxEOvnFo9pRhnXnkBLMLzo,10875
21
+ acm_gateway/upstream.py,sha256=h15BsE9OmSGLU0BrjrAmTbr-2n2ZU5HGx9iX2fi14AQ,12246
22
+ acm_gateway/visualizer.py,sha256=meEemUCfsM82lTlTEpckbedyLVk6OeXCa6g95Ec-zuw,4940
23
+ acm_mcp/__init__.py,sha256=2OMuGY08Q_QKB1CZRjqNoeq73uJ9qYn3At8p-1-2s8M,213
24
+ acm_mcp/jit.py,sha256=kWaVZOLmv2wfRGLvKMBYYQR-vbhHvjkMPF9pgu4MHKk,3897
25
+ acm_mcp/memory_store.py,sha256=RmbXAFip1tkR60Vw1ylcTrwJbfR-APkNnEJpS16A5Z4,1835
26
+ acm_mcp/server.py,sha256=nszX5lntSMIoYQPJgmJZfeL72At5lxz4x_oqjuSCQ4E,10107
27
+ acm_context_management-0.2.0.dist-info/METADATA,sha256=mhLiY1MtoAS9LJ0MuTyUgJR1IsUQ1tVtMond_1pPUTw,5364
28
+ acm_context_management-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
29
+ acm_context_management-0.2.0.dist-info/entry_points.txt,sha256=ddyNG1InNz_GZKa9Cq5udixszCy1YZ10hCqsSOgFmag,88
30
+ acm_context_management-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ acm-gateway = acm_gateway.__main__:main
3
+ acm-mcp = acm_mcp.server:main
acm_engine/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ """Bridge to the website's context-management engine.
2
+
3
+ This is the *one* place the extension reaches into ``../backend``. It puts the
4
+ backend directory on ``sys.path`` and re-exports the **pure** technique
5
+ functions plus the ``Profile`` schema, so the gateway and the website run the
6
+ exact same code. Nothing in ``../backend`` is edited.
7
+
8
+ Why only the pure functions? ``context_editing.apply_context_edits`` is
9
+ LangGraph-specific — it calls ``agent.get_state`` / ``agent.update_state``. The
10
+ gateway has no LangGraph agent; it has a plain list of messages off the wire.
11
+ So we import the lower-level building blocks (which operate on a
12
+ ``List[BaseMessage]``) and the gateway's own ``pipeline.py`` orchestrates them
13
+ in the same fixed order ``apply_context_edits`` uses.
14
+
15
+ If an import here ever fails, it's almost always because ``../backend`` moved or
16
+ a dependency in ``backend/requirements.txt`` isn't installed in this venv.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ # Two ways to find the engine, in priority order:
25
+ # 1. The **vendored** copy shipped inside the wheel (acm_engine/_vendor/) —
26
+ # this is what an end user who `pip install`ed the package gets. Refresh it
27
+ # from backend with `python scripts/sync_engine.py`.
28
+ # 2. The repo's ../backend tree — used during local dev so a checkout runs the
29
+ # live engine without a sync step.
30
+ # Whichever wins is put on sys.path so the engine's flat imports
31
+ # (`import context_profiles`, `from context_editing import ...`) resolve.
32
+ _VENDOR = Path(__file__).resolve().parent / "_vendor"
33
+ _BACKEND = Path(__file__).resolve().parents[2] / "backend"
34
+
35
+ if (_VENDOR / "context_profiles.py").is_file():
36
+ _ENGINE_DIR = _VENDOR
37
+ elif (_BACKEND / "context_profiles.py").is_file():
38
+ _ENGINE_DIR = _BACKEND
39
+ else: # pragma: no cover - defensive
40
+ raise ImportError(
41
+ "acm_engine found neither a vendored engine (acm_engine/_vendor/) nor "
42
+ f"the repo backend at {_BACKEND}. Run `python scripts/sync_engine.py`."
43
+ )
44
+
45
+ _engine_str = str(_ENGINE_DIR)
46
+ if _engine_str not in sys.path:
47
+ sys.path.insert(0, _engine_str)
48
+
49
+ # Re-export the schema (pure pydantic, zero app coupling).
50
+ from context_profiles import ( # noqa: E402
51
+ BUILTIN_PRESETS,
52
+ DEFAULT_PRESET_NAME,
53
+ PRESET_SUMMARY,
54
+ Profile,
55
+ parse_profile,
56
+ )
57
+
58
+ # Re-export the pure, message-list-level techniques. None of these touch the
59
+ # database, FastAPI, or a LangGraph agent — they take a list of messages and
60
+ # return rewrites.
61
+ from context_editing import ( # noqa: E402
62
+ _DEFAULT_SUMMARY_SYSTEM as DEFAULT_SUMMARY_SYSTEM,
63
+ evict_stale_images,
64
+ sliding_window_trim,
65
+ summarise_old_messages,
66
+ trim_tool_results,
67
+ )
68
+ from cache_layout import ( # noqa: E402
69
+ annotate_cache_breakpoints,
70
+ read_cache_tokens,
71
+ )
72
+
73
+ # Task-aware relevance pruning (suggest-only; judge engine ships now, encoder
74
+ # seam ready for Phase 2). Pure, message-list-level like the rest.
75
+ from relevance import ( # noqa: E402
76
+ Episode,
77
+ JudgeSuggester,
78
+ Suggestion,
79
+ active_task,
80
+ build_audit_rows,
81
+ load_audits,
82
+ load_feedback,
83
+ record_audit,
84
+ record_feedback,
85
+ segment_into_episodes,
86
+ suggest_removals,
87
+ )
88
+
89
+ # Local encoder engine (no LLM call). Heavy ML libs load lazily, so importing
90
+ # the class here stays cheap even on a minimal install.
91
+ from relevance_encoder import EncoderSuggester # noqa: E402
92
+
93
+ # Where the engine was actually loaded from (vendored copy or repo backend).
94
+ ENGINE_DIR = _ENGINE_DIR
95
+ BACKEND_DIR = _ENGINE_DIR # back-compat alias
96
+
97
+ __all__ = [
98
+ "ENGINE_DIR",
99
+ "BACKEND_DIR",
100
+ "BUILTIN_PRESETS",
101
+ "DEFAULT_PRESET_NAME",
102
+ "PRESET_SUMMARY",
103
+ "Profile",
104
+ "parse_profile",
105
+ "DEFAULT_SUMMARY_SYSTEM",
106
+ "trim_tool_results",
107
+ "summarise_old_messages",
108
+ "sliding_window_trim",
109
+ "evict_stale_images",
110
+ "annotate_cache_breakpoints",
111
+ "read_cache_tokens",
112
+ "Episode",
113
+ "Suggestion",
114
+ "JudgeSuggester",
115
+ "segment_into_episodes",
116
+ "active_task",
117
+ "suggest_removals",
118
+ "record_feedback",
119
+ "load_feedback",
120
+ "build_audit_rows",
121
+ "record_audit",
122
+ "load_audits",
123
+ "EncoderSuggester",
124
+ ]
@@ -0,0 +1,218 @@
1
+ """Provider-agnostic image-recall middleware (cache + eviction at call time).
2
+
3
+ This is the *common* solution for the image-recall techniques — it works for
4
+ every model the app can route to, because it leans on the one thing all
5
+ caching backends share: **a stable, append-only prefix**.
6
+
7
+ * OpenAI / Azure (and OpenAI models via OpenRouter): caching is automatic.
8
+ Keeping the prefix stable is all that's needed; the cache_control hints
9
+ we add are simply ignored.
10
+ * Anthropic / Bedrock-Claude / Gemini (incl. via OpenRouter): caching is
11
+ explicit. OpenRouter forwards the ``cache_control`` markers we put on
12
+ content blocks straight through to the upstream provider.
13
+
14
+ The middleware does both halves of "visual recall", gated by the profile's
15
+ ``image_recall.mode``:
16
+
17
+ * eviction (``evict`` / ``cache_evict``) — inside ``wrap_model_call`` we
18
+ drop the pixels of all but the most-recent K image tool-results from the
19
+ request the model sees, replacing them with their REFERENCES + a digest.
20
+ Doing it here (not just between turns) means it also fires inside a single
21
+ agent tool-loop — which is what the single-shot demo needs.
22
+ * caching (``cache`` / ``cache_evict``) — mark the stable prefix with an
23
+ ephemeral breakpoint so re-reading the frozen history is cheap/fast.
24
+ Caching never changes the model's output.
25
+
26
+ Gotchas baked in:
27
+ * Breakpoints go on a *text* block, never an image/tool block (Anthropic
28
+ rejects ``cache_control`` there — langchain issue #34920).
29
+ * String content is widened to a one-element list so we can hang a marker.
30
+ * Everything is defensive: an image-recall hiccup must never break a turn.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ from langchain.agents.middleware import AgentMiddleware
39
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
40
+
41
+ log = logging.getLogger("image_recall")
42
+
43
+
44
+ # ─── cache-token reader (used by demo + chat to surface cache details) ───
45
+
46
+
47
+ def read_cache_tokens(msg: BaseMessage) -> Dict[str, int]:
48
+ """Pull cache hit/write counts off an AIMessage, provider-agnostic.
49
+
50
+ Returns ``{cache_read, cache_write}``. Anthropic/Gemini expose these via
51
+ ``usage_metadata.input_token_details`` (cache_read / cache_creation);
52
+ OpenAI exposes a ``cache_read`` only. Falls back to ``response_metadata``
53
+ when the normalised field is absent. All-zero when nothing cached.
54
+ """
55
+ out = {"cache_read": 0, "cache_write": 0}
56
+ um = getattr(msg, "usage_metadata", None) or {}
57
+ details = um.get("input_token_details") or {}
58
+ if isinstance(details, dict):
59
+ out["cache_read"] = int(details.get("cache_read") or 0)
60
+ out["cache_write"] = int(
61
+ details.get("cache_creation") or details.get("cache_write") or 0
62
+ )
63
+ if out["cache_read"] == 0 and out["cache_write"] == 0:
64
+ # OpenAI-style raw usage on response_metadata.
65
+ rm = getattr(msg, "response_metadata", None) or {}
66
+ usage = rm.get("usage") or rm.get("token_usage") or {}
67
+ ptd = (usage.get("prompt_tokens_details") or {}) if isinstance(usage, dict) else {}
68
+ if isinstance(ptd, dict):
69
+ out["cache_read"] = int(ptd.get("cached_tokens") or 0)
70
+ return out
71
+
72
+
73
+ # ─── cache breakpoint placement ──────────────────────────────────────────
74
+
75
+
76
+ def _last_stable_index(messages: List[BaseMessage]) -> Optional[int]:
77
+ """Index of the last message in the *settled* prefix (just before the
78
+ final HumanMessage). The newest turn stays dynamic / reprocessed fresh."""
79
+ if len(messages) < 2:
80
+ return None
81
+ last_human = None
82
+ for i in range(len(messages) - 1, -1, -1):
83
+ if isinstance(messages[i], HumanMessage):
84
+ last_human = i
85
+ break
86
+ boundary = (last_human - 1) if last_human is not None else (len(messages) - 1)
87
+ return boundary if boundary >= 0 else None
88
+
89
+
90
+ def _mark_block(msg: BaseMessage, ttl: str) -> bool:
91
+ """Attach an ephemeral cache_control marker to a *text* block of ``msg``.
92
+ Skips image / non-text blocks. Returns True if a marker was placed."""
93
+ cc: Dict[str, Any] = {"type": "ephemeral"}
94
+ if ttl == "1h":
95
+ cc["ttl"] = "1h"
96
+ content = getattr(msg, "content", None)
97
+ if isinstance(content, str):
98
+ if not content.strip():
99
+ return False
100
+ msg.content = [{"type": "text", "text": content, "cache_control": cc}]
101
+ return True
102
+ if isinstance(content, list):
103
+ for block in reversed(content):
104
+ if isinstance(block, dict) and block.get("type") == "text":
105
+ block["cache_control"] = cc
106
+ return True
107
+ return False
108
+
109
+
110
+ def annotate_cache_breakpoints(
111
+ messages: List[BaseMessage],
112
+ *,
113
+ system: Optional[BaseMessage] = None,
114
+ ttl: str = "5m",
115
+ ) -> int:
116
+ """Place ephemeral breakpoints on the stable prefix (system + last settled
117
+ message). Mutates in place; returns how many breakpoints were set."""
118
+ placed = 0
119
+ if isinstance(system, SystemMessage) and _mark_block(system, ttl):
120
+ placed += 1
121
+ idx = _last_stable_index(messages)
122
+ if idx is not None:
123
+ target = messages[idx]
124
+ if target.__class__.__name__ == "ToolMessage":
125
+ # Don't mark a bare tool block; walk back to an AI/System text block.
126
+ for j in range(idx, -1, -1):
127
+ if isinstance(messages[j], (AIMessage, SystemMessage)):
128
+ target = messages[j]
129
+ break
130
+ if _mark_block(target, ttl):
131
+ placed += 1
132
+ return placed
133
+
134
+
135
+ # ─── the middleware ──────────────────────────────────────────────────────
136
+
137
+
138
+ class ImageRecallMiddleware(AgentMiddleware):
139
+ """Applies the selected image-recall technique on every model call.
140
+
141
+ ``mode`` ∈ {off, cache, evict, cache_evict}. Provider-agnostic: caching is
142
+ a no-op effect for auto-caching backends and active for explicit ones.
143
+ Attach only when ``mode != off``.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ *,
149
+ mode: str = "off",
150
+ keep_recent_images: int = 3,
151
+ ttl: str = "5m",
152
+ ) -> None:
153
+ super().__init__()
154
+ self.mode = mode if mode in {"off", "cache", "evict", "cache_evict"} else "off"
155
+ self.keep_recent_images = max(1, int(keep_recent_images or 3))
156
+ self.ttl = ttl if ttl in {"5m", "1h"} else "5m"
157
+
158
+ @property
159
+ def caching_enabled(self) -> bool:
160
+ return self.mode in {"cache", "cache_evict"}
161
+
162
+ @property
163
+ def eviction_enabled(self) -> bool:
164
+ return self.mode in {"evict", "cache_evict"}
165
+
166
+ def _apply(self, request) -> None:
167
+ """Mutate request.messages in place: evict stale images, then mark
168
+ cache breakpoints. Order matters — eviction first so the breakpoint
169
+ lands on the de-imaged prefix."""
170
+ msgs = getattr(request, "messages", None)
171
+ if not msgs:
172
+ return
173
+ if self.eviction_enabled:
174
+ # Lazy import avoids a context_editing <-> cache_layout cycle.
175
+ from context_editing import evict_stale_images
176
+
177
+ replacements, info = evict_stale_images(
178
+ msgs, keep_recent_images=self.keep_recent_images
179
+ )
180
+ if replacements:
181
+ by_id = {getattr(r, "id", None): r for r in replacements}
182
+ request.messages = [
183
+ by_id.get(getattr(m, "id", None), m) for m in msgs
184
+ ]
185
+ msgs = request.messages
186
+ log.info(
187
+ "[image_recall] evicted %d stale image(s), kept %d as pixels, freed ~%d tokens",
188
+ info["evicted"], info["kept_recent"], info["freed_tokens"],
189
+ )
190
+ if self.caching_enabled:
191
+ placed = annotate_cache_breakpoints(
192
+ msgs,
193
+ system=getattr(request, "system_message", None),
194
+ ttl=self.ttl,
195
+ )
196
+ if placed:
197
+ log.info(
198
+ "[image_recall] placed %d cache breakpoint(s) (ttl=%s) on the stable prefix",
199
+ placed, self.ttl,
200
+ )
201
+
202
+ def wrap_model_call(self, request, handler): # type: ignore[override]
203
+ try:
204
+ self._apply(request)
205
+ except Exception as e: # never break the turn over image-recall
206
+ log.warning("[image_recall] apply failed: %r", e)
207
+ return handler(request)
208
+
209
+ async def awrap_model_call(self, request, handler): # type: ignore[override]
210
+ try:
211
+ self._apply(request)
212
+ except Exception as e:
213
+ log.warning("[image_recall] apply failed: %r", e)
214
+ return await handler(request)
215
+
216
+
217
+ # Back-compat alias (earlier name during development).
218
+ CachePrefixMiddleware = ImageRecallMiddleware