claude-smart 0.2.43 → 0.2.45

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 (68) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +2 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +21 -1
  8. package/plugin/pyproject.toml +3 -3
  9. package/plugin/scripts/_lib.sh +72 -28
  10. package/plugin/scripts/backend-service.sh +55 -7
  11. package/plugin/scripts/cli.sh +2 -1
  12. package/plugin/scripts/dashboard-service.sh +7 -5
  13. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  14. package/plugin/scripts/hook_entry.sh +7 -5
  15. package/plugin/scripts/smart-install.sh +2 -2
  16. package/plugin/src/README.md +57 -0
  17. package/plugin/uv.lock +126 -5
  18. package/plugin/vendor/reflexio/.env.example +9 -0
  19. package/plugin/vendor/reflexio/pyproject.toml +5 -2
  20. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
  21. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
  22. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
  23. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
  24. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
  25. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
  26. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
  27. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  28. package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
  29. package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
  30. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
  31. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
  32. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
  33. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
  34. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
  35. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
  36. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
  37. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
  38. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
  39. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
  40. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
  41. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
  43. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  44. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
  45. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  46. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  47. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
  48. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  49. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
  50. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
  53. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
  54. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
  55. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
  56. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
  57. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
  58. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
  59. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
  60. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
  61. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
  62. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
  63. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
  64. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
  66. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
  68. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
@@ -1,9 +1,37 @@
1
1
  import inspect
2
+ import logging
3
+ import os
2
4
  from copy import deepcopy
3
5
  from typing import Any
4
6
 
5
7
  from pydantic import BaseModel
6
8
 
