claude-smart 0.2.48 → 0.2.50

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 (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -6,7 +6,7 @@ import re
6
6
  import time
7
7
  from typing import Any
8
8
 
9
- from claude_smart import state
9
+ from claude_smart import runtime, state
10
10
 
11
11
  # Tool inputs are persisted locally and later published to reflexio, so we
12
12
  # apply a conservative redaction pass at ingestion time. Chosen to avoid
@@ -144,5 +144,6 @@ def handle(payload: dict[str, Any]) -> None:
144
144
  "tool_input": _redact(payload.get("tool_input") or {}),
145
145
  "tool_output": _redact_string(output_text) if output_text else "",
146
146
  "status": _derive_status(tool_response),
147
+ "host": runtime.attribution_host(),
147
148
  }
148
149
  state.append(session_id, record)
@@ -21,7 +21,7 @@ import time
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
24
- from claude_smart import env_config, ids, publish, state
24
+ from claude_smart import env_config, ids, publish, runtime, state
25
25
  from claude_smart.events.stop import (
26
26
  _read_transcript_entries,
27
27
  _scan_transcript_for_assistant_text,
@@ -119,6 +119,7 @@ def _maybe_synthesize_assistant_anchor(
119
119
  "content": assistant_text,
120
120
  "user_id": project_id,
121
121
  "synthesised_by": "session_end_anchor",
122
+ "host": runtime.attribution_host(),
122
123
  },
123
124
  )
124
125
  _LOGGER.info(
@@ -408,6 +408,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
408
408
  "role": "User",
409
409
  "content": decision_text,
410
410
  "user_id": project_id,
411
+ "host": runtime.attribution_host(),
411
412
  },
412
413
  )
413
414
  elif prompt and not _has_unpublished_user_turn(session_id):
@@ -418,6 +419,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
418
419
  "role": "User",
419
420
  "content": prompt,
420
421
  "user_id": project_id,
422
+ "host": runtime.attribution_host(),
421
423
  },
422
424
  )
423
425
 
@@ -426,6 +428,7 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
426
428
  "role": "Assistant",
427
429
  "content": assistant_text,
428
430
  "user_id": project_id,
431
+ "host": runtime.attribution_host(),
429
432
  }
430
433
  if cited_items:
431
434
  record["cited_items"] = cited_items
@@ -28,7 +28,7 @@ import logging
28
28
  import time
29
29
  from typing import Any
30
30
 
31
- from claude_smart import context_inject, ids, state
31
+ from claude_smart import context_inject, ids, runtime, state
32
32
 
33
33
  _LOGGER = logging.getLogger(__name__)
34
34
  _TOP_K = 3
@@ -58,6 +58,7 @@ def handle(payload: dict[str, Any]) -> None:
58
58
  "role": "User",
59
59
  "content": prompt,
60
60
  "user_id": project_id,
61
+ "host": runtime.attribution_host(),
61
62
  },
62
63
  )
63
64
 
@@ -20,7 +20,8 @@ Two distinct sources of unwanted hook fires:
20
20
 
21
21
  Detection signals, OR'd:
22
22
  - ``CLAUDE_CODE_ENTRYPOINT`` is anything other than an interactive entrypoint
