claude-smart 0.2.49 → 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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  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/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -13,7 +13,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
13
13
  from reflexio.models.api_schema.retriever_schema import SearchUserPlaybookRequest
14
14
  from reflexio.models.api_schema.service_schemas import UserPlaybook
15
15
  from reflexio.models.config_schema import (
16
- EMBEDDING_DIMENSIONS,
17
16
  DeduplicationConfig,
18
17
  SearchOptions,
19
18
  )
@@ -23,6 +22,10 @@ from reflexio.server.llm.litellm_client import LiteLLMClient
23
22
  from reflexio.server.services.deduplication_utils import (
24
23
  BaseDeduplicator,
25
24
  format_dedup_timestamp,
25
+ resolve_dedup_query_embeddings,
26
+ )
27
+ from reflexio.server.services.profile.profile_generation_service_utils import (
28
+ check_string_token_overlap,
26
29
  )
27
30
  from reflexio.server.tracing import sentry_tags
28
31
 
@@ -191,6 +194,22 @@ def _existing_id_log_label(value: ExistingIdField) -> str:
191
194
  return str(value)
192
195
 
193
196
 
197
+ def _normalize_generation_request_id(
198
+ generation_request_id: str | None,
199
+ *,
200
+ request_id: str | None = None,
201
+ ) -> str:
202
+ if generation_request_id is not None:
203
+ if request_id is not None and request_id != generation_request_id:
204
+ raise TypeError(
205
+ "generation_request_id and request_id must match when both are provided"
206
+ )
207
+ return generation_request_id
208
+ if request_id is not None:
209
+ return request_id
210
+ raise TypeError("generation_request_id is required")
211
+
212
+
194
213
  class UnifyDecision(BaseModel):
195
214
  """Collapse NEW (+ 0..N EXISTING) into one row with LLM-supplied content.
196
215
 
@@ -379,6 +398,50 @@ _COUNTER_BY_KIND: dict[str, str] = {
379
398
  }
380
399
 
381
400
 
401
+ def _decision_new_ids(decision: ConsolidationDecision) -> list[str]:
402
+ if isinstance(decision, DifferentiateDecision):
403
+ return [decision.new_id]
404
+ return decision.new_ids
405
+
406
+
407
+ def validate_consolidation_output(
408
+ new_playbooks: list[UserPlaybook],
409
+ output: PlaybookConsolidationOutput,
410
+ ) -> list[str]:
411
+ """Return partition-contract errors for a parsed consolidation output."""
412
+ known_new_ids = {f"NEW-{idx}" for idx in range(len(new_playbooks))}
413
+ seen_by_id: dict[str, list[str]] = {}
414
+ errors: list[str] = []
415
+
416
+ for decision_index, decision in enumerate(output.decisions):
417
+ label = f"decision[{decision_index}] {decision.kind}"
418
+ for new_id in _decision_new_ids(decision):
419
+ if new_id not in known_new_ids:
420
+ errors.append(f"{label} references unknown NEW id {new_id}.")
421
+ continue
422
+ seen_by_id.setdefault(new_id, []).append(label)
423
+
424
+ missing = sorted(known_new_ids - set(seen_by_id))
425
+ if missing:
426
+ errors.append(
427
+ "Every NEW id must appear exactly once; missing NEW ids: "
428
+ + ", ".join(missing)
429
+ + "."
430
+ )
431
+
432
+ duplicates = {
433
+ new_id: labels for new_id, labels in seen_by_id.items() if len(labels) > 1
434
+ }
435
+ for new_id, labels in sorted(duplicates.items()):
436
+ errors.append(
437
+ f"Every NEW id must appear exactly once; {new_id} appears in "
438
+ + "; ".join(labels)
439
+ + "."
440
+ )
441
+
442
+ return errors
443
+
444
+
382
445
  class PlaybookConsolidator(BaseDeduplicator):
383
446
  """
384
447
  Consolidates new user playbook entries against each other and against existing entries
@@ -498,15 +561,12 @@ class PlaybookConsolidator(BaseDeduplicator):
498
561
  if not query_texts:
499
562
  return []
500
563
 