9
+
10
+ def positive_int_env(name: str, default: int, logger: logging.Logger) -> int:
11
+ """Resolve a strictly-positive int from environment variable ``name``.
12
+
13
+ Falls back to ``default`` when the variable is unset/blank, not a valid
14
+ integer (logging a warning in that case), or not strictly positive.
15
+
16
+ Args:
17
+ name (str): Environment variable to read.
18
+ default (int): Value returned when the variable is missing or invalid.
19
+ logger (logging.Logger): Logger used to warn on a non-integer value.
20
+
21
+ Returns:
22
+ int: The parsed positive integer, or ``default``.
23
+ """
24
+ raw = os.environ.get(name)
25
+ if not raw:
26
+ return default
27
+ try:
28
+ value = int(raw)
29
+ except ValueError:
30
+ logger.warning("Invalid %s=%r; falling back to default %d", name, raw, default)
31
+ return default
32
+ return value if value > 0 else default
33
+
34
+
7
35
  _STRICT_SCHEMA_UNSUPPORTED_KEYWORDS = frozenset(
8
36
  {
9
37
  "exclusiveMaximum",
@@ -2,8 +2,8 @@
2
2
 
3
3
  Resolution order (highest priority first):
4
4
  1. LLMConfig override (org-level configuration)
5
- 2. llm_model_setting.json site var (non-empty string values)
6
- 3. Auto-detect from available API keys in environment / APIKeyConfig
5
+ 2. For embeddings, Reflexio's local default model
6
+ 3. For other roles, llm_model_setting.json site var and provider autodetection
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -187,12 +187,12 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
187
187
  embedding="local/minilm-l6-v2",
188
188
  ),
189
189
  "openai": ProviderDefaults(
190
- generation="gpt-5-mini",
191
- evaluation="gpt-5-mini",
190
+ generation="gpt-5.5",
191
+ evaluation="gpt-5.4-mini",
192
192
  should_run="gpt-5-nano",
193
193
  pre_retrieval="gpt-5-nano",
194
194
  embedding="text-embedding-3-small",
195
- extraction_agent="gpt-5-mini",
195
+ extraction_agent="gpt-5.5",
196
196
  ),
197
197
  "anthropic": ProviderDefaults(
198
198
  generation="claude-sonnet-4-6",
@@ -372,16 +372,16 @@ def resolve_model_name(
372
372
  config_override: str | None = None,
373
373
  api_key_config: APIKeyConfig | None = None,
374
374
  ) -> str:
375
- """Resolve a model name using the 3-tier chain.
375
+ """Resolve a model name using the role-specific default chain.
376
376
 
377
377
  Resolution order (highest priority first):
378
378
  1. config_override (from LLMConfig, org-level)
379
- 2. site_var_value (from llm_model_setting.json, non-empty strings only)
380
- 3. Auto-detect from available API keys
379
+ 2. For EMBEDDING, the OSS local MiniLM model
380
+ 3. For other roles, site_var_value then auto-detect from available API keys
381
381
 
382
382
  Args:
383
383
  role: The model role to resolve.
384
- site_var_value: Value from llm_model_setting.json. Empty strings are treated as unset.
384
+ site_var_value: Value from llm_model_setting.json. Ignored for embeddings.
385
385
  config_override: Value from org-level LLMConfig.
386
386
  api_key_config: Optional org-level API key configuration for provider detection.
387
387
 
@@ -393,6 +393,11 @@ def resolve_model_name(
393
393
  """
394
394
  if config_override:
395
395
  return config_override
396
+ if role == ModelRole.EMBEDDING:
397
+ return (
398
+ _PROVIDER_DEFAULTS[_LOCAL_EMBEDDING_PROVIDER].embedding
399
+ or "local/minilm-l6-v2"
400
+ )
396
401
  if site_var_value:
397
402
  return site_var_value
398
403
  providers = detect_available_providers(api_key_config)
@@ -404,7 +409,7 @@ def validate_llm_availability(
404
409
  ) -> None:
405
410
  """Validate that at least one LLM provider and one embedding provider are available.
406
411
 
407
- Should be called once during startup. Logs the auto-selected provider at INFO level.
412
+ Should be called once during startup. Logs available providers at INFO level.
408
413
 
409
414
  Args:
410
415
  api_key_config: Optional org-level API key configuration.
@@ -449,7 +454,11 @@ def validate_llm_availability(
449
454
  (p for p in providers if _PROVIDER_DEFAULTS[p].embedding), None
450
455
  )
451
456
  if embedding_provider:
452
- logger.info("Embedding provider: %s", embedding_provider)
457
+ logger.info(
458
+ "Cloud embedding provider available: %s (used when the saved "
459
+ "embedding model selects this provider)",
460
+ embedding_provider,
461
+ )
453
462
  return
454
463
 
455
464
  from reflexio.server.llm.providers.local_embedding_provider import (
@@ -458,7 +467,8 @@ def validate_llm_availability(
458
467
 
459
468
  if is_chromadb_importable():
460
469
  logger.info(
461
- "Embedding provider: %s (fallback no cloud embedder configured)",
470
+ "Local MiniLM embedding fallback available: %s "
471
+ "(no cloud embedding provider configured)",
462
472
  _LOCAL_EMBEDDING_PROVIDER,
463
473
  )
464
474
  return
@@ -8,6 +8,7 @@ also supporting a horizontally-scaled internal service in cloud deployments.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  import logging
12
13
  import os
13
14
  import time
@@ -25,12 +26,25 @@ _ENV_PROVIDER = "REFLEXIO_EMBEDDING_PROVIDER"
25
26
  _ENV_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL"
26
27
  _ENV_TIMEOUT_MS = "REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS"
27
28
  _ENV_EMBEDDING_PORT = "EMBEDDING_PORT"
29
+ _ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST"
30
+ _ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS = (
31
+ "REFLEXIO_EMBEDDING_LOCAL_SERVICE_PROBE_TIMEOUT_MS"
32
+ )
28
33
  _ENV_CLAUDE_SMART_LOCAL = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
34
+ _ENV_MAX_TEXTS_PER_REQUEST = "REFLEXIO_EMBEDDING_SERVICE_MAX_TEXTS_PER_REQUEST"
29
35
  _DEFAULT_LOCAL_PORT = 8072
30
36
  _DEFAULT_INTERNAL_SERVICE_TIMEOUT_MS = 2_000
31
37
  _DEFAULT_LOCAL_SERVICE_TIMEOUT_MS = 30_000
38
+ _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS = 200
39
+ # Each request embeds its whole ``input`` list in one ``model.encode()`` on the
40
+ # service, so an unbounded request can exceed the client read timeout on a CPU
41
+ # daemon. Cap texts per request and concatenate; encode batching is a separate
42
+ # server-side knob (REFLEXIO_EMBED_BATCH_SIZE).
43
+ _DEFAULT_MAX_TEXTS_PER_REQUEST = 32
44
+ _LOCAL_SERVICE_PROBE_CACHE_SECONDS = 5.0
32
45
  _SERVICE_MODES = {"local_service", "internal_service"}
33
46
  _VALID_MODES = {"cloud", *_SERVICE_MODES, "inprocess", "off"}
47
+ _local_service_probe_cache: tuple[float, bool, str | None] | None = None
34
48
 
35
49
 
36
50
  class EmbeddingUnavailableError(RuntimeError):
@@ -38,8 +52,25 @@ class EmbeddingUnavailableError(RuntimeError):
38
52
 
39
53
 
40
54
  def _local_service_url() -> str:
55
+ host = os.environ.get(_ENV_DAEMON_HOST, "").strip() or "127.0.0.1"
41
56
  port = os.environ.get(_ENV_EMBEDDING_PORT, str(_DEFAULT_LOCAL_PORT))
42
- return f"http://127.0.0.1:{port}"
57
+ return f"http://{host}:{port}"
58
+
59
+
60
+ def _local_service_probe_timeout_seconds() -> float:
61
+ raw = os.environ.get(_ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS)
62
+ if raw is None:
63
+ return _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS / 1000
64
+ try:
65
+ timeout_ms = int(raw)
66
+ except ValueError:
67
+ _LOGGER.warning(
68
+ "%s must be an integer number of milliseconds; using %dms",
69
+ _ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS,
70
+ _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS,
71
+ )
72
+ return _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS / 1000
73
+ return max(timeout_ms, 1) / 1000
43
74
 
44
75
 
45
76
  def embedding_service_url(mode: EmbeddingProviderMode | None = None) -> str:
@@ -78,6 +109,43 @@ def embedding_service_timeout_seconds(
78
109
  return max(timeout_ms, 1) / 1000
79
110
 
80
111
 
112
+ def _is_local_model(model: str | None) -> bool:
113
+ return bool(model and model.startswith("local/"))
114
+
115
+
116
+ def _local_service_status() -> tuple[bool, str | None]:
117
+ global _local_service_probe_cache
118
+ now = time.monotonic()
119
+ if (
120
+ _local_service_probe_cache is not None
121
+ and now - _local_service_probe_cache[0] < _LOCAL_SERVICE_PROBE_CACHE_SECONDS
122
+ ):
123
+ return _local_service_probe_cache[1], _local_service_probe_cache[2]
124
+
125
+ try:
126
+ response = httpx.get(
127
+ f"{_local_service_url()}/health",
128
+ timeout=_local_service_probe_timeout_seconds(),
129
+ )
130
+ reachable = response.status_code < 500
131
+ active_model = response.json().get("active_model") if reachable else None
132
+ if not isinstance(active_model, str):
133
+ active_model = None
134
+ except httpx.HTTPError:
135
+ reachable = False
136
+ active_model = None
137
+ except ValueError:
138
+ reachable = False
139
+ active_model = None
140
+ _local_service_probe_cache = (now, reachable, active_model)
141
+ return reachable, active_model
142
+
143
+
144
+ def _local_service_supports_model(model: str | None) -> bool:
145
+ reachable, active_model = _local_service_status()
146
+ return reachable and (active_model is None or active_model == model)
147
+
148
+
81
149
  def _ordered_embeddings_from_response(
82
150
  data: Any, expected_count: int
83
151
  ) -> list[list[float]]:
@@ -123,10 +191,10 @@ def _ordered_embeddings_from_response(
123
191
  def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
124
192
  """Resolve the embedding provider mode for a model.
125
193
 
126
- Backwards compatibility: a ``local/*`` model still uses the in-process
127
- embedder unless the user explicitly chooses a mode or Claude Smart's legacy
128
- ``CLAUDE_SMART_USE_LOCAL_EMBEDDING=1`` flag is present. That legacy flag
129
- now means "use the shared local service" by default.
194
+ Explicit legacy env modes keep their historical behavior. With no explicit
195
+ env mode, routing is model-driven: ``local/*`` uses the local daemon when it
196
+ is reachable and otherwise falls back to the in-process embedder; cloud
197
+ models use their provider directly.
130
198
  """
131
199
  configured = os.environ.get(_ENV_PROVIDER)
132
200
  if configured:
@@ -144,8 +212,8 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
144
212
  if os.environ.get(_ENV_SERVICE_URL):
145
213
  return "internal_service"
146
214
 
147
- if model and model.startswith("local/"):
148
- return "inprocess"
215
+ if _is_local_model(model):
216
+ return "local_service" if _local_service_supports_model(model) else "inprocess"
149
217
  return "cloud"
150
218
 
151
219
 
@@ -186,11 +254,35 @@ def get_service_embeddings(
186
254
  )
187
255
 
188
256
  url = f"{embedding_service_url(mode)}/v1/embeddings"
257
+ timeout = embedding_service_timeout_seconds(mode)
258
+
259
+ # Bound each request so a single large publish cannot exceed the client read
260
+ # timeout: split into fixed-size chunks and concatenate the results in order.
261
+ chunk_size = _max_texts_per_request()
262
+ embeddings: list[list[float]] = []
263
+ for start in range(0, len(texts), chunk_size):
264
+ chunk = texts[start : start + chunk_size]
265
+ embeddings.extend(
266
+ _post_embedding_batch(
267
+ url, model=model, texts=chunk, dimensions=dimensions, timeout=timeout
268
+ )
269
+ )
270
+ return embeddings
271
+
272
+
273
+ def _post_embedding_batch(
274
+ url: str,
275
+ *,
276
+ model: str,
277
+ texts: list[str],
278
+ dimensions: int | None,
279
+ timeout: float,
280
+ ) -> list[list[float]]:
281
+ """POST one bounded batch of texts to the embedding service."""
189
282
  payload: dict[str, Any] = {"model": model, "input": texts}
190
283
  if dimensions:
191
284
  payload["dimensions"] = dimensions
192
285
 
193
- timeout = embedding_service_timeout_seconds(mode)
194
286
  last_error: Exception | None = None
195
287
  for attempt in range(2):
196
288
  try:
@@ -199,12 +291,48 @@ def get_service_embeddings(
199
291
  response.raise_for_status()
200
292
  body = response.json()
201
293
  return _ordered_embeddings_from_response(body.get("data"), len(texts))
202
- except Exception as exc: # noqa: BLE001 - normalized to typed degradation signal
294
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
295
+ # Connection never established — the request never reached the
296
+ # server, so a single retry is safe and cannot duplicate work.
203
297
  last_error = exc
204
298
  if attempt == 0:
205
299
  time.sleep(0.1)
300
+ except (httpx.HTTPError, json.JSONDecodeError, ValueError) as exc:
301
+ # The server already received the request: a read timeout means it
302
+ # is still encoding, and retrying would queue a second identical
303
+ # encode behind the first and amplify load on an already-saturated
304
+ # daemon. HTTPStatusError (4xx/5xx) and malformed-payload errors
305
+ # (JSONDecodeError / the ValueError raised by
306
+ # _ordered_embeddings_from_response) will not change on retry
307
+ # either. Fail fast. Anything outside this set (AttributeError,
308
+ # TypeError, ...) is a programming bug and propagates raw.
309
+ last_error = exc
310
+ break
206
311
 
207
312
  _LOGGER.warning("Embedding service unavailable at %s: %s", url, last_error)
208
313
  raise EmbeddingUnavailableError(
209
314
  f"Embedding service unavailable at {url}: {last_error}"
210
315
  ) from last_error
316
+
317
+
318
+ def _max_texts_per_request() -> int:
319
+ raw = os.environ.get(_ENV_MAX_TEXTS_PER_REQUEST)
320
+ if raw is None:
321
+ return _DEFAULT_MAX_TEXTS_PER_REQUEST
322
+ try:
323
+ value = int(raw)
324
+ except ValueError:
325
+ _LOGGER.warning(
326
+ "%s must be an integer; using %d",
327
+ _ENV_MAX_TEXTS_PER_REQUEST,
328
+ _DEFAULT_MAX_TEXTS_PER_REQUEST,
329
+ )
330
+ return _DEFAULT_MAX_TEXTS_PER_REQUEST
331
+ if value < 1:
332
+ _LOGGER.warning(
333
+ "%s must be >= 1; using %d",
334
+ _ENV_MAX_TEXTS_PER_REQUEST,
335
+ _DEFAULT_MAX_TEXTS_PER_REQUEST,
336
+ )
337
+ return _DEFAULT_MAX_TEXTS_PER_REQUEST
338
+ return value
@@ -154,7 +154,7 @@ def register_if_chromadb_available() -> bool:
154
154
  _LOGGER.debug("Local embedder not registered: `chromadb` is not installed.")
155
155
  return False
156
156
  _REGISTERED = True
157
- _LOGGER.info("Local embedding provider enabled (model=%s)", _MODEL_KEY)
157
+ _LOGGER.debug("Registered local MiniLM embedding handler (model=%s)", _MODEL_KEY)
158
158
  return True
159
159
 
160
160
 
@@ -34,6 +34,8 @@ import os
34
34
  import threading
35
35
  from typing import Any
36
36
 
37
+ from reflexio.server.llm.llm_utils import positive_int_env
38
+
37
39
  _LOGGER = logging.getLogger(__name__)
38
40
 
39
41
  _ENV_ENABLE = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
@@ -54,11 +56,27 @@ _TARGET_DIM = 512
54
56
  # defensively to avoid pathological multi-MB inputs.
55
57
  _MAX_CHARS = 32_000
56
58
 
59
+ # Encode in small mini-batches so a single large request can't spike memory:
60
+ # peak activation memory scales with batch_size, not with the total number of
61
+ # texts in the request. A small batch is what makes the daemon's bounded
62
+ # concurrency (see ``embedding_service``) safe to raise above 1.
63
+ _DEFAULT_ENCODE_BATCH_SIZE = 4
64
+ _ENV_BATCH_SIZE = "REFLEXIO_EMBED_BATCH_SIZE"
65
+
66
+
67
+ def _encode_batch_size() -> int:
68
+ """Resolve the encode mini-batch size from env, defaulting to 4."""
69
+ return positive_int_env(_ENV_BATCH_SIZE, _DEFAULT_ENCODE_BATCH_SIZE, _LOGGER)
70
+
57
71
 
58
72
  class NomicEmbedderError(RuntimeError):
59
73
  """Raised when the Nomic embedder is requested but its deps are missing."""
60
74
 
61
75
 
76
+ def _huggingface_cache_path() -> str:
77
+ return os.environ.get("HF_HOME") or "~/.cache/huggingface"
78
+
79
+
62
80
  class NomicEmbedder:
63
81
  """Lazily-loaded singleton wrapping a sentence-transformers model.
64
82
 
@@ -102,8 +120,9 @@ class NomicEmbedder:
102
120
  ) from exc
103
121
  _LOGGER.info(
104
122
  "Loading Nomic embedding model %s — first call may download "
105
- "~550 MB to ~/.cache/huggingface/",
123
+ "~550 MB to %s",
106
124
  _HF_MODEL_NAME,
125
+ _huggingface_cache_path(),
107
126
  )
108
127
  # Force CPU device — MPS init has been observed to hang on some
109
128
  # Apple Silicon + macOS combos for several minutes during model
@@ -120,7 +139,7 @@ class NomicEmbedder:
120
139
  "Nomic embedder ready (model=%s, target_dim=%d, native_dim=%d)",
121
140
  _HF_MODEL_NAME,
122
141
  _TARGET_DIM,
123
- self._model.get_sentence_embedding_dimension(),
142
+ _embedding_dimension(self._model),
124
143
  )
125
144
  return self._model
126
145
 
@@ -141,7 +160,12 @@ class NomicEmbedder:
141
160
  # show_progress_bar=False so server logs stay clean during ingest
142
161
  # batches. convert_to_numpy=True returns a numpy ndarray; we slice
143
162
  # and renormalise per-row before converting to plain Python lists.
144
- raw = model.encode(safe, show_progress_bar=False, convert_to_numpy=True)
163
+ raw = model.encode(
164
+ safe,
165
+ batch_size=_encode_batch_size(),
166
+ show_progress_bar=False,
167
+ convert_to_numpy=True,
168
+ )
145
169
  return [_truncate_and_renormalise(vec.tolist()) for vec in raw]
146
170
 
147
171
 
@@ -167,6 +191,15 @@ def _truncate_and_renormalise(vec: list[float]) -> list[float]:
167
191
  return [x / norm for x in sliced]
168
192
 
169
193
 
194
+ def _embedding_dimension(model: Any) -> int:
195
+ get_embedding_dimension = getattr(model, "get_embedding_dimension", None)
196
+ if callable(get_embedding_dimension):
197
+ dimension = get_embedding_dimension()
198
+ else:
199
+ dimension = model.get_sentence_embedding_dimension()
200
+ return int(dimension) # type: ignore[arg-type]
201
+
202
+
170
203
  _REGISTERED = False
171
204
 
172
205
 
@@ -21,6 +21,7 @@ this module never triggers a model download.
21
21
  from __future__ import annotations
22
22
 
23
23
  import logging
24
+ import os
24
25
  import threading
25
26
  from typing import Any
26
27
 
@@ -31,6 +32,13 @@ _LOGGER = logging.getLogger(__name__)
31
32
  # well-known MS-MARCO benchmark performance.
32
33
  _MODEL_NAME = "cross-encoder/ms-marco-MiniLM-L-6-v2"
33
34
 
35
+ # Device override for the cross-encoder. Defaults to CPU: without an
36
+ # explicit device sentence-transformers auto-selects MPS on Apple
37
+ # Silicon, where torch's caching allocator accumulates gigabytes of
38
+ # GPU buffers it never returns to the OS — and the 22M-param model
39
+ # gains nothing from GPU at K=30 pairs. Mirrors NOMIC_EMBED_DEVICE.
40
+ _DEVICE_ENV_VAR = "REFLEXIO_RERANK_DEVICE"
41
+
34
42
  # Singleton state — never accessed directly outside ``_get_model``.
35
43
  _MODEL: Any | None = None
36
44
  _MODEL_LOCK = threading.Lock()
@@ -107,13 +115,15 @@ def _get_model() -> Any:
107
115
  if _MODEL is not None:
108
116
  return _MODEL
109
117
  cross_encoder_cls = _import_cross_encoder()
118
+ device = os.environ.get(_DEVICE_ENV_VAR, "cpu")
110
119
  try:
111
- _MODEL = cross_encoder_cls(_MODEL_NAME)
120
+ _LOGGER.info("Loading reranker model %s (device=%s)", _MODEL_NAME, device)
121
+ _MODEL = cross_encoder_cls(_MODEL_NAME, device=device)
112
122
  except Exception as e: # noqa: BLE001 — surface as a typed failure
113
123
  raise CrossEncoderUnavailableError(
114
124
  f"Failed to load cross-encoder model {_MODEL_NAME!r}: {e}"
115
125
  ) from e
116
- _LOGGER.info("Loaded cross-encoder model %s", _MODEL_NAME)
126
+ _LOGGER.info("Reranker model ready (model=%s)", _MODEL_NAME)
117
127
  return _MODEL
118
128
 
119
129
 
@@ -140,7 +150,7 @@ def score_pairs(query: str, docs: list[str]) -> list[float]:
140
150
  return []
141
151
  model = _get_model()
142
152
  pairs = [(query, doc) for doc in docs]
143
- raw_scores = model.predict(pairs)
153
+ raw_scores = model.predict(pairs, show_progress_bar=False)
144
154
  # ``predict`` returns a numpy array; convert to plain Python floats so
145
155
  # the caller can serialise the result without numpy as a dependency.
146
156
  return [float(s) for s in raw_scores]
@@ -578,6 +578,9 @@ def run_tool_loop(
578
578
  )
579
579
 
580
580
  # ---- Native tool loop ---------------------------------------------
581
+ # Local import keeps litellm_client a type-only dependency of this module.
582
+ from reflexio.server.llm.litellm_client import LiteLLMClientError
583
+
581
584
  local_msgs = list(messages)
582
585
  try:
583
586
  for _step in range(max_steps):
@@ -677,6 +680,20 @@ def run_tool_loop(
677
680
  pending_tool_call_ids=pending_tool_call_ids,
678
681
  max_steps_remaining=max_steps - _step - 1,
679
682
  )
683
+ except LiteLLMClientError as e:
684
+ # LLM failure after the client exhausted its retries and fallbacks —
685
+ # a known failure mode (timeouts, provider errors), not a bug. Log at
686
+ # warning so it doesn't surface as a Sentry error.
687
+ logger.warning("event=tool_loop_llm_error error=%s", e)
688
+ trace.finished = False
689
+ return ToolLoopResult(
690
+ ctx=ctx,
691
+ trace=trace,
692
+ finished_reason="error",
693
+ messages=local_msgs,
694
+ pending_tool_call_ids=pending_tool_call_ids,
695
+ max_steps_remaining=0,
696
+ )
680
697
  except Exception:
681
698
  logger.exception("Tool loop raised an unexpected exception")
682
699
  trace.finished = False
@@ -1,5 +1,5 @@
1
1
  ---
2
- active: true
2
+ active: false
3
3
  description: "Reconcile newly-extracted playbooks against existing storage. Decides per-candidate whether each one unifies with one-or-more existing rows (growing a coherent multi-rule skill, mixed do/avoid rules allowed), is rejected as redundant against an existing row, should differentiate (refine both triggers), or is independent."
4
4
  changelog: "v2.3.0: `unify` now COMPOSES — it may grow a broader multi-rule skill from coherent related fragments (related sub-aspects of one task), not only same-trigger duplicates. A skill MAY hold mixed-polarity rules (do-rules and avoid-rules for different sub-aspects). Replaced the mechanical single-orientation/same-polarity contract with an LLM-judged no-self-contradiction guard: do NOT unify if combining the rules would make the skill contradict itself on the same situation (same trigger/condition with opposite advice) — route those to `differentiate` or `reject_new`. Re-synthesis (fewest/most-general rules, preserve avoid-detail) and the over-budget `differentiate` preference are unchanged. v2.2.0: `unify` re-synthesizes leaner, more general content (MDL / simplify-in-place) instead of concatenating — fewest, most general rules that still cover all inputs; drop redundant/subsumed wording. Asymmetric fidelity: compress/generalize success guidance, but preserve every distinct avoidance/failure detail verbatim — never soften a named pitfall into a vague platitude. Prefer `differentiate` over `unify` when merging would force an over-long, low-cohesion rule. v2.1.0: `unify` no longer emits a `polarity` field — the unified row's orientation is derived from its wording (recommendation vs avoidance) by the apply-path polarity validator. Same-trigger opposite-orientation rules still must not unify; express orientation through the rule wording. v2.0.0: collapsed 5-kind union → 4-kind. `unify` subsumes `duplicate`+`prefer_new`; `reject_new` replaces `prefer_existing`. Output schema is structurally incompatible with v1.x."
5
5
  variables:
@@ -0,0 +1,69 @@
1
+ ---
2
+ active: false
3
+ description: "Reconcile newly-extracted playbooks against existing storage. Decides per-candidate whether each one unifies with one-or-more existing rows (growing a coherent multi-rule skill, mixed do/avoid rules allowed), is rejected as redundant against an existing row, should differentiate (refine both triggers), or is independent."
4
+ changelog: "v2.3.1: replaces schema example-style output prompting with compact format guidance for the four decision shapes. v2.3.0: `unify` now COMPOSES — it may grow a broader multi-rule skill from coherent related fragments (related sub-aspects of one task), not only same-trigger duplicates. A skill MAY hold mixed-polarity rules (do-rules and avoid-rules for different sub-aspects). Replaced the mechanical single-orientation/same-polarity contract with an LLM-judged no-self-contradiction guard: do NOT unify if combining the rules would make the skill contradict itself on the same situation (same trigger/condition with opposite advice) — route those to `differentiate` or `reject_new`. Re-synthesis (fewest/most-general rules, preserve avoid-detail) and the over-budget `differentiate` preference are unchanged. v2.2.0: `unify` re-synthesizes leaner, more general content (MDL / simplify-in-place) instead of concatenating — fewest, most general rules that still cover all inputs; drop redundant/subsumed wording. Asymmetric fidelity: compress/generalize success guidance, but preserve every distinct avoidance/failure detail verbatim — never soften a named pitfall into a vague platitude. Prefer `differentiate` over `unify` when merging would force an over-long, low-cohesion rule. v2.1.0: `unify` no longer emits a `polarity` field — the unified row's orientation is derived from its wording (recommendation vs avoidance) by the apply-path polarity validator. Same-trigger opposite-orientation rules still must not unify; express orientation through the rule wording. v2.0.0: collapsed 5-kind union → 4-kind. `unify` subsumes `duplicate`+`prefer_new`; `reject_new` replaces `prefer_existing`. Output schema is structurally incompatible with v1.x."
5
+ variables:
6
+ - new_playbook_count
7
+ - new_playbooks
8
+ - existing_playbooks
9
+ ---
10
+ You are reconciling a set of newly-extracted playbooks against the related existing playbook rows already in storage. For each new candidate, decide its relationship to the existing rows.
11
+
12
+ Each rendered row carries `Content`, `Trigger`, `Rationale`, `Name`, `Source`, and `Last Modified`. Read each rule's orientation (do-this vs avoid-this) directly from its `Content` / `Rationale` wording. A unified skill may hold both do-rules and avoid-rules for different sub-aspects, so do not treat differing orientation alone as a reason not to merge. Use `Trigger` together with the actual situation each rule addresses as the primary keys for comparison.
13
+
14
+ [New playbooks (count: {new_playbook_count})]
15
+ {new_playbooks}
16
+
17
+ [Existing related playbooks]
18
+ {existing_playbooks}
19
+
20
+ # Decision kinds
21
+
22
+ Emit exactly one decision per NEW candidate. Each decision is one of:
23
+
24
+ - **unify** — the NEW belongs in the same skill as one or more EXISTING rows. This covers two cases:
25
+ 1. **Dedup / supersede** — the NEW is the same rule as an EXISTING row (after merge), or supersedes one (stronger / broader / more specific evidence).
26
+ 2. **Compose** — the NEW covers a **related sub-aspect of the same task** as an EXISTING playbook (coherent, not a strict duplicate). Grow a **broader multi-rule skill** by incorporating the fragment as an additional rule, instead of forcing `reject_new`/`independent`.
27
+
28
+ Provide the final `content`, `trigger`, and `rationale`. List which EXISTING ids you're archiving in `archive_existing_ids` (use an empty list when no EXISTING rows are absorbed). A composed skill MAY hold **mixed-polarity rules** — do-rules and avoid-rules together — when they address **different** sub-aspects of the one task (e.g. "Do: announce in the deploy channel" alongside "Avoid: Friday-afternoon deploys"). Only unify fragments that are **genuinely coherent** (same task scope); unrelated fragments are `independent`.
29
+
30
+ - **reject_new** — an EXISTING row already covers NEW or makes NEW redundant. Name the EXISTING id that wins via `superseded_by_existing_id`. Storage-stability tie-break: when same-situation opposite advice is balanced, default here.
31
+
32
+ - **differentiate** — both valid in distinct contexts (typically same trigger, opposite advice, where the contexts differ). Set `refined_new_trigger` and `refined_existing_trigger` to be strictly narrower than the originals AND mutually exclusive.
33
+
34
+ - **independent** — different topic or task from any existing row. Insert NEW with no archive.
35
+
36
+ # How to write a unified skill (re-synthesis, not concatenation)
37
+
38
+ When you `unify`, re-synthesize a single leaner skill. Do not stitch the inputs together.
39
+
40
+ - Produce the **fewest, most general rules that still cover all the inputs**. Generalize the shared behavior into one clear statement; drop wording that is redundant with or subsumed by another input.
41
+ - A skill may carry several rules. When it does, keep each rule a clean, self-contained do-rule or avoid-rule for its own sub-aspect — do not blur distinct rules into one.
42
+ - **Preserve every distinct avoidance/failure detail with high fidelity.** This is asymmetric: compress and generalize the success guidance, but keep each specific failure intact. Never collapse a named pitfall, concrete error, or specific anti-pattern into a vague platitude — carry it forward in its specific form.
43
+ - Prefer **`differentiate` over `unify`** when merging the inputs would force an over-long, low-cohesion skill. Two focused rules that each read cleanly beat one bloated skill that tries to say everything. If you cannot state the merged skill concisely without losing distinct failure detail, that is a signal to `differentiate` (or keep NEW `independent`) rather than `unify`.
44
+
45
+ # The no-self-contradiction guard
46
+
47
+ A skill MAY hold mixed-polarity rules (do-rules and avoid-rules) for **different** sub-aspects. What it must NEVER hold is two rules that **contradict each other on the same situation** — the same trigger/condition paired with opposite advice (e.g. "use `-F`" and "avoid `-F`" for the very same case).
48
+
49
+ - Before you `unify`, ask: *would combining these rules produce a skill that contradicts itself on the same situation?* If yes, do **not** unify — route the pair to `differentiate` (refine the triggers so each rule owns a disjoint situation) or `reject_new` (one rule wins).
50
+ - Mixed polarity across **different** sub-aspects is fine and expected; mixed advice on the **same** sub-aspect is the forbidden case.
51
+
52
+ # Hard constraints
53
+
54
+ - A NEW + EXISTING pair that gives opposite advice on the **same** situation (same trigger) MUST route to `differentiate` or `reject_new` — never `unify` (that would make the skill self-contradict) and never `independent`.
55
+ - `differentiate.refined_new_trigger` and `refined_existing_trigger` MUST be non-empty and strictly narrower than the originals.
56
+ - When in doubt about a same-situation opposite-advice stalemate, default to `reject_new`. Storage stability wins ties.
57
+
58
+ # Output Format Guidance
59
+
60
+ Respond ONLY with a valid JSON object matching `PlaybookConsolidationOutput`: `{{"decisions": [<one decision object for each NEW candidate>]}}`.
61
+
62
+ Each decision object MUST include `kind` and `new_id`. The `kind` value determines the remaining required fields:
63
+
64
+ - `unify`: include `archive_existing_ids` as a list of EXISTING list positions, plus final `content`, `trigger`, and `rationale`; optional `reason` may explain the decision.
65
+ - `reject_new`: include `superseded_by_existing_id` as the existing row's database id; optional `reason` may explain the decision.
66
+ - `differentiate`: include `existing_id` as the existing row's database id, plus non-empty `refined_new_trigger` and `refined_existing_trigger`; optional `reason` may explain the decision.
67
+ - `independent`: include only `kind`, `new_id`, and optional `reason`.
68
+
69
+ Do not emit markdown, prose, comments, chain-of-thought, or top-level keys other than `decisions`. Do not include fields from another decision kind.