23
- (``"cli"`` or Claude Desktop's ``"claude-desktop"``). Headless
23
+ (``"cli"``, Claude Desktop's ``"claude-desktop"``, or Claude Code IDE
24
+ entrypoints such as ``"claude-vscode"``/``"claude-jetbrains"``). Headless
24
25
  ``claude -p`` sets ``sdk-cli`` (and the SDKs may set other values). This
25
26
  catches case (2) for any third-party tool, not just claude-mem.
26
27
  - Env var ``CLAUDE_SMART_INTERNAL=1``, set by reflexio's provider
@@ -46,7 +47,9 @@ from typing import Any
46
47
  from claude_smart import runtime
47
48
 
48
49
  _ENTRYPOINT_VAR = "CLAUDE_CODE_ENTRYPOINT"
49
- _INTERACTIVE_ENTRYPOINTS = frozenset({"cli", "claude-desktop"})
50
+ _INTERACTIVE_ENTRYPOINTS = frozenset(
51
+ {"cli", "claude-desktop", "claude-vscode", "claude-jetbrains"}
52
+ )
50
53
  _CODEX_TITLE_PROMPT_PREFIX = (
51
54
  "You are a helpful assistant. You will be presented with a user prompt, "
52
55
  "and your job is to provide a short title for a task"
@@ -4,6 +4,13 @@ Reflexio's ``LocalScriptAssistant`` sends one JSON payload on stdin and expects
4
4
  one JSON object on stdout. This module bridges that protocol to a guarded
5
5
  local CLI subprocess so candidate playbooks can be evaluated against the active
6
6
  host without re-entering claude-smart/reflexio hooks.
7
+
8
+ Host CLI resolution: when ``CLAUDE_SMART_CLI_PATH`` is set in the environment
9
+ (``backend-service.sh`` writes it for ``opencode`` and ``codex`` hosts so
10
+ generation routes through the host-specific compatibility bridge instead of
11
+ the real Claude CLI), this script honours that override. Falling back to
12
+ ``shutil.which("claude")`` preserves the legacy Claude Code host behaviour
13
+ where no override is configured.
7
14
  """
8
15
 
9
16
  from __future__ import annotations
@@ -22,6 +29,20 @@ from claude_smart import internal_call, runtime
22
29
  _CLI_TIMEOUT_SECONDS = 300
23
30
  _READ_ONLY_TOOLS = "Read,Grep,Glob,LS"
24
31
  _MUTATING_TOOLS = "Bash,Edit,Write,MultiEdit,NotebookEdit"
32
+ # Mirrors ``reflexio.server.llm.providers.claude_code_provider._ENV_CLI_PATH``
33
+ # so backend-service.sh can route both the reflexio Claude CLI provider and
34
+ # this assistant script through the same host-specific bridge.
35
+ _ENV_CLI_PATH = "CLAUDE_SMART_CLI_PATH"
36
+ _CLAUDE_SMART_COMPAT_BRIDGE_NAMES = frozenset(
37
+ {
38
+ "codex-claude-compat",
39
+ "codex-claude-compat.cmd",
40
+ "codex-claude-compat.js",
41
+ "opencode-claude-compat",
42
+ "opencode-claude-compat.cmd",
43
+ "opencode-claude-compat.js",
44
+ }
45
+ )
25
46
 
26
47
 
27
48
  class OptimizerAssistantError(Exception):
@@ -142,26 +163,51 @@ def _run_local_cli(*, prompt: str, system_prompt: str) -> str:
142
163
  return _run_claude(prompt=prompt, system_prompt=system_prompt)
143
164
 
144
165
 
166
+ def _resolve_claude_cli_path() -> str:
167
+ """Return the host CLI to invoke for evaluation rollouts.
168
+
169
+ When ``CLAUDE_SMART_CLI_PATH`` is set, prefer it so backend-service.sh can
170
+ route the assistant through the same bridge it uses for the reflexio
171
+ ``claude-code`` provider (e.g. ``opencode-claude-compat`` for the OpenCode
172
+ host). Falls back to ``shutil.which("claude")`` so Claude Code hosts — and
173
+ any environment that never set the override — continue to use the real
174
+ ``claude`` CLI.
175
+ """
176
+ override = os.environ.get(_ENV_CLI_PATH)
177
+ if override:
178
+ return override
179
+ return shutil.which("claude") or "claude"
180
+
181
+
182
+ def _is_claude_smart_compat_bridge(cli_path: str) -> bool:
183
+ return Path(cli_path).name.lower() in _CLAUDE_SMART_COMPAT_BRIDGE_NAMES
184
+
185
+
145
186
  def _run_claude(*, prompt: str, system_prompt: str) -> str:
146
- cli_path = shutil.which("claude") or "claude"
147
- # This is an evaluation rollout, not a real user session: allow local
148
- # inspection, but prevent filesystem, shell, MCP, and session mutations.
187
+ cli_path = _resolve_claude_cli_path()
149
188
  cmd = [
150
189
  cli_path,
151
190
  "-p",
152
191
  "--output-format",
153
192
  "json",
154
- "--permission-mode",
155
- "plan",
156
- "--tools",
157
- _READ_ONLY_TOOLS,
158
- "--disallowedTools",
159
- _MUTATING_TOOLS,
160
- "--no-session-persistence",
161
- "--mcp-config",
162
- '{"mcpServers": {}}',
163
- "--strict-mcp-config",
164
193
  ]
194
+ if not _is_claude_smart_compat_bridge(cli_path):
195
+ # This is an evaluation rollout, not a real user session: allow local
196
+ # inspection, but prevent filesystem, shell, MCP, and session mutations.
197
+ cmd.extend(
198
+ [
199
+ "--permission-mode",
200
+ "plan",
201
+ "--tools",
202
+ _READ_ONLY_TOOLS,
203
+ "--disallowedTools",
204
+ _MUTATING_TOOLS,
205
+ "--no-session-persistence",
206
+ "--mcp-config",
207
+ '{"mcpServers": {}}',
208
+ "--strict-mcp-config",
209
+ ]
210
+ )
165
211
  if system_prompt:
166
212
  cmd.extend(["--append-system-prompt", system_prompt])
167
213
 
@@ -8,7 +8,8 @@ messaging without peeking at the adapter.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Literal
11
+ import uuid
12
+ from typing import Any, Literal
12
13
 
13
14
  from claude_smart import state
14
15
  from claude_smart.reflexio_adapter import Adapter
@@ -16,6 +17,50 @@ from claude_smart.reflexio_adapter import Adapter
16
17
  PublishStatus = Literal["nothing", "ok", "failed"]
17
18
 
18
19
 
20
+ def _publish_request_id(session_id: str, start: int, end: int) -> str:
21
+ """Return a stable request ID for one buffered publish range."""
22
+
23
+ name = f"claude-smart:{session_id}:{start}:{end}"
24
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, name))
25
+
26
+
27
+ def _prepare_publish_batch(
28
+ session_id: str,
29
+ ) -> tuple[int, int, list[dict[str, Any]]] | None:
30
+ """Freeze and return one canonical unpublished record range."""
31
+
32
+ while True:
33
+ records = state.read_all(session_id)
34
+ published, available = state.unpublished_slice(records)
35
+ if not available:
36
+ return None
37
+
38
+ publish_end = state.pending_publish_end(records, published)
39
+ if publish_end is None:
40
+ publish_end = state.safe_publish_end(records, published)
41
+ _, proposed = state.unpublished_slice(
42
+ records[:publish_end], published_override=published
43
+ )
44
+ if not proposed:
45
+ return None
46
+ state.append(
47
+ session_id,
48
+ {"publish_attempt": {"start": published, "end": publish_end}},
49
+ )
50
+
51
+ canonical = state.read_all(session_id)
52
+ if state.published_record_offset(canonical) != published:
53
+ continue
54
+ canonical_end = state.pending_publish_end(canonical, published)
55
+ if canonical_end is None:
56
+ continue
57
+ _, interactions = state.unpublished_slice(
58
+ canonical[:canonical_end], published_override=published
59
+ )
60
+ if interactions:
61
+ return published, canonical_end, interactions
62
+
63
+
19
64
  def publish_unpublished(
20
65
  *,
21
66
  session_id: str,
@@ -56,20 +101,27 @@ def publish_unpublished(
56
101
  was unreachable. On ``"failed"`` the watermark is not advanced,
57
102
  so the next hook retries the same batch.
58
103
  """
59
- records = state.read_all(session_id)
60
- _, interactions = state.unpublished_slice(records)
61
- if not interactions:
104
+ batch = _prepare_publish_batch(session_id)
105
+ if batch is None:
62
106
  return ("nothing", 0)
107
+ published_record_offset, publish_end, interactions = batch
108
+
63
109
  client = adapter if adapter is not None else Adapter()
64
- ok = client.publish(
110
+ request_id = _publish_request_id(session_id, published_record_offset, publish_end)
111
+ result = client.publish(
65
112
  session_id=session_id,
66
113
  project_id=project_id,
114
+ request_id=request_id,
67
115
  interactions=interactions,
68
116
  force_extraction=force_extraction,
69
117
  override_learning_stall=override_learning_stall,
70
118
  skip_aggregation=skip_aggregation,
71
119
  )
72
- if ok:
73
- state.append(session_id, {"published_up_to": len(records)})
120
+ if result:
121
+ marker: dict[str, Any] = {"published_up_to": publish_end}
122
+ confirmed_request_id = result.request_id
123
+ if isinstance(confirmed_request_id, str) and confirmed_request_id:
124
+ marker["request_id"] = confirmed_request_id
125
+ state.append(session_id, marker)
74
126
  return ("ok", len(interactions))
75
127
  return ("failed", len(interactions))
@@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__)
20
20
 
21
21
  _ENV_URL = "REFLEXIO_URL"
22
22
  _ENV_API_KEY = "REFLEXIO_API_KEY"
23
- _DEFAULT_URL = "http://localhost:8071/"
24
23
  # Cap every HTTP round-trip from a hook so a hung backend can't stall the
25
24
  # Claude Code / Codex session. reflexio's client default is 300s, which is
26
25
  # fine for batch workloads but unacceptable on the hook path — a single
@@ -28,18 +27,48 @@ _DEFAULT_URL = "http://localhost:8071/"
28
27
  # in ``ids.py`` for short-lived hook HTTP calls.
29
28
  _HTTP_TIMEOUT_SECONDS = 5
30
29
  _SEARCH_MODE_HYBRID = "hybrid" # reflexio.models.config_schema.SearchMode.HYBRID
30
+ _SOURCE = "claude-smart"
31
31
  _UNIFIED_ENTITY_TYPES = ("profiles", "user_playbooks", "agent_playbooks")
32
32
  _AGENT_PLAYBOOK_APPROVAL_STATUSES = ("pending", "approved")
33
33
  _REJECTED_AGENT_PLAYBOOK_STATUS = "rejected"
34
+ _LOCAL_8071_URLS = {
35
+ "http://localhost:8071",
36
+ "http://localhost:8071/",
37
+ "http://127.0.0.1:8071",
38
+ "http://127.0.0.1:8071/",
39
+ }
40
+
41
+
42
+ def _default_url() -> str:
43
+ return f"http://localhost:{os.environ.get('BACKEND_PORT', '8071')}/"
44
+
45
+
46
+ def _configured_url() -> str:
47
+ url = os.environ.get(_ENV_URL, "")
48
+ backend_port = os.environ.get("BACKEND_PORT", "")
49
+ if url in _LOCAL_8071_URLS and backend_port not in {"", "8071"}:
50
+ return _default_url()
51
+ return url or _default_url()
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PublishResult:
56
+ """Outcome and server-confirmed lineage for one publish call."""
57
+
58
+ ok: bool
59
+ request_id: str | None = None
60
+
61
+ def __bool__(self) -> bool:
62
+ return self.ok
34
63
 
35
64
 
36
65
  @dataclass
37
66
  class Adapter:
38
67
  """Wraps the reflexio client and absorbs connection errors.
39
68
 
40
- All methods degrade to a neutral no-op return (empty list / False) on
41
- connection failure so a missing or down reflexio server never crashes
42
- a Claude Code hook.
69
+ All methods degrade to a neutral no-op result (an empty list or a falsey
70
+ result) on connection failure so a missing or down reflexio server never
71
+ crashes a host hook.
43
72
  """
44
73
 
45
74
  url: str = ""
@@ -48,7 +77,7 @@ class Adapter:
48
77
 
49
78
  def __post_init__(self) -> None:
50
79
  env_config.load_reflexio_env()
51
- self.url = self.url or os.environ.get(_ENV_URL, _DEFAULT_URL)
80
+ self.url = self.url or _configured_url()
52
81
  self.api_key = self.api_key or os.environ.get(_ENV_API_KEY, "")
53
82
  self._client: Any | None = None
54
83
 
@@ -92,38 +121,96 @@ class Adapter:
92
121
  *,
93
122
  session_id: str,
94
123
  project_id: str,
124
+ request_id: str | None = None,
95
125
  interactions: Sequence[dict[str, Any]],
96
126
  force_extraction: bool = False,
97
127
  override_learning_stall: bool = False,
98
128
  skip_aggregation: bool = False,
99
- ) -> bool:
100
- """Publish buffered interactions to reflexio. Returns True on success."""
129
+ ) -> PublishResult:
130
+ """Publish interactions and return this call's confirmed request ID."""
101
131
  if not interactions:
