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
@@ -8,8 +8,15 @@ wrapper over the ``record_usage_event`` hook (which only enqueues). No DB I/O.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
12
+ import uuid
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
11
16
  from reflexio.server.usage_metrics import record_usage_event
12
17
 
18
+ logger = logging.getLogger(__name__)
19
+
13
20
  _INTERNAL = (
14
21
  "internal" # == BillingCallerType.INTERNAL.value (kept literal; OSS stays clean)
15
22
  )
@@ -29,7 +36,10 @@ def record_extraction_tokens(
29
36
  ) -> None:
30
37
  """Emit the Learning cost facet — call only when extraction fired.
31
38
 
32
- No-op when ``billing_input_tokens <= 0``.
39
+ No-op when ``billing_input_tokens <= 0``. Each call mints a fresh
40
+ ``event_key=f"tok:{uuid4()}"`` so two token emits under the same
41
+ ``request_id`` (e.g. profile + playbook extraction in one request) never
42
+ collapse into one billed event downstream.
33
43
 
34
44
  Args:
35
45
  org_id: Organisation identifier.
@@ -51,6 +61,7 @@ def record_extraction_tokens(
51
61
  pipeline=pipeline,
52
62
  request_id=request_id,
53
63
  session_id=session_id,
64
+ event_key=f"tok:{uuid.uuid4()}",
54
65
  count_value=billing_input_tokens,
55
66
  prompt_tokens=prompt_tokens,
56
67
  completion_tokens=completion_tokens,
@@ -68,12 +79,26 @@ def record_learnings_generated(
68
79
  platform_llm: bool | None,
69
80
  platform_storage: bool | None,
70
81
  pipeline: str | None = None,
82
+ user_id: str | None = None,
71
83
  request_id: str | None = None,
72
84
  session_id: str | None = None,
85
+ source: str | None = None,
86
+ agent_version: str | None = None,
87
+ playbook_name: str | None = None,
88
+ entity_type: str | None = None,
89
+ metadata: Mapping[str, Any] | None = None,
73
90
  ) -> None:
74
91
  """Emit the Learning value facet — number of profiles/playbooks generated.
75
92
 
76
- No-op when ``count <= 0``.
93
+ Documented FALLBACK for callers that genuinely lack a per-record id list
94
+ (e.g. dedup/consolidation can reduce the persisted count below the raw
95
+ extracted count, so there is no safe 1:1 id per unit of ``count``). Prefer
96
+ :func:`record_learnings_generated_records` whenever the caller has the
97
+ durable learning ids in scope. No-op when ``count <= 0``.
98
+
99
+ Emits a single event carrying a synthesized ``event_key=f"learn-batch:{uuid4()}"``
100
+ (distinct per call) so this aggregate event still has a dedup key, even
101
+ though it is not entity-backed.
77
102
 
78
103
  Args:
79
104
  org_id: Organisation identifier.