501
- # Batch-generate embeddings
502
- try:
503
- embeddings = self.client.get_embeddings(
504
- query_texts, dimensions=EMBEDDING_DIMENSIONS
505
- )
506
- except Exception as e:
507
- logger.warning("Failed to generate embeddings for dedup search: %s", e)
508
- # Fall back to text-only search
509
- embeddings = [None] * len(query_texts)
564
+ # Embed dedup queries with the same model that indexed the store —
565
+ # see resolve_dedup_query_embeddings for why the client's default
566
+ # embedding model must not be used here.
567
+ embeddings = resolve_dedup_query_embeddings(
568
+ storage, self.client, query_texts, entity_label="Playbook"
569
+ )
510
570
 
511
571
  # Search for each new entry
512
572
  seen_ids: set[int] = set()
@@ -565,6 +625,36 @@ class PlaybookConsolidator(BaseDeduplicator):
565
625
  )
566
626
  return new_text, existing_text
567
627
 
628
+ def _render_consolidation_prompt(
629
+ self,
630
+ new_playbooks: list[UserPlaybook],
631
+ existing_playbooks: list[UserPlaybook],
632
+ ) -> str:
633
+ """Render the consolidation prompt for the given NEW + EXISTING rows.
634
+
635
+ Rendering is deterministic, so the repair path calls this again to
636
+ reconstruct the exact first-turn prompt when building the follow-up
637
+ conversation.
638
+
639
+ Args:
640
+ new_playbooks: New entries to deduplicate.
641
+ existing_playbooks: Existing entries from the database.
642
+
643
+ Returns:
644
+ The fully rendered consolidation prompt.
645
+ """
646
+ new_text, existing_text = self._format_new_and_existing_for_prompt(
647
+ new_playbooks, existing_playbooks
648
+ )
649
+ return self.request_context.prompt_manager.render_prompt(
650
+ self._get_prompt_id(),
651
+ {
652
+ "new_playbook_count": len(new_playbooks),
653
+ "new_playbooks": new_text,
654
+ "existing_playbooks": existing_text,
655
+ },
656
+ )
657
+
568
658
  def _consolidation_decisions(
569
659
  self,
570
660
  new_playbooks: list[UserPlaybook],
@@ -605,20 +695,7 @@ class PlaybookConsolidator(BaseDeduplicator):
605
695
  Parsed ``PlaybookConsolidationOutput``; an empty output (no
606
696
  decisions) if the LLM returned an unexpected response shape.
607
697
  """
608
- # Format for prompt
609
- new_text, existing_text = self._format_new_and_existing_for_prompt(
610
- new_playbooks, existing_playbooks
611
- )
612
-
613
- # Build and call LLM
614
- prompt = self.request_context.prompt_manager.render_prompt(
615
- self._get_prompt_id(),
616
- {
617
- "new_playbook_count": len(new_playbooks),
618
- "new_playbooks": new_text,
619
- "existing_playbooks": existing_text,
620
- },
621
- )
698
+ prompt = self._render_consolidation_prompt(new_playbooks, existing_playbooks)
622
699
 
623
700
  output_schema_class = self._get_output_schema_class()
624
701
 
@@ -653,16 +730,18 @@ class PlaybookConsolidator(BaseDeduplicator):
653
730
  def deduplicate(
654
731
  self,
655
732
  results: list[list[UserPlaybook]],
656
- request_id: str,
657
- agent_version: str,
733
+ generation_request_id: str | None = None,
734
+ agent_version: str | None = None,
658
735
  user_id: str | None = None,
736
+ *,
737
+ request_id: str | None = None,
659
738
  ) -> tuple[list[UserPlaybook], list[int], list[tuple[int, list[int]]]]:
660
739
  """
661
740
  Consolidate user playbook entries across extractors and against existing entries in DB.
662
741
 
663
742
  Args:
664
743
  results: List of entry lists from extractors (each extractor returns list[UserPlaybook])
665
- request_id: Request ID for context
744
+ generation_request_id: Request ID for context
666
745
  agent_version: Agent version for context
667
746
  user_id: Optional user ID to scope the existing entry search
668
747
 
@@ -680,6 +759,12 @@ class PlaybookConsolidator(BaseDeduplicator):
680
759
  non-merge archives such as ``differentiate``'s split source); the
681
760
  caller subtracts the merge-covered ids to find pure-delete leftovers.
682
761
  """
762
+ generation_request_id = _normalize_generation_request_id(
763
+ generation_request_id, request_id=request_id
764
+ )
765
+ if agent_version is None:
766
+ raise TypeError("agent_version is required")
767
+
683
768
  # Check if mock mode is enabled
684
769
  if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
685
770
  logger.info("Mock mode: skipping consolidation")
@@ -714,7 +799,7 @@ class PlaybookConsolidator(BaseDeduplicator):
714
799
  op="identify_duplicates",
715
800
  org_id=self.request_context.org_id,
716
801
  user_id=user_id,
717
- request_id=request_id,
802
+ request_id=generation_request_id,
718
803
  agent_version=agent_version,
719
804
  error_type=type(e).__name__,
720
805
  ):
@@ -723,14 +808,21 @@ class PlaybookConsolidator(BaseDeduplicator):
723
808
 
724
809
  if not dedup_output.decisions:
725
810
  logger.info(
726
- "No consolidation decisions returned for request %s", request_id
811
+ "No consolidation decisions returned for request %s",
812
+ generation_request_id,
727
813
  )
728
814
  return new_playbooks, [], []
729
815
 
730
816
  logger.info(
731
817
  "Received %d consolidation decisions for request %s",
732
818
  len(dedup_output.decisions),
733
- request_id,
819
+ generation_request_id,
820
+ )
821
+ dedup_output = self._repair_consolidation_output_if_needed(
822
+ new_playbooks=new_playbooks,
823
+ existing_playbooks=existing_playbooks,
824
+ dedup_output=dedup_output,
825
+ request_id=request_id,
734
826
  )
735
827
 
736
828
  # Build consolidated result via the discriminated-union apply path
@@ -738,10 +830,215 @@ class PlaybookConsolidator(BaseDeduplicator):
738
830
  new_playbooks=new_playbooks,
739
831
  existing_playbooks=existing_playbooks,
740
832
  dedup_output=dedup_output,
741
- request_id=request_id,
833
+ generation_request_id=generation_request_id,
742
834
  agent_version=agent_version,
743
835
  )
744
836
 
837
+ def _repair_consolidation_output_if_needed(
838
+ self,
839
+ *,
840
+ new_playbooks: list[UserPlaybook],
841
+ existing_playbooks: list[UserPlaybook],
842
+ dedup_output: PlaybookConsolidationOutput,
843
+ request_id: str | None,
844
+ ) -> PlaybookConsolidationOutput:
845
+ errors = validate_consolidation_output(new_playbooks, dedup_output)
846
+ if not errors:
847
+ errors = self._find_suspicious_under_consumed_new_rows(
848
+ new_playbooks, dedup_output
849
+ )
850
+ if not errors:
851
+ return dedup_output
852
+
853
+ repaired = self._repair_consolidation_output(
854
+ original_prompt=self._render_consolidation_prompt(
855
+ new_playbooks, existing_playbooks
856
+ ),
857
+ invalid_output=dedup_output,
858
+ errors=errors,
859
+ request_id=request_id,
860
+ )
861
+ if repaired is None:
862
+ return dedup_output
863
+
864
+ repaired_errors = validate_consolidation_output(new_playbooks, repaired)
865
+ if repaired_errors:
866
+ logger.warning(
867
+ "event=consolidation_repair_failed request_id=%s errors=%s",
868
+ request_id,
869
+ repaired_errors,
870
+ )
871
+ return dedup_output
872
+
873
+ logger.info(
874
+ "event=consolidation_repaired request_id=%s decision_count=%d",
875
+ request_id,
876
+ len(repaired.decisions),
877
+ )
878
+ return repaired
879
+
880
+ def _repair_consolidation_output(
881
+ self,
882
+ *,
883
+ original_prompt: str,
884
+ invalid_output: PlaybookConsolidationOutput,
885
+ errors: list[str],
886
+ request_id: str | None,
887
+ ) -> PlaybookConsolidationOutput | None:
888
+ """Ask the model to fix its consolidation output in a follow-up turn.
889
+
890
+ The repair is sent as the third turn of the original conversation —
891
+ [user: consolidation prompt, assistant: the invalid decisions, user:
892
+ validation errors + fix instruction] — so the model retains the full
893
+ first-turn context (rendered rows, decision-kind rules, output format)
894
+ without restating any of it.
895
+
896
+ Args:
897
+ original_prompt: The exact rendered first-turn consolidation prompt.
898
+ invalid_output: The parsed decisions that failed validation.
899
+ errors: Human-readable validation errors to feed back.
900
+ request_id: Request ID for log correlation.
901
+
902
+ Returns:
903
+ The repaired ``PlaybookConsolidationOutput``, or None when the
904
+ repair call fails or returns an unexpected shape.
905
+ """
906
+ logger.warning(
907
+ "event=consolidation_repair_attempted request_id=%s errors=%s",
908
+ request_id,
909
+ errors,
910
+ )
911
+ followup = "\n".join(
912
+ [
913
+ "Your decisions above violate the NEW-id coverage contract:",
914
+ "",
915
+ "\n".join(f"- {error}" for error in errors),
916
+ "",
917
+ "Return corrected decisions that cover every NEW id exactly once. "
918
+ "If a unified rule uses facts/details from multiple NEW rows, `new_id` must list all of them. "
919
+ "If your decisions are semantically correct apart from these errors, "
920
+ "keep them and fix only the coverage.",
921
+ "",
922
+ 'Respond ONLY with valid JSON matching PlaybookConsolidationOutput: {"decisions": [...]}.',
923
+ ]
924
+ )
925
+ messages = [
926
+ {"role": "user", "content": original_prompt},
927
+ {"role": "assistant", "content": invalid_output.model_dump_json()},
928
+ {"role": "user", "content": followup},
929
+ ]
930
+
931
+ from reflexio.server.services.service_utils import (
932
+ log_llm_messages,
933
+ log_model_response,
934
+ )
935
+
936
+ log_llm_messages(logger, "Playbook consolidation repair", messages)
937
+
938
+ try:
939
+ response = self.client.generate_chat_response(
940
+ messages=messages,
941
+ model=self.model_name,
942
+ response_format=self._get_output_schema_class(),
943
+ )
944
+ except Exception as exc: # noqa: BLE001 — repair is best-effort
945
+ logger.warning(
946
+ "event=consolidation_repair_failed request_id=%s error_type=%s",
947
+ request_id,
948
+ type(exc).__name__,
949
+ exc_info=True,
950
+ )
951
+ return None
952
+
953
+ log_model_response(logger, "Consolidation repair response", response)
954
+
955
+ if not isinstance(response, PlaybookConsolidationOutput):
956
+ logger.warning(
957
+ "event=consolidation_repair_failed request_id=%s unexpected_type=%s",
958
+ request_id,
959
+ type(response),
960
+ )
961
+ return None
962
+ return response
963
+
964
+ @staticmethod
965
+ def _find_suspicious_under_consumed_new_rows(
966
+ new_playbooks: list[UserPlaybook],
967
+ dedup_output: PlaybookConsolidationOutput,
968
+ ) -> list[str]:
969
+ candidates_by_id = {
970
+ f"NEW-{idx}": playbook for idx, playbook in enumerate(new_playbooks)
971
+ }
972
+ # Every kind except reject_new stores a row for its consumed candidates
973
+ # (raw for independent, refined-trigger copy for differentiate, the
974
+ # re-synthesized decision content for a sibling unify), so any of them
975
+ # can duplicate a unify's merged row. Only reject_new drops the row
976
+ # outright and is never a duplicate-storage risk worth a repair call.
977
+ consuming_kind_by_new_id = {
978
+ new_id: decision.kind
979
+ for decision in dedup_output.decisions
980
+ for new_id in _decision_new_ids(decision)
981
+ }
982
+ source_ids_by_decision = [
983
+ {
984
+ source_id
985
+ for new_id in _decision_new_ids(decision)
986
+ if new_id in candidates_by_id
987
+ for source_id in candidates_by_id[new_id].source_interaction_ids
988
+ }
989
+ for decision in dedup_output.decisions
990
+ ]
991
+ errors: list[str] = []
992
+
993
+ for decision_index, decision in enumerate(dedup_output.decisions):
994
+ if not isinstance(decision, UnifyDecision):
995
+ continue
996
+ consumed_ids = set(decision.new_ids)
997
+ consumed_source_ids = source_ids_by_decision[decision_index]
998
+ if not consumed_source_ids:
999
+ continue
1000
+
1001
+ # Candidates stored (near-)verbatim by another decision whose facts
1002
+ # this unify's merged content may have absorbed.
1003
+ for other_id, other in candidates_by_id.items():
1004
+ if other_id in consumed_ids:
1005
+ continue
1006
+ if consuming_kind_by_new_id.get(other_id) not in (
1007
+ "independent",
1008
+ "differentiate",
1009
+ ):
1010
+ continue
1011
+ if not consumed_source_ids.intersection(other.source_interaction_ids):
1012
+ continue
1013
+ if check_string_token_overlap(decision.content, other.content):
1014
+ errors.append(
1015
+ f"decision[{decision_index}] unify likely absorbed facts from {other_id} but did not list it in new_id; "
1016
+ "if the unified rule absorbed this row's facts, add its id to new_id; "
1017
+ "if not, leave the decisions unchanged."
1018
+ )
1019
+
1020
+ # Sibling unify decisions each persist their own survivor row, so
1021
+ # two same-source unifies with heavily overlapping FINAL contents
1022
+ # are the same duplicate-storage class. Compare stored content to
1023
+ # stored content and flag each pair once (j > i).
1024
+ for sibling_index in range(decision_index + 1, len(dedup_output.decisions)):
1025
+ sibling = dedup_output.decisions[sibling_index]
1026
+ if not isinstance(sibling, UnifyDecision):
1027
+ continue
1028
+ if not consumed_source_ids.intersection(
1029
+ source_ids_by_decision[sibling_index]
1030
+ ):
1031
+ continue
1032
+ if check_string_token_overlap(decision.content, sibling.content):
1033
+ errors.append(
1034
+ f"decision[{decision_index}] and decision[{sibling_index}] are both unify decisions over "
1035
+ "same-source NEW rows with highly overlapping content; if they describe the same skill, "
1036
+ "merge them into one unify decision whose new_id lists every consumed NEW id; "
1037
+ "if they are genuinely distinct skills, leave the decisions unchanged."
1038
+ )
1039
+
1040
+ return errors
1041
+
745
1042
  # ===============================
746
1043
  # Apply path: discriminated-union decisions -> (new rows, archive ids)
747
1044
  # ===============================
@@ -751,8 +1048,10 @@ class PlaybookConsolidator(BaseDeduplicator):
751
1048
  new_playbooks: list[UserPlaybook],
752
1049
  existing_playbooks: list[UserPlaybook],
753
1050
  dedup_output: PlaybookConsolidationOutput,
754
- request_id: str,
755
- agent_version: str, # noqa: ARG002
1051
+ generation_request_id: str | None = None,
1052
+ agent_version: str | None = None, # noqa: ARG002
1053
+ *,
1054
+ request_id: str | None = None,
756
1055
  ) -> tuple[list[UserPlaybook], list[int], list[tuple[int, list[int]]]]:
757
1056
  """
758
1057
  Build the deduplicated entry list from LLM decisions.
@@ -766,7 +1065,7 @@ class PlaybookConsolidator(BaseDeduplicator):
766
1065
  new_playbooks: Flattened list of new (candidate) entries.
767
1066
  existing_playbooks: List of existing entries from the DB.
768
1067
  dedup_output: LLM decisions output (discriminated union).
769
- request_id: Request ID stamped onto newly-built rows.
1068
+ generation_request_id: Request ID stamped onto newly-built rows.
770
1069
  agent_version: Agent version (currently unused, kept for symmetry).
771
1070
 
772
1071
  Returns:
@@ -781,6 +1080,10 @@ class PlaybookConsolidator(BaseDeduplicator):
781
1080
  two rows (no single survivor), so its archived id appears in the
782
1081
  delete set but NOT in any merge group.
783
1082
  """
1083
+ generation_request_id = _normalize_generation_request_id(
1084
+ generation_request_id, request_id=request_id
1085
+ )
1086
+
784
1087
  candidates_by_id = {
785
1088
  f"NEW-{idx}": playbook for idx, playbook in enumerate(new_playbooks)
786
1089
  }
@@ -810,7 +1113,7 @@ class PlaybookConsolidator(BaseDeduplicator):
810
1113
  existing_by_position=existing_by_position,
811
1114
  archive_ids=archive_ids,
812
1115
  seen_archive=seen_archive,
813
- request_id=request_id,
1116
+ generation_request_id=generation_request_id,
814
1117
  )
815
1118
  except Exception as exc: # noqa: BLE001 — per-decision isolation
816
1119
  result_counters.failed_count += 1
@@ -834,7 +1137,7 @@ class PlaybookConsolidator(BaseDeduplicator):
834
1137
  subsystem="playbook_consolidator",
835
1138
  op="apply_decision",
836
1139
  org_id=self.request_context.org_id,
837
- request_id=request_id,
1140
+ request_id=generation_request_id,
838
1141
  kind=decision.kind,
839
1142
  new_id=new_id_str,
840
1143
  existing_id=existing_id_str,
@@ -890,7 +1193,7 @@ class PlaybookConsolidator(BaseDeduplicator):
890
1193
  existing_by_position: dict[str, UserPlaybook],
891
1194
  archive_ids: list[int],
892
1195
  seen_archive: set[int],
893
- request_id: str,
1196
+ generation_request_id: str,
894
1197
  ) -> tuple[list[UserPlaybook], list[str], list[int]]:
895
1198
  """Dispatch a single decision to its kind-specific apply method.
896
1199
 
@@ -904,7 +1207,7 @@ class PlaybookConsolidator(BaseDeduplicator):
904
1207
  archive_ids: Accumulator list mutated with ids to archive/delete.
905
1208
  seen_archive: Accumulator set guarding ``archive_ids`` against
906
1209
  duplicate ids.
907
- request_id: Request ID stamped onto newly-built rows.
1210
+ generation_request_id: Request ID stamped onto newly-built rows.
908
1211
 
909
1212
  Returns:
910
1213
  Tuple of ``(rows_to_insert, handled_new_ids, merge_source_ids)``.
@@ -923,7 +1226,7 @@ class PlaybookConsolidator(BaseDeduplicator):
923
1226
  existing_by_position=existing_by_position,
924
1227
  archive_ids=archive_ids,
925
1228
  seen_archive=seen_archive,
926
- request_id=request_id,
1229
+ generation_request_id=generation_request_id,
927
1230
  )
928
1231
  if isinstance(decision, RejectNewDecision):
929
1232
  return self._apply_reject_new(
@@ -940,10 +1243,14 @@ class PlaybookConsolidator(BaseDeduplicator):
940
1243
  existing_by_position=existing_by_position,
941
1244
  archive_ids=archive_ids,
942
1245
  seen_archive=seen_archive,
943
- request_id=request_id,
1246
+ generation_request_id=generation_request_id,
944
1247
  )
945
1248
  if isinstance(decision, IndependentDecision):
946
- return self._apply_independent(decision, candidates_by_id=candidates_by_id)
1249
+ return self._apply_independent(
1250
+ decision,
1251
+ candidates_by_id=candidates_by_id,
1252
+ generation_request_id=generation_request_id,
1253
+ )
947
1254
  raise ValueError(f"unknown decision kind: {decision}")
948
1255
 
949
1256
  def _apply_unify(
@@ -954,7 +1261,7 @@ class PlaybookConsolidator(BaseDeduplicator):
954
1261
  existing_by_position: dict[str, UserPlaybook],
955
1262
  archive_ids: list[int],
956
1263
  seen_archive: set[int],
957
- request_id: str,
1264
+ generation_request_id: str,
958
1265
  ) -> tuple[list[UserPlaybook], list[str], list[int]]:
959
1266
  """Collapse / compose NEW (+ 0..N EXISTING) into one row.
960
1267
 
@@ -974,7 +1281,7 @@ class PlaybookConsolidator(BaseDeduplicator):
974
1281
  existing_by_position: Mapping ``"EXISTING-M"`` -> existing playbook.
975
1282
  archive_ids: Accumulator mutated with EXISTING ids to archive.
976
1283
  seen_archive: Dedup set for ``archive_ids``.
977
- request_id: Request ID stamped on the unified row.
1284
+ generation_request_id: Request ID stamped on the unified row.
978
1285
 
979
1286
  Returns:
980
1287
  Tuple of ``([unified_row], [consumed NEW-N ids], merge_source_ids)``
@@ -1035,7 +1342,8 @@ class PlaybookConsolidator(BaseDeduplicator):
1035
1342
  user_playbook_id=0,
1036
1343
  user_id=primary_candidate.user_id,
1037
1344
  agent_version=primary_candidate.agent_version,
1038
- request_id=request_id,
1345
+ # Legacy storage/API field; value may be synthetic for manual/rerun flows.
1346
+ request_id=generation_request_id,
1039
1347
  playbook_name=primary_candidate.playbook_name,
1040
1348
  created_at=int(datetime.now(UTC).timestamp()),
1041
1349
  content=decision.content,
@@ -1120,7 +1428,7 @@ class PlaybookConsolidator(BaseDeduplicator):
1120
1428
  existing_by_position: dict[str, UserPlaybook],
1121
1429
  archive_ids: list[int],
1122
1430
  seen_archive: set[int],
1123
- request_id: str,
1431
+ generation_request_id: str,
1124
1432
  ) -> tuple[list[UserPlaybook], list[str], list[int]]:
1125
1433
  """Archive the existing row and emit two refined rows in its place.
1126
1434
 
@@ -1136,7 +1444,7 @@ class PlaybookConsolidator(BaseDeduplicator):
1136
1444
  existing_by_position: Mapping ``"EXISTING-M"`` -> existing playbook.
1137
1445
  archive_ids: Accumulator mutated with the existing id to archive.
1138
1446
  seen_archive: Dedup set for ``archive_ids``.
1139
- request_id: Request ID stamped on both new rows.
1447
+ generation_request_id: Request ID stamped on both new rows.
1140
1448
 
1141
1449
  Returns:
1142
1450
  Tuple of ``([refined_new_row, refined_existing_row], [NEW-N id],
@@ -1168,7 +1476,7 @@ class PlaybookConsolidator(BaseDeduplicator):
1168
1476
  refined_candidate = candidate.model_copy(
1169
1477
  update={
1170
1478
  "user_playbook_id": 0,
1171
- "request_id": request_id,
1479
+ "request_id": generation_request_id,
1172
1480
  "trigger": decision.refined_new_trigger,
1173
1481
  "created_at": now_ts,
1174
1482
  }
@@ -1176,7 +1484,7 @@ class PlaybookConsolidator(BaseDeduplicator):
1176
1484
  refined_existing = existing.model_copy(
1177
1485
  update={
1178
1486
  "user_playbook_id": 0,
1179
- "request_id": request_id,
1487
+ "request_id": generation_request_id,
1180
1488
  "trigger": decision.refined_existing_trigger,
1181
1489
  "created_at": now_ts,
1182
1490
  "source_interaction_ids": list(existing.source_interaction_ids),
@@ -1189,6 +1497,7 @@ class PlaybookConsolidator(BaseDeduplicator):
1189
1497
  decision: IndependentDecision,
1190
1498
  *,
1191
1499
  candidates_by_id: dict[str, UserPlaybook],
1500
+ generation_request_id: str,
1192
1501
  ) -> tuple[list[UserPlaybook], list[str], list[int]]:
1193
1502
  """Insert the new candidate unchanged; no archive.
1194
1503
 
@@ -1205,7 +1514,9 @@ class PlaybookConsolidator(BaseDeduplicator):
1205
1514
  candidate = candidates_by_id.get(new_id)
1206
1515
  if candidate is None:
1207
1516
  raise KeyError(f"independent references unknown NEW id: {new_id}")
1208
- rows.append(candidate)
1517
+ rows.append(
1518
+ candidate.model_copy(update={"request_id": generation_request_id})
1519
+ )
1209
1520
  return rows, decision.new_ids, []
1210
1521
 
1211
1522
  @staticmethod