102
- return True
132
+ return PublishResult(True)
103
133
  client = self._get_client()
104
134
  if client is None:
105
- return False
135
+ return PublishResult(False)
106
136
  try:
137
+ interaction_list = list(interactions)
138
+ raw_request = getattr(client, "_make_request", None)
139
+ supports_source = _supports_keyword(client.publish_interaction, "source")
140
+ if request_id is not None and callable(raw_request):
141
+ payload: dict[str, Any] = {
142
+ "request_id": request_id,
143
+ "user_id": project_id,
144
+ "interaction_data_list": interaction_list,
145
+ "agent_version": runtime.agent_version(),
146
+ "session_id": session_id,
147
+ "skip_aggregation": skip_aggregation,
148
+ "force_extraction": force_extraction,
149
+ "evaluation_only": False,
150
+ "override_learning_stall": override_learning_stall,
151
+ }
152
+ if supports_source:
153
+ payload["source"] = _SOURCE
154
+ try:
155
+ raw_request(
156
+ "POST",
157
+ "/api/publish_interaction",
158
+ json=payload,
159
+ params=None,
160
+ )
161
+ return PublishResult(True, request_id)
162
+ except Exception as exc: # noqa: BLE001
163
+ if not _needs_raw_retrieved_learning_publish(interaction_list):
164
+ raise
165
+ _LOGGER.warning(
166
+ "Could not confirm retrieved-learning links; retrying "
167
+ "the base interaction with the same request ID: %s",
168
+ exc,
169
+ )
170
+ fallback_payload = {
171
+ **payload,
172
+ "interaction_data_list": _without_retrieved_learnings(
173
+ interaction_list
174
+ ),
175
+ }
176
+ raw_request(
177
+ "POST",
178
+ "/api/publish_interaction",
179
+ json=fallback_payload,
180
+ params=None,
181
+ )
182
+ return PublishResult(True, request_id)
183
+ if _needs_raw_retrieved_learning_publish(interaction_list):
184
+ _LOGGER.warning(
185
+ "Stable raw publishing is unavailable; publishing "
186
+ "without optional retrieved-learning links"
187
+ )
188
+ interaction_list = _without_retrieved_learnings(interaction_list)
107
189
  kwargs = {
108
190
  "user_id": project_id,
109
- "interactions": list(interactions),
191
+ "interactions": interaction_list,
110
192
  "agent_version": runtime.agent_version(),
111
193
  "session_id": session_id,
112
194
  "wait_for_response": False,
113
195
  "force_extraction": force_extraction,
114
196
  "skip_aggregation": skip_aggregation,
115
197
  }
