claude-smart 0.2.47 → 0.2.49

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 (139) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  8. package/plugin/dashboard/package-lock.json +61 -390
  9. package/plugin/dashboard/package.json +2 -2
  10. package/plugin/opencode/dist/server.mjs +60 -2
  11. package/plugin/opencode/server.mts +64 -2
  12. package/plugin/pyproject.toml +2 -2
  13. package/plugin/scripts/_lib.sh +58 -6
  14. package/plugin/scripts/backend-service.sh +15 -10
  15. package/plugin/scripts/codex-hook.js +16 -4
  16. package/plugin/scripts/dashboard-service.sh +1 -1
  17. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  18. package/plugin/scripts/opencode-claude-compat.js +8 -1
  19. package/plugin/scripts/smart-install.sh +101 -20
  20. package/plugin/src/README.md +1 -1
  21. package/plugin/src/claude_smart/cli.py +79 -29
  22. package/plugin/src/claude_smart/env_config.py +53 -11
  23. package/plugin/uv.lock +5 -5
  24. package/plugin/vendor/reflexio/.env.example +7 -0
  25. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  26. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  27. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  28. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  29. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  30. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  31. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  33. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  34. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  35. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  36. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  37. package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
  38. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  39. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  40. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  41. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  42. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  43. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  44. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  45. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  46. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  47. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  48. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
  49. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  50. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  51. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  52. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  53. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  54. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  55. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  56. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  57. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  58. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  59. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  60. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
  61. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  62. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  63. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  64. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  66. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  68. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  73. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  74. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  76. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  77. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
  81. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
  82. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  83. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  84. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  88. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  89. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +25 -12
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -62,14 +62,22 @@ _ENV_TIMEOUT = "CLAUDE_SMART_CLI_TIMEOUT"
62
62
  _ENV_MODEL = "CLAUDE_SMART_CLI_MODEL"
63
63
  _HOST_CODEX = "codex"
64
64
  _HOST_CLAUDE_CODE = "claude-code"
65
+
66
+
67
+ def _is_windows() -> bool:
68
+ return os.name == "nt"
69
+
70
+
65
71
  _CODEX_COMPAT_SCRIPT_NAMES = (
66
72
  ("codex-claude-compat.cmd", "codex-claude-compat")
67
- if os.name == "nt"
73
+ if _is_windows()
68
74
  else ("codex-claude-compat", "codex-claude-compat.cmd")
69
75
  )
70
76
  _CODEX_COMPAT_SCRIPT_NAME_SET = set(_CODEX_COMPAT_SCRIPT_NAMES)
71
77
  _DEFAULT_TIMEOUT_SECONDS = 120
72
78
  _DEFAULT_CLI_MODEL = "claude-sonnet-5"
79
+ _WINDOWS_ARGV_SYSTEM_PROMPT_LIMIT = 3_000
80
+ _WINDOWS_CLI_SUFFIXES = (".cmd", ".exe", ".bat")
73
81
 
74
82
  _TRUTHY_ENV_VALUES = {"1", "true", "yes"}
75
83
  _UNSUPPORTED_PARAMS_WARNED: set[str] = set()
@@ -116,6 +124,40 @@ def _candidate_codex_compat_path() -> Path | None:
116
124
  return None
117
125
 
118
126
 
127
+ def _windows_cli_suffixes() -> tuple[str, ...]:
128
+ suffixes: list[str] = []
129
+ for suffix in os.environ.get("PATHEXT", "").split(";"):
130
+ suffix = suffix.strip().lower()
131
+ if not suffix:
132
+ continue
133
+ suffix = suffix if suffix.startswith(".") else f".{suffix}"
134
+ # PowerShell scripts require a PowerShell host; do not return them as
135
+ # directly executable CLI shims for subprocess.run([...]).
136
+ if suffix == ".ps1":
137
+ continue
138
+ suffixes.append(suffix)
139
+ for suffix in _WINDOWS_CLI_SUFFIXES:
140
+ if suffix not in suffixes:
141
+ suffixes.append(suffix)
142
+ return tuple(suffixes)
143
+
144
+
145
+ def _resolve_cli_override_path(cli_path: str) -> str:
146
+ if not _is_windows():
147
+ return cli_path
148
+ path = Path(cli_path)
149
+ if path.suffix:
150
+ return cli_path
151
+
152
+ # Windows package managers expose extensioned shims while users often
153
+ # configure the extensionless binary name.
154
+ for suffix in _windows_cli_suffixes():
155
+ candidate = path.with_suffix(suffix)
156
+ if candidate.exists():
157
+ return str(candidate)
158
+ return cli_path
159
+
160
+
119
161
  def _resolve_cli_path() -> str | None:
120
162
  """Return the path to the active host CLI, or None if unavailable.
121
163
 
@@ -129,7 +171,7 @@ def _resolve_cli_path() -> str | None:
129
171
  """
130
172
  override = os.environ.get(_ENV_CLI_PATH)
131
173
  if override:
132
- candidate = Path(override)
174
+ candidate = Path(_resolve_cli_override_path(override))
133
175
  if candidate.is_file() and os.access(candidate, os.X_OK):
134
176
  return str(candidate)
135
177
  _LOGGER.warning(
@@ -432,6 +474,9 @@ def _run_claude_stream(
432
474
  ) -> ParseResult:
433
475
  """Invoke ``claude -p --output-format stream-json`` and return a ParseResult."""
434
476
  model = os.environ.get(_ENV_MODEL) or _DEFAULT_CLI_MODEL
477
+ inline_system_prompt = (
478
+ _is_windows() and len(system_prompt) > _WINDOWS_ARGV_SYSTEM_PROMPT_LIMIT
479
+ )
435
480
  cmd = [
436
481
  cli_path,
437
482
  "-p",
@@ -442,8 +487,11 @@ def _run_claude_stream(
442
487
  "--model",
443
488
  model,
444
489
  ]
445
- if system_prompt:
490
+ if system_prompt and not inline_system_prompt:
446
491
  cmd.extend(["--append-system-prompt", system_prompt])
492
+ stdin_text = dialogue
493
+ if inline_system_prompt:
494
+ stdin_text = f"{system_prompt}\n\n{dialogue}" if dialogue else system_prompt
447
495
 
448
496
  # Tag the child process so any hooks it fires (e.g. claude-smart's
449
497
  # Stop hook) can detect that this is a reflexio-internal invocation
@@ -460,9 +508,11 @@ def _run_claude_stream(
460
508
  try:
461
509
  proc = subprocess.run( # noqa: S603 — cmd is constructed from validated parts.
462
510
  cmd,
463
- input=dialogue,
511
+ input=stdin_text,
464
512
  capture_output=True,
465
513
  text=True,
514
+ encoding="utf-8",
515
+ errors="replace" if _is_windows() else "strict",
466
516
  timeout=timeout_seconds,
467
517
  check=False,
468
518
  env=env,
@@ -511,6 +561,8 @@ def _run_codex_stream(
511
561
  input=_codex_prompt(prompt=dialogue, system_prompt=system_prompt),
512
562
  capture_output=True,
513
563
  text=True,
564
+ encoding="utf-8",
565
+ errors="replace" if _is_windows() else "strict",
514
566
  timeout=timeout_seconds,
515
567
  check=False,
516
568
  env=env,
@@ -18,9 +18,23 @@ from __future__ import annotations
18
18
  import importlib.util
19
19
  import logging
20
20
  import os
21
+ import shutil
21
22
  import threading
23
+ from collections.abc import Iterator
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
22
26
  from typing import Any
23
27
 
28
+ try:
29
+ import fcntl
30
+ except ImportError: # pragma: no cover - Windows only
31
+ fcntl = None # type: ignore[assignment]
32
+
33
+ try:
34
+ import msvcrt
35
+ except ImportError: # pragma: no cover - POSIX only
36
+ msvcrt = None # type: ignore[assignment]
37
+
24
38
  _LOGGER = logging.getLogger(__name__)
25
39
 
26
40
  _ENV_ENABLE = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
@@ -36,6 +50,17 @@ _TARGET_DIM = 512
36
50
  # ValueError that ONNXMiniLM_L6_V2 throws on over-length input.
37
51
  _MAX_CHARS = 800
38
52
 
53
+ # Chroma keeps this file list private inside ONNXMiniLM_L6_V2's download helper.
54
+ # Keep this in sync with chromadb's all-MiniLM-L6-v2 extracted archive layout.
55
+ _MINILM_EXPECTED_FILES = (
56
+ "config.json",
57
+ "model.onnx",
58
+ "special_tokens_map.json",
59
+ "tokenizer_config.json",
60
+ "tokenizer.json",
61
+ "vocab.txt",
62
+ )
63
+
39
64
 
40
65
  class LocalEmbedderError(RuntimeError):
41
66
  """Raised when the local embedder is called without chromadb installed."""
@@ -98,9 +123,54 @@ class LocalEmbedder:
98
123
  """
99
124
  ef = self._load()
100
125
  safe_inputs = [(text or "")[:_MAX_CHARS] for text in texts]
101
- raw = ef(safe_inputs)
126
+ try:
127
+ raw = ef(safe_inputs)
128
+ except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
129
+ raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
102
130
  return [_pad(vec) for vec in raw]
103
131
 
132
+ def _retry_embed_after_cache_clear(
133
+ self, failed_ef: Any, exc: Exception, safe_inputs: list[str]
134
+ ) -> Any:
135
+ with self._ef_lock:
136
+ if self._ef is not None and self._ef is not failed_ef:
137
+ return self._ef(safe_inputs)
138
+
139
+ embedding_cls = type(failed_ef)
140
+ recovery = _recoverable_minilm_cache(embedding_cls, exc)
141
+ if recovery is None:
142
+ raise exc
143
+ cache_path, cache_was_complete = recovery
144
+
145
+ with _exclusive_file_lock(_minilm_cache_lock_path(cache_path)):
146
+ if _should_clear_minilm_cache(
147
+ embedding_cls, cache_path, exc, cache_was_complete
148
+ ):
149
+ shutil.rmtree(cache_path, ignore_errors=True)
150
+ recovery_action = "clearing"
151
+ _LOGGER.warning(
152
+ "Failed to use local MiniLM embedder; cleared cache %s "
153
+ "and retrying once",
154
+ cache_path,
155
+ exc_info=True,
156
+ )
157
+ else:
158
+ recovery_action = "waiting for another process to refresh"
159
+ _LOGGER.info(
160
+ "MiniLM cache %s was refreshed by another process; retrying",
161
+ cache_path,
162
+ )
163
+ try:
164
+ fresh_ef = embedding_cls()
165
+ self._ef = fresh_ef
166
+ return fresh_ef(safe_inputs)
167
+ except Exception as retry_exc:
168
+ raise LocalEmbedderError(
169
+ "Local MiniLM cache recovery failed after "
170
+ f"{recovery_action} {cache_path}. Delete this cache "
171
+ "directory, restart Reflexio, and retry local embedding."
172
+ ) from retry_exc
173
+
104
174
 
105
175
  def _pad(vec: Any) -> list[float]:
106
176
  """Zero-pad a 384-dim vector to ``_TARGET_DIM`` as a plain list[float]."""
@@ -113,6 +183,118 @@ def _pad(vec: Any) -> list[float]:
113
183
  return floats + [0.0] * (_TARGET_DIM - len(floats))
114
184
 
115
185
 
186
+ def _recoverable_minilm_cache(
187
+ embedding_cls: Any, exc: Exception
188
+ ) -> tuple[Path, bool] | None:
189
+ cache_path = _minilm_cache_path(embedding_cls)
190
+ if cache_path is None:
191
+ return None
192
+ cache_was_complete = _minilm_cache_complete(embedding_cls, cache_path)
193
+ if cache_was_complete and not _complete_minilm_cache_error_identifies_cache(
194
+ embedding_cls, cache_path, exc
195
+ ):
196
+ return None
197
+
198
+ return cache_path, cache_was_complete
199
+
200
+
201
+ def _minilm_cache_path(embedding_cls: Any) -> Path | None:
202
+ download_path = getattr(embedding_cls, "DOWNLOAD_PATH", None)
203
+ if not download_path:
204
+ return None
205
+
206
+ model_name = getattr(embedding_cls, "MODEL_NAME", None)
207
+ cache_path = Path(str(download_path)).expanduser()
208
+ if not model_name or cache_path.name != model_name:
209
+ return None
210
+
211
+ return cache_path
212
+
213
+
214
+ def _minilm_cache_lock_path(cache_path: Path) -> Path:
215
+ return cache_path.parent / f".{cache_path.name}.lock"
216
+
217
+
218
+ @contextmanager
219
+ def _exclusive_file_lock(lock_path: Path) -> Iterator[None]:
220
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
221
+ with lock_path.open("a+b") as lock_file:
222
+ lock_file.seek(0)
223
+ if lock_file.read(1) == b"":
224
+ lock_file.write(b"\0")
225
+ lock_file.flush()
226
+ lock_file.seek(0)
227
+
228
+ if fcntl is not None:
229
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
230
+ try:
231
+ yield
232
+ finally:
233
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
234
+ elif msvcrt is not None:
235
+ msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
236
+ lock_file.fileno(),
237
+ msvcrt.LK_LOCK, # type: ignore[reportAttributeAccessIssue]
238
+ 1,
239
+ )
240
+ try:
241
+ yield
242
+ finally:
243
+ lock_file.seek(0)
244
+ msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
245
+ lock_file.fileno(),
246
+ msvcrt.LK_UNLCK, # type: ignore[reportAttributeAccessIssue]
247
+ 1,
248
+ )
249
+ else:
250
+ yield
251
+
252
+
253
+ def _should_clear_minilm_cache(
254
+ embedding_cls: Any, cache_path: Path, exc: Exception, cache_was_complete: bool
255
+ ) -> bool:
256
+ cache_is_complete = _minilm_cache_complete(embedding_cls, cache_path)
257
+ if not cache_is_complete:
258
+ return True
259
+
260
+ if not cache_was_complete:
261
+ return False
262
+
263
+ return _complete_minilm_cache_error_identifies_cache(embedding_cls, cache_path, exc)
264
+
265
+
266
+ def _complete_minilm_cache_error_identifies_cache(
267
+ embedding_cls: Any, cache_path: Path, exc: Exception
268
+ ) -> bool:
269
+ chain = _exception_chain(exc)
270
+ archive_name = str(getattr(embedding_cls, "ARCHIVE_FILENAME", ""))
271
+ messages = "\n".join(str(chain_exc) for chain_exc in chain)
272
+ return (
273
+ str(cache_path) in messages
274
+ or bool(archive_name and archive_name in messages)
275
+ or "does not match expected SHA256" in messages
276
+ )
277
+
278
+
279
+ def _minilm_cache_complete(embedding_cls: Any, cache_path: Path) -> bool:
280
+ extracted_folder = str(getattr(embedding_cls, "EXTRACTED_FOLDER_NAME", "onnx"))
281
+ return all(
282
+ (cache_path / extracted_folder / file_name).exists()
283
+ for file_name in _MINILM_EXPECTED_FILES
284
+ )
285
+
286
+
287
+ def _exception_chain(exc: Exception) -> list[BaseException]:
288
+ chain: list[BaseException] = []
289
+ seen: set[int] = set()
290
+ current: BaseException | None = exc
291
+ while current is not None and id(current) not in seen:
292
+ seen.add(id(current))
293
+ chain.append(current)
294
+ current = current.__cause__ or current.__context__
295
+ return chain
296
+
297
+
116
298
  _REGISTERED = False
117
299
 
118
300
 
@@ -0,0 +1,244 @@
1
+ """HTTP middleware for the OSS FastAPI app (Tier3 A2 extraction from api.py).
2
+
3
+ Holds the five middleware classes ``create_app`` wires in (in that order) plus
4
+ the request-limit / CORS-origin helpers and their tunable constants. No
5
+ middleware reads mutable module-global app state — every read is of an
6
+ import-time constant or an env var — so this extraction is behavior-preserving.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+
13
+ from anyio.to_thread import current_default_thread_limiter
14
+ from fastapi import Request, status
15
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
16
+ from starlette.responses import Response
17
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
18
+
19
+ from reflexio.server.correlation import correlation_id_var, generate_correlation_id
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Bot protection configuration
24
+ REQUEST_TIMEOUT_SECONDS = 60
25
+ SYNC_REQUEST_TIMEOUT_SECONDS = (
26
+ 600 # Longer timeout for synchronous processing (wait_for_response=true)
27
+ )
28
+ SUSPICIOUS_USER_AGENTS = ["bot", "crawler", "spider", "scraper", "curl", "wget"]
29
+ ALLOWED_EMPTY_UA_PATHS = ["/health", "/"] # Paths that allow empty user agents
30
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
31
+
32
+
33
+ def _resolve_cors_origins() -> list[str]:
34
+ """Resolve browser origins allowed to make credentialed CORS requests."""
35
+ configured_origins = os.getenv("REFLEXIO_ALLOWED_ORIGINS", "").strip()
36
+ if configured_origins:
37
+ origins = [
38
+ origin.strip().rstrip("/")
39
+ for origin in configured_origins.split(",")
40
+ if origin.strip()
41
+ ]
42
+ return origins or ["http://localhost:8080"]
43
+
44
+ frontend_url = os.getenv("FRONTEND_URL", "").strip()
45
+ if frontend_url:
46
+ return [frontend_url.rstrip("/")]
47
+
48
+ return ["http://localhost:8080"]
49
+
50
+
51
+ def _max_body_bytes_from_env() -> int:
52
+ raw_value = os.getenv("REFLEXIO_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
53
+ try:
54
+ max_bytes = int(raw_value)
55
+ except ValueError:
56
+ logger.warning(
57
+ "Ignoring invalid REFLEXIO_MAX_BODY_BYTES=%r; using %s",
58
+ raw_value,
59
+ DEFAULT_MAX_BODY_BYTES,
60
+ )
61
+ return DEFAULT_MAX_BODY_BYTES
62
+ if max_bytes <= 0:
63
+ logger.warning(
64
+ "Ignoring non-positive REFLEXIO_MAX_BODY_BYTES=%r; using %s",
65
+ raw_value,
66
+ DEFAULT_MAX_BODY_BYTES,
67
+ )
68
+ return DEFAULT_MAX_BODY_BYTES
69
+ return max_bytes
70
+
71
+
72
+ class BotProtectionMiddleware(BaseHTTPMiddleware):
73
+ """Middleware to detect and block suspicious bot-like requests."""
74
+
75
+ async def dispatch(
76
+ self, request: Request, call_next: RequestResponseEndpoint
77
+ ) -> Response:
78
+ """Process request and block suspicious patterns.
79
+
80
+ Args:
81
+ request (Request): The incoming request
82
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
83
+
84
+ Returns:
85
+ Response: The response from the next handler or a 403 JSON response
86
+ """
87
+ from starlette.responses import JSONResponse
88
+
89
+ user_agent = request.headers.get("user-agent", "").lower()
90
+ path = request.url.path
91
+
92
+ # Allow health check and root without user agent
93
+ if path not in ALLOWED_EMPTY_UA_PATHS:
94
+ # Block requests with no user agent
95
+ if not user_agent:
96
+ return JSONResponse(
97
+ status_code=status.HTTP_403_FORBIDDEN,
98
+ content={"detail": "Forbidden: Missing user agent"},
99
+ )
100
+
101
+ # Block requests with suspicious user agents
102
+ for suspicious in SUSPICIOUS_USER_AGENTS:
103
+ if suspicious in user_agent:
104
+ return JSONResponse(
105
+ status_code=status.HTTP_403_FORBIDDEN,
106
+ content={"detail": "Forbidden: Suspicious user agent"},
107
+ )
108
+
109
+ return await call_next(request)
110
+
111
+
112
+ class TimeoutMiddleware(BaseHTTPMiddleware):
113
+ """Middleware to enforce request timeout."""
114
+
115
+ async def dispatch(
116
+ self, request: Request, call_next: RequestResponseEndpoint
117
+ ) -> Response:
118
+ """Process request with timeout enforcement.
119
+
120
+ Args:
121
+ request (Request): The incoming request
122
+ call_next (RequestResponseEndpoint): Next middleware/handler in chain
123
+
124
+ Returns:
125
+ Response: The response from the next handler or a 504 JSON response
126
+ """
127
+ from starlette.responses import JSONResponse
128
+
129
+ # Use longer timeout for synchronous processing requests
130
+ timeout = REQUEST_TIMEOUT_SECONDS
131
+ if request.query_params.get("wait_for_response", "").lower() == "true":
132
+ timeout = SYNC_REQUEST_TIMEOUT_SECONDS
133
+
134
+ try:
135
+ return await asyncio.wait_for(call_next(request), timeout=timeout)
136
+ except TimeoutError:
137
+ return JSONResponse(
138
+ status_code=status.HTTP_504_GATEWAY_TIMEOUT,
139
+ content={"detail": "Request timeout"},
140
+ )
141
+
142
+
143
+ class _RequestBodyTooLargeError(Exception):
144
+ """Raised when the streamed request body exceeds the configured limit."""
145
+
146
+
147
+ class BodySizeLimitMiddleware:
148
+ """Reject requests whose declared or streamed body size exceeds the limit."""
149
+
150
+ def __init__(self, app: ASGIApp) -> None:
151
+ self.app = app
152
+
153
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
154
+ from starlette.responses import JSONResponse
155
+
156
+ if scope["type"] != "http":
157
+ await self.app(scope, receive, send)
158
+ return
159
+
160
+ max_body_bytes = _max_body_bytes_from_env()
161
+ content_length = None
162
+ for name, value in scope.get("headers", []):
163
+ if name.lower() == b"content-length":
164
+ content_length = value.decode("latin-1")
165
+ break
166
+
167
+ if content_length is not None:
168
+ try:
169
+ body_bytes = int(content_length)
170
+ except ValueError:
171
+ body_bytes = 0
172
+ if body_bytes > max_body_bytes:
173
+ await JSONResponse(
174
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
175
+ content={"detail": "Request body too large"},
176
+ )(scope, receive, send)
177
+ return
178
+
179
+ consumed_bytes = 0
180
+
181
+ async def limited_receive() -> Message:
182
+ nonlocal consumed_bytes
183
+ message = await receive()
184
+ if message["type"] == "http.request":
185
+ consumed_bytes += len(message.get("body", b""))
186
+ if consumed_bytes > max_body_bytes:
187
+ raise _RequestBodyTooLargeError
188
+ return message
189
+
190
+ try:
191
+ await self.app(scope, limited_receive, send)
192
+ except _RequestBodyTooLargeError:
193
+ await JSONResponse(
194
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
195
+ content={"detail": "Request body too large"},
196
+ )(scope, receive, send)
197
+
198
+
199
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
200
+ """Attach conservative browser security headers to every response."""
201
+
202
+ async def dispatch(
203
+ self, request: Request, call_next: RequestResponseEndpoint
204
+ ) -> Response:
205
+ response = await call_next(request)
206
+ response.headers["X-Content-Type-Options"] = "nosniff"
207
+ response.headers["X-Frame-Options"] = "DENY"
208
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
209
+ if (
210
+ request.url.scheme == "https"
211
+ or request.headers.get("x-forwarded-proto", "").lower() == "https"
212
+ ):
213
+ response.headers["Strict-Transport-Security"] = (
214
+ "max-age=31536000; includeSubDomains"
215
+ )
216
+ return response
217
+
218
+
219
+ class CorrelationIdMiddleware(BaseHTTPMiddleware):
220
+ """Middleware that assigns a unique correlation ID to each request.
221
+
222
+ The ID is stored in a ContextVar so it propagates to log records
223
+ (via CorrelationIdFilter) and to ThreadPoolExecutor workers when
224
+ ``contextvars.copy_context()`` is used.
225
+ """
226
+
227
+ async def dispatch(
228
+ self, request: Request, call_next: RequestResponseEndpoint
229
+ ) -> Response:
230
+ cid = generate_correlation_id()
231
+ correlation_id_var.set(cid)
232
+ try:
233
+ stats = current_default_thread_limiter().statistics()
234
+ request.state.tp_borrowed = stats.borrowed_tokens
235
+ request.state.tp_total = stats.total_tokens
236
+ request.state.tp_waiting = stats.tasks_waiting
237
+ except Exception as exc: # noqa: BLE001
238
+ logger.debug("Failed to snapshot threadpool limiter stats: %s", exc)
239
+ request.state.tp_borrowed = None
240
+ request.state.tp_total = None
241
+ request.state.tp_waiting = None
242
+ response = await call_next(request)
243
+ response.headers["X-Correlation-ID"] = cid
244
+ return response
@@ -62,6 +62,11 @@ def _limit_for(operation: OperationName) -> int:
62
62
  return _DEFAULT_LIMITS[operation]
63
63
 
64
64
 
65
+ def operation_limit_value(operation: OperationName) -> int:
66
+ """Return the configured concurrency limit for an operation."""
67
+ return _limit_for(operation)
68
+
69
+
65
70
  def _timeout_for(operation: OperationName) -> float:
66
71
  raw = os.getenv(_env_key(operation, "TIMEOUT_SECONDS"), "").strip()
67
72
  if raw:
@@ -403,6 +408,7 @@ def operation_limit(
403
408
  *,
404
409
  timeout_seconds: float | None = None,
405
410
  wait_forever: bool = False,
411
+ log_timeout: bool = True,
406
412
  ) -> Any:
407
413
  """Acquire the per-org limiter for an operation, recording wait metrics."""
408
414
  state = _state_for(org_id, operation)
@@ -448,7 +454,7 @@ def operation_limit(
448
454
  limit=state.limit,
449
455
  active=active,
450
456
  )
451
- if operation == "publish":
457
+ if operation == "publish" and log_timeout:
452
458
  logger.warning(
453
459
  "publish_limiter_timeout org_id=%s limit=%s active=%s "
454
460
  "waiting=%s wait_ms=%s timeout_seconds=%s hardware=%s",
@@ -504,12 +510,14 @@ def run_with_operation_limit[T](
504
510
  fn: Callable[[], T],
505
511
  timeout_seconds: float | None = None,
506
512
  wait_forever: bool = False,
513
+ log_timeout: bool = True,
507
514
  ) -> T:
508
515
  with operation_limit(
509
516
  org_id,
510
517
  operation,
511
518
  timeout_seconds=timeout_seconds,
512
519
  wait_forever=wait_forever,
520
+ log_timeout=log_timeout,
513
521
  ):
514
522
  return fn()
515
523
 
@@ -0,0 +1,79 @@
1
+ """Shared slowapi rate limiter for the OSS FastAPI app (Tier3 A2).
2
+
3
+ The ``limiter`` singleton and ``configure_rate_limiter`` live here so the domain
4
+ route modules can decorate handlers with ``@limiter.limit(...)`` without importing
5
+ from ``reflexio.server.api`` (which would be circular — ``api`` aggregates the
6
+ route modules). ``reflexio.server.api`` re-exports ``limiter`` and
7
+ ``configure_rate_limiter`` so the existing enterprise imports keep resolving.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from fastapi import Request
14
+ from slowapi import Limiter
15
+ from slowapi.util import get_remote_address
16
+
17
+ from reflexio.server.tracing import profile_step
18
+
19
+
20
+ def get_rate_limit_key(request: Request) -> str:
21
+ """Get rate limit key based on IP address.
22
+
23
+ Args:
24
+ request (Request): The incoming request
25
+
26
+ Returns:
27
+ str: Rate limit key (IP address)
28
+ """
29
+ return get_remote_address(request)
30
+
31
+
32
+ def _storage_backend_name(limiter_obj: Limiter) -> str:
33
+ storage = getattr(limiter_obj, "_storage", None)
34
+ if storage is None:
35
+ return "unknown"
36
+ return storage.__class__.__name__
37
+
38
+
39
+ def _trace_external_rate_limit_backend(limiter_obj: Limiter) -> None:
40
+ """Trace rate-limit storage hits when the backend is an external service."""
41
+ backend = _storage_backend_name(limiter_obj)
42
+ if backend == "MemoryStorage":
43
+ return
44
+
45
+ strategy = getattr(limiter_obj, "limiter", None)
46
+ if strategy is None or getattr(strategy, "_reflexio_traced", False):
47
+ return
48
+
49
+ original_hit = strategy.hit
50
+
51
+ def traced_hit(item: Any, *identifiers: str, cost: int = 1) -> bool:
52
+ with profile_step(
53
+ "rate_limit.backend_hit",
54
+ storage_backend=backend,
55
+ strategy=strategy.__class__.__name__,
56
+ cost=cost,
57
+ ):
58
+ return original_hit(item, *identifiers, cost=cost)
59
+
60
+ strategy.hit = traced_hit
61
+ strategy._reflexio_traced = True
62
+
63
+
64
+ # Initialize rate limiter
65
+ limiter = Limiter(key_func=get_rate_limit_key)
66
+ _trace_external_rate_limit_backend(limiter)
67
+
68
+
69
+ def configure_rate_limiter(key_func: Callable[..., str]) -> None:
70
+ """
71
+ Replace the rate limiter's key function.
72
+
73
+ This is the supported way to override the default IP-based key function
74
+ (e.g. with an org-scoped or token-scoped variant in the enterprise layer).
75
+
76
+ Args:
77
+ key_func: A callable that accepts a Request and returns a string key.
78
+ """
79
+ limiter._key_func = key_func # type: ignore[reportAttributeAccessIssue]