@@ -81,8 +106,14 @@ def record_learnings_generated(
81
106
  platform_llm: True iff the platform supplies the LLM for this org.
82
107
  platform_storage: True iff the platform supplies storage; None defers to rollup.
83
108
  pipeline: Optional pipeline tag (e.g. ``"playbook"``).
109
+ user_id: Optional user ID tied to the generated learning.
84
110
  request_id: Optional request correlation ID.
85
111
  session_id: Optional session ID.
112
+ source: Optional metering source/path label.
113
+ agent_version: Optional agent version tied to the generated learning.
114
+ playbook_name: Optional playbook name for playbook learnings.
115
+ entity_type: Optional entity type (e.g. ``"profile"``).
116
+ metadata: Optional path-specific usage metadata.
86
117
  """
87
118
  if count <= 0:
88
119
  return
@@ -91,15 +122,237 @@ def record_learnings_generated(
91
122
  event_name="learnings_generated",
92
123
  event_category="learning",
93
124
  pipeline=pipeline,
125
+ user_id=user_id,
94
126
  request_id=request_id,
95
127
  session_id=session_id,
128
+ source=source,
129
+ agent_version=agent_version,
130
+ playbook_name=playbook_name,
131
+ entity_type=entity_type,
132
+ event_key=f"learn-batch:{uuid.uuid4()}",
96
133
  count_value=count,
97
134
  platform_llm=platform_llm,
98
135
  platform_storage=platform_storage,
99
136
  caller_type=_INTERNAL,
137
+ metadata=metadata,
100
138
  )
101
139
 
102
140
 
141
+ def record_learnings_generated_records(
142
+ *,
143
+ org_id: str,
144
+ learning_ids: list[str],
145
+ platform_llm: bool | None,
146
+ platform_storage: bool | None,
147
+ pipeline: str | None = None,
148
+ user_id: str | None = None,
149
+ request_id: str | None = None,
150
+ session_id: str | None = None,
151
+ source: str | None = None,
152
+ agent_version: str | None = None,
153
+ playbook_name: str | None = None,
154
+ entity_type: str | None = None,
155
+ metadata: Mapping[str, Any] | None = None,
156
+ ) -> None:
157
+ """Emit the Learning value facet — one event per generated learning record.
158
+
159
+ Entity-backed alternative to :func:`record_learnings_generated`: emits one
160
+ ``learnings_generated`` event per id in ``learning_ids`` (``count_value=1``,
161
+ ``event_key=f"learn:{entity_type}:{id}"``, ``entity_id=id``) instead of a
162
+ single aggregate event, so downstream dedup can key on the learning id.
163
+ The ``entity_type`` segment is required for collision-freedom: entity-backed
164
+ callers draw ids from separate autoincrement primary keys in separate
165
+ tables (e.g. ``user_playbook_id`` and ``agent_playbook_id`` both start at
166
+ 1), so the same integer id can legitimately occur in two tables — without
167
+ the entity-type segment those would mint the same ``event_key`` and
168
+ collapse into one event downstream. When ``entity_type`` is falsy, a
169
+ stable ``"_"`` placeholder is used (``learn:_:{id}``) rather than emitting
170
+ ``entity_type=None`` literally into the key. The summed ``count_value``
171
+ across the emitted events equals ``len(learning_ids)`` — unchanged from
172
+ the total a caller would have passed as ``count`` to
173
+ :func:`record_learnings_generated`.
174
+
175
+ Callers must pass real, durable ids — never fabricate one to pad the
176
+ list. No-op when ``learning_ids`` is empty.
177
+
178
+ Args:
179
+ org_id: Organisation identifier.
180
+ learning_ids: Ids of the learnings durably generated in this run
181
+ (e.g. ``profile_id`` / ``user_playbook_id`` / ``agent_playbook_id``).
182
+ platform_llm: True iff the platform supplies the LLM for this org.
183
+ platform_storage: True iff the platform supplies storage; None defers to rollup.
184
+ pipeline: Optional pipeline tag (e.g. ``"playbook"``).
185
+ user_id: Optional user ID tied to the generated learning.
186
+ request_id: Optional request correlation ID.
187
+ session_id: Optional session ID.
188
+ source: Optional metering source/path label.
189
+ agent_version: Optional agent version tied to the generated learning.
190
+ playbook_name: Optional playbook name for playbook learnings.
191
+ entity_type: Optional entity type (e.g. ``"profile"``).
192
+ metadata: Optional path-specific usage metadata (shared across events).
193
+ """
194
+ key_entity_type = entity_type or "_"
195
+ for learning_id in learning_ids:
196
+ record_usage_event(
197
+ org_id=org_id,
198
+ event_name="learnings_generated",
199
+ event_category="learning",
200
+ pipeline=pipeline,
201
+ user_id=user_id,
202
+ request_id=request_id,
203
+ session_id=session_id,
204
+ source=source,
205
+ agent_version=agent_version,
206
+ playbook_name=playbook_name,
207
+ entity_type=entity_type,
208
+ entity_id=learning_id,
209
+ event_key=f"learn:{key_entity_type}:{learning_id}",
210
+ count_value=1,
211
+ platform_llm=platform_llm,
212
+ platform_storage=platform_storage,
213
+ caller_type=_INTERNAL,
214
+ metadata=metadata,
215
+ )
216
+
217
+
218
+ def emit_learnings_generated(
219
+ *,
220
+ org_id: str,
221
+ configurator: Any,
222
+ count: int,
223
+ source: str,
224
+ pipeline: str | None = None,
225
+ user_id: str | None = None,
226
+ request_id: str | None = None,
227
+ agent_version: str | None = None,
228
+ playbook_name: str | None = None,
229
+ entity_type: str | None = None,
230
+ metadata: Mapping[str, Any] | None = None,
231
+ ) -> None:
232
+ """Resolve ``platform_llm`` from config and emit the Learning value facet.
233
+
234
+ Convenience wrapper for the non-extraction learning-mutation paths (reflection,
235
+ resumable-extraction finalization, aggregation, offline-tuner auto-apply). It
236
+ owns the ``configurator.get_config()`` + ``platform_llm_from_config`` lookup so
237
+ each call site stays a thin one-liner, and — critically — is **guarded**: the
238
+ product path must never fail because metering failed, so config resolution and
239
+ emission are wrapped and any exception is logged and swallowed (mirroring the
240
+ extraction path's ``_record_billing_learning_events``). No-op when
241
+ ``count <= 0``.
242
+
243
+ Args:
244
+ org_id: Organisation identifier.
245
+ configurator: Object exposing ``get_config()`` for platform-LLM resolution.
246
+ count: Number of learnings durably produced by this path.
247
+ source: Metering source/path label (e.g. ``"reflection"``).
248
+ pipeline: Optional pipeline tag (e.g. ``"playbook"``).
249
+ user_id: Optional user ID tied to the generated learning.
250
+ request_id: Optional request correlation ID.
251
+ agent_version: Optional agent version tied to the generated learning.
252
+ playbook_name: Optional playbook name for playbook learnings.
253
+ entity_type: Optional entity type (e.g. ``"profile"``).
254
+ metadata: Optional path-specific usage metadata.
255
+ """
256
+ if count <= 0:
257
+ return
258
+ try:
259
+ from reflexio.server.billing_signals import platform_llm_from_config
260
+
261
+ config = configurator.get_config()
262
+ record_learnings_generated(
263
+ org_id=org_id,
264
+ count=count,
265
+ platform_llm=platform_llm_from_config(config),
266
+ platform_storage=None,
267
+ pipeline=pipeline,
268
+ user_id=user_id,
269
+ request_id=request_id,
270
+ source=source,
271
+ agent_version=agent_version,
272
+ playbook_name=playbook_name,
273
+ entity_type=entity_type,
274
+ metadata=metadata,
275
+ )
276
+ except Exception:
277
+ logger.warning(
278
+ "emit_learnings_generated failed for source=%s org=%s; "
279
+ "learnings_generated event not emitted",
280
+ source,
281
+ org_id,
282
+ exc_info=True,
283
+ )
284
+
285
+
286
+ def emit_learnings_generated_records(
287
+ *,
288
+ org_id: str,
289
+ configurator: Any,
290
+ learning_ids: list[str],
291
+ source: str,
292
+ pipeline: str | None = None,
293
+ user_id: str | None = None,
294
+ request_id: str | None = None,
295
+ agent_version: str | None = None,
296
+ playbook_name: str | None = None,
297
+ entity_type: str | None = None,
298
+ metadata: Mapping[str, Any] | None = None,
299
+ ) -> None:
300
+ """Resolve ``platform_llm`` from config and emit one event per learning id.
301
+
302
+ Entity-backed counterpart to :func:`emit_learnings_generated`, currently
303
+ adopted by two of the non-extraction learning-mutation paths —
304
+ resumable-extraction finalization and aggregation — the callers with
305
+ durable per-record ids in scope. Extraction, reflection, and offline-tuner
306
+ auto-apply do not have a safe 1:1 id per unit of count (see
307
+ :func:`record_learnings_generated_records`) and use the count-based
308
+ :func:`emit_learnings_generated` fallback instead. Same guard semantics:
309
+ config resolution and emission are wrapped and any exception is logged
310
+ and swallowed — the product path must never fail because metering
311
+ failed. No-op when ``learning_ids`` is empty.
312
+
313
+ Args:
314
+ org_id: Organisation identifier.
315
+ configurator: Object exposing ``get_config()`` for platform-LLM resolution.
316
+ learning_ids: Ids of the learnings durably produced by this path.
317
+ source: Metering source/path label (e.g. ``"reflection"``).
318
+ pipeline: Optional pipeline tag (e.g. ``"playbook"``).
319
+ user_id: Optional user ID tied to the generated learning.
320
+ request_id: Optional request correlation ID.
321
+ agent_version: Optional agent version tied to the generated learning.
322
+ playbook_name: Optional playbook name for playbook learnings.
323
+ entity_type: Optional entity type (e.g. ``"profile"``).
324
+ metadata: Optional path-specific usage metadata (shared across events).
325
+ """
326
+ if not learning_ids:
327
+ return
328
+ try:
329
+ from reflexio.server.billing_signals import platform_llm_from_config
330
+
331
+ config = configurator.get_config()
332
+ record_learnings_generated_records(
333
+ org_id=org_id,
334
+ learning_ids=learning_ids,
335
+ platform_llm=platform_llm_from_config(config),
336
+ platform_storage=None,
337
+ pipeline=pipeline,
338
+ user_id=user_id,
339
+ request_id=request_id,
340
+ source=source,
341
+ agent_version=agent_version,
342
+ playbook_name=playbook_name,
343
+ entity_type=entity_type,
344
+ metadata=metadata,
345
+ )
346
+ except Exception:
347
+ logger.warning(
348
+ "emit_learnings_generated_records failed for source=%s org=%s; "
349
+ "learnings_generated events not emitted",
350
+ source,
351
+ org_id,
352
+ exc_info=True,
353
+ )
354
+
355
+
103
356
  def record_applied_learnings(
104
357
  *,
105
358
  org_id: str,
@@ -114,6 +367,8 @@ def record_applied_learnings(
114
367
  """Emit the Application line — surfaced top-K learnings.
115
368
 
116
369
  No-op unless ``caller_type == "production_agent"`` AND ``surfaced_count > 0``.
370
+ Each call mints a fresh ``event_key=f"applied:{uuid4()}"`` — a distinct
371
+ key per search-response moment, never collapsed by ``request_id``.
117
372
 
118
373
  Args:
119
374
  org_id: Organisation identifier.
@@ -134,6 +389,7 @@ def record_applied_learnings(
134
389
  pipeline=pipeline,
135
390
  request_id=request_id,
136
391
  session_id=session_id,
392
+ event_key=f"applied:{uuid.uuid4()}",
137
393
  count_value=surfaced_count,
138
394
  platform_llm=platform_llm,
139
395
  platform_storage=platform_storage,
@@ -152,7 +408,10 @@ def record_search_request(
152
408
 
153
409
  No-op unless ``caller_type == "production_agent"``. Unlike
154
410
  :func:`record_applied_learnings`, empty search responses still count because
155
- this measures requests made, not learnings surfaced.
411
+ this measures requests made, not learnings surfaced. Each call mints a
412
+ fresh ``event_key=f"search:{uuid4()}"`` — a distinct key per request, so
413
+ two searches under the same ``request_id`` are never collapsed into one
414
+ billed event downstream.
156
415
 
157
416
  Args:
158
417
  org_id: Organisation identifier.
@@ -168,6 +427,7 @@ def record_search_request(
168
427
  event_category="application",
169
428
  request_id=request_id,
170
429
  session_id=session_id,
430
+ event_key=f"search:{uuid.uuid4()}",
171
431
  count_value=1,
172
432
  caller_type=caller_type,
173
433
  )
@@ -0,0 +1,164 @@
1
+ """Process-wide bounded executor for deferred fire-and-forget callbacks.
2
+
3
+ Replaces thread-per-fire in the debounce schedulers (tagging, group
4
+ evaluation, playbook optimization) so thread count is O(1) instead of
5
+ O(active keys) (spec section 6.2). Sizes are module constants, not env vars.
6
+
7
+ Overflow policy: drop-oldest (a dropped fire is a stale debounced trigger —
8
+ the next event for that key re-creates it) with one warning log per drop and
9
+ a throttled anomaly when drops exceed ``_DROP_RATE_PER_MINUTE`` in a rolling
10
+ minute (the "this is routine, resize it" signal).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+ import time
18
+ from collections import deque
19
+ from collections.abc import Callable
20
+ from typing import NamedTuple
21
+
22
+ from reflexio.server.tracing import capture_anomaly
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _WORKERS = 16
27
+ _QUEUE_SIZE = 256
28
+ _DROP_RATE_PER_MINUTE = 10
29
+ _DROP_ANOMALY_THROTTLE_SECONDS = 3600.0
30
+
31
+
32
+ class _DropFacts(NamedTuple):
33
+ """Facts about one drop, collected while ``_cond`` is held.
34
+
35
+ Carries everything :meth:`BoundedCallbackExecutor._emit_drop` needs to log
36
+ + escalate AFTER the lock is released, so the contended lock is never held
37
+ across a logging/anomaly call.
38
+ """
39
+
40
+ dropped_name: str
41
+ drops_last_minute: int
42
+ fire_anomaly: bool
43
+
44
+
45
+ class BoundedCallbackExecutor:
46
+ """Fixed worker pool + bounded FIFO queue with drop-oldest overflow.
47
+
48
+ Args:
49
+ workers: Number of daemon worker threads.
50
+ queue_size: Max queued callbacks before drop-oldest applies.
51
+ """
52
+
53
+ def __init__(
54
+ self, *, workers: int = _WORKERS, queue_size: int = _QUEUE_SIZE
55
+ ) -> None:
56
+ self._queue: deque[tuple[str, Callable[[], None]]] = deque()
57
+ self._queue_size = queue_size
58
+ self._cond = threading.Condition()
59
+ self._drop_times: deque[float] = deque()
60
+ self._last_drop_anomaly = 0.0
61
+ for i in range(workers):
62
+ threading.Thread(
63
+ target=self._worker_loop,
64
+ daemon=True,
65
+ name=f"callback-exec-{i}",
66
+ ).start()
67
+
68
+ def submit(self, name: str, fn: Callable[[], None]) -> None:
69
+ """Enqueue a callback; on a full queue, evict the oldest queued fire.
70
+
71
+ Args:
72
+ name: Short label for logs (e.g. the scheduler key).
73
+ fn: Zero-arg callback to run on a worker.
74
+ """
75
+ drop_facts: _DropFacts | None = None
76
+ with self._cond:
77
+ if len(self._queue) >= self._queue_size:
78
+ dropped_name, _ = self._queue.popleft()
79
+ drop_facts = self._record_drop_locked(dropped_name)
80
+ self._queue.append((name, fn))
81
+ self._cond.notify()
82
+ if drop_facts is not None:
83
+ self._emit_drop(drop_facts)
84
+
85
+ def _record_drop_locked(self, dropped_name: str) -> _DropFacts:
86
+ """Collect the facts of one drop while ``self._cond`` is held.
87
+
88
+ Only bookkeeping happens under the lock (all 16 workers + all
89
+ producers contend on it): pruning the rolling drop-time window and
90
+ deciding — and, if firing, committing — the throttle state
91
+ (``_last_drop_anomaly``) so the decision stays race-free. Logging and
92
+ anomaly emission are the caller's job, done AFTER the lock is
93
+ released (see :meth:`_emit_drop`).
94
+ """
95
+ now = time.monotonic()
96
+ self._drop_times.append(now)
97
+ while self._drop_times and now - self._drop_times[0] > 60.0:
98
+ self._drop_times.popleft()
99
+ drops_last_minute = len(self._drop_times)
100
+ fire_anomaly = (
101
+ drops_last_minute > _DROP_RATE_PER_MINUTE
102
+ and now - self._last_drop_anomaly > _DROP_ANOMALY_THROTTLE_SECONDS
103
+ )
104
+ if fire_anomaly:
105
+ self._last_drop_anomaly = now
106
+ return _DropFacts(
107
+ dropped_name=dropped_name,
108
+ drops_last_minute=drops_last_minute,
109
+ fire_anomaly=fire_anomaly,
110
+ )
111
+
112
+ def _emit_drop(self, facts: _DropFacts) -> None:
113
+ """Log one drop and (if decided under the lock) escalate the anomaly.
114
+
115
+ Called with ``self._cond`` NOT held — logging and ``capture_anomaly``
116
+ never happen while the contended lock is owned. References the
117
+ module-level ``capture_anomaly`` name (not a bound attribute) so
118
+ tests that ``monkeypatch.setattr(module, "capture_anomaly", ...)``
119
+ still intercept the call.
120
+ """
121
+ logger.warning(
122
+ "event=callback_executor_drop_oldest dropped=%s", facts.dropped_name
123
+ )
124
+ if facts.fire_anomaly:
125
+ capture_anomaly(
126
+ "callback_executor.drop_rate_exceeded",
127
+ drops_last_minute=facts.drops_last_minute,
128
+ )
129
+
130
+ def _worker_loop(self) -> None:
131
+ while True:
132
+ with self._cond:
133
+ while not self._queue:
134
+ self._cond.wait()
135
+ name, fn = self._queue.popleft()
136
+ try:
137
+ fn()
138
+ except BaseException: # noqa: BLE001 — daemon worker threads must
139
+ # survive anything a callback raises (including a callback
140
+ # that itself raises SystemExit); KeyboardInterrupt is only
141
+ # ever delivered to the main thread, so swallowing it here is
142
+ # safe. The fixed 16-worker pool must never silently shrink.
143
+ logger.exception(
144
+ "event=callback_executor_callback_failed name=%s", name
145
+ )
146
+
147
+
148
+ _executor: BoundedCallbackExecutor | None = None
149
+ _executor_lock = threading.Lock()
150
+
151
+
152
+ def submit_callback(name: str, fn: Callable[[], None]) -> None:
153
+ """Submit to the lazily-created process-wide executor.
154
+
155
+ Args:
156
+ name: Short label for logs.
157
+ fn: Zero-arg callback.
158
+ """
159
+ global _executor # noqa: PLW0603
160
+ if _executor is None:
161
+ with _executor_lock:
162
+ if _executor is None:
163
+ _executor = BoundedCallbackExecutor()
164
+ _executor.submit(name, fn)