198
+ if supports_source:
199
+ kwargs["source"] = _SOURCE
116
200
  if _supports_keyword(client.publish_interaction, "override_learning_stall"):
117
201
  kwargs["override_learning_stall"] = override_learning_stall
118
202
  elif override_learning_stall:
119
203
  _LOGGER.debug(
120
204
  "publish_interaction client does not support override_learning_stall"
121
205
  )
122
- client.publish_interaction(**kwargs)
123
- return True
206
+ response = client.publish_interaction(**kwargs)
207
+ response_request_id = getattr(response, "request_id", None)
208
+ if isinstance(response_request_id, str) and response_request_id:
209
+ return PublishResult(True, response_request_id)
210
+ return PublishResult(True)
124
211
  except Exception as exc: # noqa: BLE001
125
212
  _LOGGER.warning("publish_interaction failed: %s", exc)
126
- return False
213
+ return PublishResult(False)
127
214
 
128
215
  def apply_extraction_defaults(self, *, window_size: int, stride_size: int) -> bool:
129
216
  """Push claude-smart's preferred extraction defaults to the reflexio server.
@@ -340,7 +427,12 @@ class Adapter:
340
427
  # -----------------------------------------------------------------
341
428
 
342
429
  def search_all(
343
- self, *, project_id: str, query: str, top_k: int = 5
430
+ self,
431
+ *,
432
+ project_id: str,
433
+ query: str,
434
+ top_k: int = 5,
435
+ session_id: str | None = None,
344
436
  ) -> tuple[list[Any], list[Any], list[Any]]:
345
437
  """Unified hybrid search → ``(user_playbooks, agent_playbooks, preferences)``.
