superlocalmemory 3.6.9 → 3.6.11

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +112 -10
  2. package/README.md +67 -9
  3. package/package.json +1 -1
  4. package/pyproject.toml +6 -1
  5. package/skills/slm-optimize/README.md +55 -0
  6. package/skills/slm-optimize/SKILL.md +139 -0
  7. package/src/superlocalmemory/__init__.py +1 -1
  8. package/src/superlocalmemory/cli/compress_cmd.py +32 -70
  9. package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
  10. package/src/superlocalmemory/cli/setup_wizard.py +49 -0
  11. package/src/superlocalmemory/mcp/agent_context.py +111 -0
  12. package/src/superlocalmemory/mcp/server.py +4 -0
  13. package/src/superlocalmemory/mcp/tools_active.py +7 -8
  14. package/src/superlocalmemory/mcp/tools_core.py +16 -0
  15. package/src/superlocalmemory/mcp/tools_optimize.py +304 -0
  16. package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
  17. package/src/superlocalmemory/optimize/cache/exact.py +7 -4
  18. package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
  19. package/src/superlocalmemory/optimize/cache/manager.py +70 -8
  20. package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
  21. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
  22. package/src/superlocalmemory/optimize/compress/router.py +82 -87
  23. package/src/superlocalmemory/optimize/config/__init__.py +16 -0
  24. package/src/superlocalmemory/optimize/config/defaults.py +1 -6
  25. package/src/superlocalmemory/optimize/config/schema.py +2 -19
  26. package/src/superlocalmemory/optimize/config/store.py +15 -1
  27. package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
  28. package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
  29. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
  30. package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
  31. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
  32. package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
  33. package/src/superlocalmemory/optimize/proxy/server.py +29 -0
  34. package/src/superlocalmemory/optimize/storage/db.py +102 -11
  35. package/src/superlocalmemory/optimize/storage/schema.py +11 -0
  36. package/src/superlocalmemory/server/routes/optimize.py +6 -8
  37. package/src/superlocalmemory/server/unified_daemon.py +26 -5
  38. package/src/superlocalmemory/ui/index.html +18 -14
  39. package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
  40. package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
  41. package/src/superlocalmemory/ui/js/optimize.js +9 -9
  42. package/src/superlocalmemory.egg-info/PKG-INFO +69 -10
  43. package/src/superlocalmemory.egg-info/SOURCES.txt +3 -2
  44. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  45. package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
  46. package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
@@ -26,7 +26,6 @@ from superlocalmemory.optimize.config.store import ConfigStore
26
26
  logger = logging.getLogger("slm.optimize.compress.router")
27
27
 
28
28
  _MIN_CHARS_FOR_COMPRESSION: int = 500
29
- _MIN_RATIO_STRUCTURED: float = 0.60
30
29
 
31
30
 
32
31
  class CompressRouter:
@@ -50,8 +49,6 @@ class CompressRouter:
50
49
  return cls._instance
51
50
 
52
51
  def __init__(self) -> None:
53
- self._json_compressor: "JSONCompressor | None" = None
54
- self._code_compressor: "CodeCompressor | None" = None
55
52
  self._llmlingua_compressor: "LLMLinguaCompressor | None" = None
56
53
  self._ccr_store: "CCRStore | None" = None
57
54
  self._aligner: "CacheAligner | None" = None
@@ -103,12 +100,17 @@ class CompressRouter:
103
100
  tenant_id="default",
104
101
  )
105
102
 
106
- if tokens_after >= tokens_before:
107
- return req # no improvement
108
-
103
+ # S-01/Stage-9 fix: build new_bytes BEFORE the improvement guard.
104
+ # Layer 1 normalization saves characters (not word-count tokens), so
105
+ # tokens_after == tokens_before for "normalize" strategy. Checking byte
106
+ # length of the serialized body correctly detects Layer 1 savings.
109
107
  body["messages"] = new_messages
110
108
  new_bytes = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
111
109
 
110
+ bytes_saved = len(req.body_bytes) - len(new_bytes)
111
+ if bytes_saved <= 0 and tokens_after >= tokens_before:
112
+ return req # neither bytes nor tokens improved
113
+
112
114
  # CONTRACT §3: fire on_compress metrics callback
113
115
  lossy = strategy == "llmlingua2_prose"
114
116
  self.on_compress(tokens_before, tokens_after, lossy)
@@ -160,7 +162,12 @@ class CompressRouter:
160
162
  primary_strategy = "none"
161
163
  new_messages: list[dict[str, Any]] = []
162
164
 
163
- protect_indices = set(range(max(0, len(messages) - protect_recent), len(messages)))
165
+ # K-05: protect last N *user* turns, not last N messages of any role
166
+ user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
167
+ protect_indices: set[int] = set(user_indices[-protect_recent:]) if protect_recent > 0 else set()
168
+ # Always protect the very last message (current turn, any role)
169
+ if messages:
170
+ protect_indices.add(len(messages) - 1)
164
171
 
165
172
  for idx, msg in enumerate(messages):
166
173
  role = msg.get("role", "")
@@ -248,96 +255,77 @@ class CompressRouter:
248
255
  ) -> tuple[str, int, int, str]:
249
256
  tokens_before = _token_estimate(text)
250
257
 
251
- # JSON detection
258
+ if len(text) < _MIN_CHARS_FOR_COMPRESSION:
259
+ return text, tokens_before, tokens_before, "none"
260
+
261
+ # K-01/K-02/K-03: NEVER compress structured content (JSON or code)
252
262
  stripped = text.strip()
253
263
  if stripped.startswith(("{", "[")):
264
+ # PERF-02: for large content, structural bracket-match avoids O(n) json.loads().
265
+ # Conservative: matching outer brackets → treat as JSON and skip compression.
266
+ # K-01 mandate is safety-first: false-positive (non-JSON treated as JSON) is
267
+ # safe; false-negative (JSON compressed) would be a correctness violation.
268
+ _last = stripped[-1] if stripped else ""
269
+ if len(stripped) > 8192 and (
270
+ (stripped[0] == "{" and _last == "}") or (stripped[0] == "[" and _last == "]")
271
+ ):
272
+ return text, tokens_before, tokens_before, "none" # large JSON → passthrough
254
273
  try:
255
- parsed = json.loads(stripped)
256
- compressor = self._get_json_compressor()
257
- compressed = compressor.compress(parsed)
258
- tokens_after = _token_estimate_structured(compressed)
259
- tokens_before_adj = _token_estimate_structured(text)
260
- ratio = tokens_after / tokens_before_adj if tokens_before_adj else 1.0
261
- if ratio < _MIN_RATIO_STRUCTURED:
262
- # B-03: store-before-compress
263
- ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
264
- if ccr_id:
265
- try:
266
- obj = json.loads(compressed)
267
- if isinstance(obj, dict):
268
- obj["__slm_ccr__"] = ccr_id
269
- compressed = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
270
- elif isinstance(obj, list):
271
- # RB-02: list-root embedding
272
- wrapper = {"__slm_ccr__": ccr_id, "__slm_data__": obj}
273
- compressed = json.dumps(wrapper, ensure_ascii=False, separators=(",", ":"))
274
- except Exception:
275
- pass
276
- self._ccr_update_compressed(ccr_id, compressed.encode())
277
- logger.debug("[%s] JSON compressed %.2f ratio ccr_id=%s", request_id, ratio, ccr_id)
278
- return compressed, tokens_before_adj, tokens_after, "extractive_json"
279
- return text, tokens_before, tokens_before, "none"
280
- except (json.JSONDecodeError, Exception):
281
- pass
282
-
283
- # Code detection
284
- lang = _detect_language(text)
285
- if lang is not None:
286
- compressor = self._get_code_compressor()
287
- # RB-03: compress first, compute ratio, then store CCR only if beneficial
288
- compressed_probe = compressor.compress(text, language=lang, ccr_id="")
289
- tokens_after = _token_estimate(compressed_probe)
290
- ratio = tokens_after / tokens_before if tokens_before else 1.0
291
- if ratio < _MIN_RATIO_STRUCTURED:
292
- # B-03: store-before-compress
293
- ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
294
- compressed = compressor.compress(text, language=lang, ccr_id=ccr_id)
295
- if ccr_id:
296
- self._ccr_update_compressed(ccr_id, compressed.encode())
297
- logger.debug("[%s] Code compressed lang=%s ratio=%.2f ccr_id=%s",
298
- request_id, lang, ratio, ccr_id)
299
- return compressed, tokens_before, tokens_after, "extractive_code"
300
- return text, tokens_before, tokens_before, "none"
274
+ json.loads(stripped)
275
+ return text, tokens_before, tokens_before, "none" # valid JSON → passthrough
276
+ except json.JSONDecodeError:
277
+ pass # not valid JSON — treat as prose
278
+ except Exception as exc:
279
+ logger.warning("compress: unexpected error probing JSON content: %s", exc)
280
+
281
+ if _detect_language(text) is not None:
282
+ return text, tokens_before, tokens_before, "none" # code → passthrough
283
+
284
+ # Layer 1 — lossless whitespace normalization (always-on, safe)
285
+ normalized = self._normalize_whitespace(text)
286
+ tokens_after_l1 = _token_estimate(normalized)
301
287
 
302
- # Prose: only if aggressive mode AND compress_prose is enabled
303
- # (Phase 3 — opt-in prose tier; off by default; gated by config
304
- # field compress_prose added in LLD-04 INTERFACE-CONTRACT v2.)
288
+ # Layer 2 LLMLingua-2 prose compression (aggressive + opt-in only)
305
289
  cfg = self._get_config()
306
290
  prose_enabled = bool(getattr(cfg, "compress_prose", False))
307
- if aggressive and prose_enabled:
291
+ if aggressive and prose_enabled: # pragma: no cover — LLMLingua optional dep
308
292
  compressor = self._get_llmlingua_compressor()
309
293
  if compressor is not None:
310
- # B-03: store-before-compress
294
+ # B-03: store original BEFORE lossy compression
311
295
  ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
312
- compressed = compressor.compress(text)
296
+ compressed = compressor.compress(normalized)
313
297
  if ccr_id:
314
298
  self._ccr_update_compressed(ccr_id, compressed.encode())
315
- tokens_after = _token_estimate(compressed)
316
- logger.info(
317
- "[%s] LLMLingua-2 prose compressed rate=%.2f ccr_id=%s (LOSSY)",
318
- request_id,
319
- tokens_after / tokens_before if tokens_before else 1.0,
320
- ccr_id,
321
- )
322
- return compressed, tokens_before, tokens_after, "llmlingua2_prose"
299
+ tokens_after_l2 = _token_estimate(compressed)
300
+ if tokens_after_l2 < tokens_before:
301
+ logger.info(
302
+ "[%s] LLMLingua-2 prose compressed rate=%.2f ccr_id=%s (LOSSY)",
303
+ request_id,
304
+ tokens_after_l2 / tokens_before if tokens_before else 1.0,
305
+ ccr_id,
306
+ )
307
+ return compressed, tokens_before, tokens_after_l2, "llmlingua2_prose"
323
308
 
324
- return text, tokens_before, tokens_before, "none"
309
+ # S-01 fix: compare character length, not word count.
310
+ # _token_estimate() is word-count — whitespace normalization saves characters/bytes
311
+ # but never removes words, so token counts are identical before and after Layer 1.
312
+ # Character comparison correctly detects when normalization reduced the body size.
313
+ if len(normalized) < len(text):
314
+ return normalized, tokens_before, tokens_after_l1, "normalize"
325
315
 
326
- # ── Lazy loaders ─────────────────────────────────────────────────────
316
+ return text, tokens_before, tokens_before, "none"
327
317
 
328
- def _get_json_compressor(self) -> "JSONCompressor":
329
- if self._json_compressor is None:
330
- from superlocalmemory.optimize.compress.extractive_json import JSONCompressor
331
- self._json_compressor = JSONCompressor()
332
- return self._json_compressor
318
+ @staticmethod
319
+ def _normalize_whitespace(text: str) -> str:
320
+ """Layer 1 lossless: collapse excess blank lines, strip trailing spaces per line."""
321
+ import re
322
+ text = re.sub(r"\n{3,}", "\n\n", text)
323
+ lines = [line.rstrip() for line in text.split("\n")]
324
+ return "\n".join(lines)
333
325
 
334
- def _get_code_compressor(self) -> "CodeCompressor":
335
- if self._code_compressor is None:
336
- from superlocalmemory.optimize.compress.extractive_code import CodeCompressor
337
- self._code_compressor = CodeCompressor()
338
- return self._code_compressor
326
+ # ── Lazy loaders ─────────────────────────────────────────────────────
339
327
 
340
- def _get_llmlingua_compressor(self) -> "LLMLinguaCompressor | None":
328
+ def _get_llmlingua_compressor(self) -> "LLMLinguaCompressor | None": # pragma: no cover
341
329
  if self._llmlingua_compressor is None:
342
330
  try:
343
331
  from superlocalmemory.optimize.compress.prose_llmlingua import LLMLinguaCompressor
@@ -396,21 +384,32 @@ class CompressRouter:
396
384
  return CompressTextResult(
397
385
  compressed_text=compressed, strategy=strat,
398
386
  tokens_before=tb, tokens_after=ta,
387
+ lossy=strat == "llmlingua2_prose",
399
388
  )
400
389
  except Exception as exc:
401
390
  logger.debug("compress_text failed (non-fatal): %s", exc)
391
+ t = _token_estimate(text)
402
392
  return CompressTextResult(
403
393
  compressed_text=text, strategy="none",
404
- tokens_before=len(text.split()), tokens_after=len(text.split()),
394
+ tokens_before=t, tokens_after=t,
395
+ lossy=False,
405
396
  )
406
397
 
407
398
 
408
399
  @dataclass
409
400
  class CompressTextResult:
401
+ """Result of a compress_text() call.
402
+
403
+ UX-02 note: lossy=True only when strategy="llmlingua2_prose" (Layer 2).
404
+ In the default install (LLMLingua optional dep not installed), lossy is
405
+ always False — install `llmlingua>=0.2.0` and set compress_prose=True +
406
+ compress_mode="aggressive" to activate lossy compression.
407
+ """
410
408
  compressed_text: str
411
- strategy: str # "extractive_json" | "extractive_code" | "llmlingua2_prose" | "none"
409
+ strategy: str # "normalize" | "llmlingua2_prose" | "none"
412
410
  tokens_before: int
413
411
  tokens_after: int
412
+ lossy: bool = False # K-10: True only for llmlingua2_prose (Layer 2)
414
413
 
415
414
 
416
415
  # ── Module-level helpers ──────────────────────────────────────────────────────
@@ -419,10 +418,6 @@ def _token_estimate(text: str) -> int:
419
418
  return len(text.split()) if text else 0
420
419
 
421
420
 
422
- def _token_estimate_structured(text: str) -> int:
423
- return max(1, len(text) // 4) if text else 0
424
-
425
-
426
421
  def _msg_has_tool_result(msg: dict) -> bool:
427
422
  """B-09: Detect historical tool_result blocks in messages."""
428
423
  content = msg.get("content", "")
@@ -24,6 +24,22 @@ def load_optimize_config() -> OptimizeConfig:
24
24
  return get_optimize_config()
25
25
 
26
26
 
27
+ def get_shared_store() -> "ConfigStore":
28
+ """Return the process-wide ConfigStore singleton, creating it on first use.
29
+
30
+ This is the SINGLE instance the daemon, the /api/optimize routes, the proxy
31
+ hook-reload callback, and the hot-reload watchdog must all share so that a UI
32
+ config change reaches the live proxy (fixes W-02 hot-reload + W-05 fresh-store
33
+ -per-request). The daemon calls this at startup, registers a change callback,
34
+ and starts the watchdog.
35
+ """
36
+ global _store
37
+ if _store is None:
38
+ from superlocalmemory.optimize.config.store import ConfigStore
39
+ _store = ConfigStore()
40
+ return _store
41
+
42
+
27
43
  def _set_config_store(store: "ConfigStore") -> None:
28
44
  global _store
29
45
  _store = store
@@ -23,15 +23,10 @@ DEFAULT_OPTIMIZE_CONFIG = OptimizeConfig(
23
23
  semantic_boundary_floor=0.85,
24
24
  semantic_pad_latency_ms=0.0,
25
25
  semantic_centroid_min_similarity=0.85,
26
- compress_enabled=True,
26
+ compress_enabled=False,
27
27
  compress_mode="safe",
28
- compress_code=True,
29
- compress_json=True,
30
28
  compress_prose=False,
31
- compress_ccr=True,
32
- compress_align=True,
33
29
  compress_protect_recent=4,
34
- compress_llmlingua_allow_download=False,
35
30
  ttl_seconds=86400,
36
31
  ttl=TTLConfig(),
37
32
  providers={
@@ -89,15 +89,10 @@ class OptimizeConfig:
89
89
  semantic_centroid_min_similarity: float = 0.85
90
90
 
91
91
  # Compress
92
- compress_enabled: bool = True
92
+ compress_enabled: bool = False
93
93
  compress_mode: str = "safe"
94
- compress_code: bool = True
95
- compress_json: bool = True
96
94
  compress_prose: bool = False
97
- compress_ccr: bool = True
98
- compress_align: bool = True
99
95
  compress_protect_recent: int = 4
100
- compress_llmlingua_allow_download: bool = False
101
96
 
102
97
  # TTL + providers + pricing
103
98
  ttl_seconds: int = 86400
@@ -182,17 +177,10 @@ class OptimizeConfig:
182
177
  semantic_centroid_min_similarity=float(
183
178
  d.get("semantic_centroid_min_similarity", 0.85)
184
179
  ),
185
- compress_enabled=bool(d.get("compress_enabled", True)),
180
+ compress_enabled=bool(d.get("compress_enabled", False)),
186
181
  compress_mode=str(d.get("compress_mode", "safe")),
187
- compress_code=bool(d.get("compress_code", True)),
188
- compress_json=bool(d.get("compress_json", True)),
189
182
  compress_prose=bool(d.get("compress_prose", False)),
190
- compress_ccr=bool(d.get("compress_ccr", True)),
191
- compress_align=bool(d.get("compress_align", True)),
192
183
  compress_protect_recent=int(d.get("compress_protect_recent", 4)),
193
- compress_llmlingua_allow_download=bool(
194
- d.get("compress_llmlingua_allow_download", False)
195
- ),
196
184
  ttl_seconds=int(d.get("ttl_seconds", 86400)),
197
185
  providers=providers,
198
186
  pricing=dict(d.get("pricing", {})),
@@ -230,13 +218,8 @@ class OptimizeConfig:
230
218
  "semantic_centroid_min_similarity": self.semantic_centroid_min_similarity,
231
219
  "compress_enabled": self.compress_enabled,
232
220
  "compress_mode": self.compress_mode,
233
- "compress_code": self.compress_code,
234
- "compress_json": self.compress_json,
235
221
  "compress_prose": self.compress_prose,
236
- "compress_ccr": self.compress_ccr,
237
- "compress_align": self.compress_align,
238
222
  "compress_protect_recent": self.compress_protect_recent,
239
- "compress_llmlingua_allow_download": self.compress_llmlingua_allow_download,
240
223
  "ttl_seconds": self.ttl_seconds,
241
224
  "providers": {k: v.as_dict() for k, v in self.providers.items()},
242
225
  "pricing": self.pricing,
@@ -74,7 +74,13 @@ class ConfigStore:
74
74
  return self._current_config
75
75
 
76
76
  def save(self, config: OptimizeConfig) -> None:
77
- """Write config to optimize.json with version bump."""
77
+ """Write config to optimize.json with version bump.
78
+
79
+ Fires registered change callbacks immediately after a successful write
80
+ (outside the lock) so a UI/CLI save reaches the live proxy without
81
+ waiting for the 2s watchdog poll. The watchdog skips this write via
82
+ ``_saved_by_self`` so callbacks fire exactly once.
83
+ """
78
84
  config.validate()
79
85
  with self._lock:
80
86
  new_version = self._version + 1
@@ -93,6 +99,14 @@ class ConfigStore:
93
99
  self._version = new_version
94
100
  finally:
95
101
  self._saved_by_self = False
102
+ callbacks = list(self._change_callbacks)
103
+ # Fire callbacks OUTSIDE the lock — a callback may rebuild proxy hooks
104
+ # or call back into get(); keeping them off the lock avoids contention.
105
+ for cb in callbacks:
106
+ try:
107
+ cb(new_cfg)
108
+ except Exception as exc:
109
+ logger.warning("ConfigStore save callback error: %s", exc)
96
110
 
97
111
  def start_watchdog(self) -> None:
98
112
  """Start the background hot-reload watchdog thread.
@@ -24,7 +24,7 @@ class MetricsCollector:
24
24
  Hook binding (from INTERFACE-CONTRACT §3):
25
25
  on_hit(tokens_saved_input, tokens_saved_output): cache hit
26
26
  on_miss(): cache miss
27
- on_compress(bytes_original, bytes_after): compress ran
27
+ on_compress(tokens_before, tokens_after): compress ran (word-count proxy)
28
28
  on_eviction(): entry expired/evicted
29
29
  """
30
30
 
@@ -69,20 +69,28 @@ class MetricsCollector:
69
69
  with self._data_lock:
70
70
  self._misses += 1
71
71
 
72
- def on_compress(self, bytes_original: int, bytes_after: int) -> None:
73
- """Record a compression run with byte counts."""
72
+ def on_compress(self, tokens_before: int, tokens_after: int) -> None:
73
+ """Record a compression run. Arguments are word-count proxy estimates from _token_estimate().
74
+
75
+ M-03: consistent naming — these are token estimates, not byte counts.
76
+ Stored in compress_bytes_original/after fields for DB schema compat; unit is word-count.
77
+ """
74
78
  with self._data_lock:
75
79
  self._compress_runs += 1
76
- self._compress_bytes_original += max(0, bytes_original)
77
- self._compress_bytes_after += max(0, bytes_after)
78
- saved_tokens = max(0, bytes_original - bytes_after)
79
- self._tokens_saved_compress += saved_tokens
80
+ self._compress_bytes_original += max(0, tokens_before)
81
+ self._compress_bytes_after += max(0, tokens_after)
82
+ self._tokens_saved_compress += max(0, tokens_before - tokens_after)
80
83
 
81
84
  def on_eviction(self) -> None:
82
85
  """Record an eviction."""
83
86
  with self._data_lock:
84
87
  self._evictions += 1
85
88
 
89
+ def increment_skipped_temperature(self) -> None:
90
+ """C-04: record a cache skip due to non-zero temperature."""
91
+ with self._data_lock:
92
+ self._calls_skipped += 1
93
+
86
94
  def record_latency(self, ms: float) -> None:
87
95
  """Record latency overhead in milliseconds."""
88
96
  if ms < 0:
@@ -358,6 +358,7 @@ async def _stream_and_cache_forward(
358
358
  body_bytes: bytes,
359
359
  upstream_url: str,
360
360
  on_complete: "Callable[[bytes], Any] | None" = None,
361
+ max_accumulate: int | None = None,
361
362
  ) -> Response | StreamingResponse:
362
363
  """Stream-forward with optional post-stream cache-store callback.
363
364
 
@@ -388,10 +389,12 @@ async def _stream_and_cache_forward(
388
389
  )
389
390
 
390
391
  acc: list[bytes] = []
392
+ acc_bytes = 0
393
+ acc_capped = False
391
394
  complete_called = False
392
395
 
393
396
  async def _generate() -> AsyncIterator[bytes]:
394
- nonlocal complete_called
397
+ nonlocal complete_called, acc_bytes, acc_capped
395
398
  stream_error = False
396
399
  try:
397
400
  async with proxy.http_client.stream(
@@ -399,7 +402,17 @@ async def _stream_and_cache_forward(
399
402
  ) as upstream_resp:
400
403
  async for chunk in upstream_resp.aiter_bytes():
401
404
  if chunk:
402
- acc.append(chunk)
405
+ # Always forward to the client; only bound what we hold
406
+ # in memory for the on_complete callback (CWE-400).
407
+ if max_accumulate is None or acc_bytes < max_accumulate:
408
+ acc.append(chunk)
409
+ acc_bytes += len(chunk)
410
+ elif not acc_capped:
411
+ acc_capped = True
412
+ logger.debug(
413
+ "[%s] stream accumulator capped at %d bytes",
414
+ request_id, max_accumulate,
415
+ )
403
416
  yield chunk
404
417
  except httpx.RemoteProtocolError as exc:
405
418
  stream_error = True
@@ -447,6 +460,91 @@ async def _stream_and_cache_forward(
447
460
  )
448
461
 
449
462
 
463
+ async def capture_passthrough_forward(
464
+ proxy: Any,
465
+ request: Request,
466
+ *,
467
+ provider: str,
468
+ upstream_url: str,
469
+ allowed_headers: frozenset,
470
+ request_id: str,
471
+ model_hint: str = "",
472
+ sse_parser: "Callable[[bytes], bytes | None] | None" = None,
473
+ is_stream: bool = False,
474
+ ) -> Response | StreamingResponse:
475
+ """Shadow-capture passthrough (v3.6.10, plan §7).
476
+
477
+ Pure passthrough to upstream + record the exchange to the capture corpus.
478
+ NO cache, NO compression — capture mode observes only authentic traffic.
479
+ Fail-open: a capture or forward error degrades to a normal forward/error
480
+ response; the user's request is never blocked by capture.
481
+ """
482
+ from superlocalmemory.optimize.proxy.capture import (
483
+ extract_usage,
484
+ record_exchange_async,
485
+ )
486
+
487
+ body_bytes = await request.body()
488
+ fwd_headers = _build_forward_headers(request, allowed_headers)
489
+ fwd_headers["content-length"] = str(len(body_bytes))
490
+
491
+ if is_stream:
492
+ async def _on_complete(acc: bytes) -> None:
493
+ parsed = sse_parser(acc) if sse_parser else None
494
+ payload = parsed if parsed is not None else acc
495
+ itok, otok, mdl = extract_usage(provider, parsed)
496
+ await record_exchange_async(
497
+ provider=provider,
498
+ model=mdl or model_hint,
499
+ request_body=body_bytes,
500
+ response_body=payload,
501
+ content_type="text/event-stream",
502
+ input_tokens=itok,
503
+ output_tokens=otok,
504
+ status_code=200,
505
+ stream=True,
506
+ )
507
+
508
+ # Bound the in-memory accumulator (CWE-400): the corpus only keeps the
509
+ # first 1 MB per side anyway, so cap accumulation there.
510
+ from superlocalmemory.optimize.proxy.capture import _MAX_CAPTURE_BODY_BYTES
511
+ return await _stream_and_cache_forward(
512
+ proxy, request_id, fwd_headers, body_bytes, upstream_url,
513
+ on_complete=_on_complete,
514
+ max_accumulate=_MAX_CAPTURE_BODY_BYTES,
515
+ )
516
+
517
+ if proxy.http_client is None:
518
+ return await _fail_open_forward(proxy, request, upstream_url)
519
+ try:
520
+ upstream_resp = await proxy.http_client.post(
521
+ upstream_url, content=body_bytes, headers=fwd_headers,
522
+ )
523
+ except Exception as exc:
524
+ logger.error("[%s] capture passthrough upstream error: %r", request_id, exc)
525
+ return await _fail_open_forward(proxy, request, upstream_url)
526
+
527
+ resp_bytes = upstream_resp.content
528
+ itok, otok, mdl = extract_usage(provider, resp_bytes)
529
+ await record_exchange_async(
530
+ provider=provider,
531
+ model=mdl or model_hint,
532
+ request_body=body_bytes,
533
+ response_body=resp_bytes,
534
+ content_type="application/json",
535
+ input_tokens=itok,
536
+ output_tokens=otok,
537
+ status_code=upstream_resp.status_code,
538
+ stream=False,
539
+ )
540
+ return Response(
541
+ content=resp_bytes,
542
+ status_code=upstream_resp.status_code,
543
+ media_type="application/json",
544
+ headers=_filter_response_headers(dict(upstream_resp.headers)),
545
+ )
546
+
547
+
450
548
  async def _safe_cache_check(hooks: HookChain, ctx: ProxyRequest) -> CachedResponse:
451
549
  try:
452
550
  result = hooks.cache.check(ctx)
@@ -25,7 +25,9 @@ from superlocalmemory.optimize.proxy._helpers import (
25
25
  _safe_compress,
26
26
  _stream_and_cache_forward,
27
27
  _stream_forward,
28
+ capture_passthrough_forward,
28
29
  )
30
+ from superlocalmemory.optimize.proxy.capture import capture_enabled
29
31
  from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
30
32
 
31
33
  logger = logging.getLogger("slm.optimize.proxy.anthropic")
@@ -192,6 +194,16 @@ async def handle_messages(proxy: object, request: Request) -> Response:
192
194
 
193
195
  stream = bool(body.get("stream", False))
194
196
  has_tools = _body_has_tools(body)
197
+
198
+ # v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
199
+ if capture_enabled():
200
+ return await capture_passthrough_forward(
201
+ proxy, request, provider="anthropic", upstream_url=upstream_url,
202
+ allowed_headers=_ANTHROPIC_FORWARD_HEADERS, request_id=request_id,
203
+ model_hint=str(body.get("model", "")),
204
+ sse_parser=_parse_sse_to_json, is_stream=stream,
205
+ )
206
+
195
207
  ctx = ProxyRequest(
196
208
  provider="anthropic",
197
209
  method="POST",