346
438
 
@@ -354,6 +446,10 @@ class Adapter:
354
446
  project_id (str): reflexio ``user_id`` for this repo.
355
447
  query (str): Free-text query routed through BM25 + vector RRF.
356
448
  top_k (int): Cap on results per entity type.
449
+ session_id (str | None): Claude Code session id. When set, the
450
+ server skips results it already returned to this session
451
+ and backfills next-best matches, so repeated hook searches
452
+ within one session stop re-injecting the same rules.
357
453
 
358
454
  Returns:
359
455
  tuple[list[Any], list[Any], list[Any]]: ``(user_playbooks,
@@ -374,6 +470,7 @@ class Adapter:
374
470
  enable_agent_answer=False,
375
471
  top_k=top_k,
376
472
  search_mode=_SEARCH_MODE_HYBRID,
473
+ session_id=session_id,
377
474
  )
378
475
  except Exception as exc: # noqa: BLE001
379
476
  self._record_read_error("unified search", exc)
@@ -449,6 +546,32 @@ def _supports_keyword(callable_obj: Any, keyword: str) -> bool:
449
546
  return keyword in signature.parameters
450
547
 
451
548
 
549
+ def _needs_raw_retrieved_learning_publish(
550
+ interactions: Sequence[dict[str, Any]],
551
+ ) -> bool:
552
+ """Return whether links require a caller-stable raw request.
553
+
554
+ The public client does not accept a caller-supplied request ID, and older
555
+ clients also strip this field. The authenticated helper preserves both.
556
+ """
557
+ return any(item.get("retrieved_learnings") for item in interactions)
558
+
559
+
560
+ def _without_retrieved_learnings(
561
+ interactions: Sequence[dict[str, Any]],
562
+ ) -> list[dict[str, Any]]:
563
+ """Copy interactions without the optional compatibility-only field."""
564
+
565
+ return [
566
+ {
567
+ key: value
568
+ for key, value in interaction.items()
569
+ if key != "retrieved_learnings"
570
+ }
571
+ for interaction in interactions
572
+ ]
573
+
574
+
452
575
  def _filter_rejected_agent_playbooks(items: list[Any]) -> list[Any]:
453
576
  """Drop rejected shared skills defensively, even if an older backend ignores filters."""
454
577
  return [
@@ -15,27 +15,36 @@ INTERNAL_ENV = "CLAUDE_SMART_INTERNAL"
15
15
  HOST_CLAUDE_CODE = "claude-code"
16
16
  HOST_CODEX = "codex"
17
17
  HOST_OPENCODE = "opencode"
18
+ HOST_UNKNOWN = "unknown"
18
19
  VALID_HOSTS = frozenset({HOST_CLAUDE_CODE, HOST_CODEX, HOST_OPENCODE})
19
20
 
20
21
  _SHARED_AGENT_VERSION = "claude-code"
21
22
  _current_host: str | None = None
22
23
 
23
24
 
25
+ def _resolve_host(value: str | None, fallback: str) -> str:
26
+ return value if value in VALID_HOSTS else fallback
27
+
28
+
24
29
  def set_host(value: str | None) -> str:
25
30
  """Set the current host, returning the normalized value."""
26
31
  global _current_host
27
- host = value if value in VALID_HOSTS else HOST_CLAUDE_CODE
28
- _current_host = host
32
+ _current_host = value if value is not None else HOST_UNKNOWN
33
+ host = _resolve_host(_current_host, HOST_CLAUDE_CODE)
29
34
  os.environ[HOST_ENV] = host
30
35
  return host
31
36
 
32
37
 
33
38
  def host() -> str:
34
39
  """Return the current host, defaulting to Claude Code for compatibility."""
35
- if _current_host is not None:
36
- return _current_host
37
- value = os.environ.get(HOST_ENV)
38
- return value if value in VALID_HOSTS else HOST_CLAUDE_CODE
40
+ value = _current_host if _current_host is not None else os.environ.get(HOST_ENV)
41
+ return _resolve_host(value, HOST_CLAUDE_CODE)
42
+
43
+
44
+ def attribution_host() -> str:
45
+ """Return the explicitly selected record host, or unknown when unset."""
46
+ value = _current_host if _current_host is not None else os.environ.get(HOST_ENV)
47
+ return _resolve_host(value, HOST_UNKNOWN)
39
48
 
40
49
 
41
50
  def is_codex() -> bool: