claude-smart 0.2.46 → 0.2.48

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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -33,6 +33,74 @@ logger = logging.getLogger(__name__)
33
33
  # Playbook-specific Pydantic Output Schemas for LLM
34
34
  # ===============================
35
35
 
36
+ NewIdField = str | list[str]
37
+ ExistingIdField = int | list[int]
38
+
39
+
40
+ def _strip_prompt_brackets(value: str) -> str:
41
+ """Strip the display brackets used in prompt labels, e.g. ``[NEW-0]``."""
42
+ stripped = value.strip()
43
+ if stripped.startswith("[") and stripped.endswith("]"):
44
+ return stripped[1:-1].strip()
45
+ return stripped
46
+
47
+
48
+ def _dedupe_preserving_order(values: list[str]) -> list[str]:
49
+ seen: set[str] = set()
50
+ deduped: list[str] = []
51
+ for value in values:
52
+ if value in seen:
53
+ continue
54
+ seen.add(value)
55
+ deduped.append(value)
56
+ return deduped
57
+
58
+
59
+ def _coerce_new_id(value: object) -> str:
60
+ """Normalize a prompt-format NEW id to ``NEW-N``.
61
+
62
+ LLMs sometimes echo the rendered display label (``[NEW-0]``) or wrap the
63
+ id in a one-item list. Keep the apply path keyed on canonical ``NEW-N``.
64
+ """
65
+ if isinstance(value, list):
66
+ if len(value) != 1:
67
+ raise ValueError(f"new_id expected one NEW id, got {value!r}")
68
+ return _coerce_new_id(value[0])
69
+ if not isinstance(value, str):
70
+ raise ValueError(
71
+ f"new_id must be 'NEW-N' label, got {type(value).__name__}: {value!r}"
72
+ )
73
+
74
+ stripped = _strip_prompt_brackets(value)
75
+ for prefix in ("NEW-", "NEW_", "new-", "new_"):
76
+ if stripped.startswith(prefix):
77
+ suffix = stripped[len(prefix) :]
78
+ break
79
+ else:
80
+ raise ValueError(f"new_id must be 'NEW-N' label, got {value!r}")
81
+
82
+ try:
83
+ parsed = int(suffix)
84
+ except ValueError as exc:
85
+ raise ValueError(f"new_id must be 'NEW-N' label, got {value!r}") from exc
86
+ if parsed < 0:
87
+ raise ValueError(f"new_id must be >= 0, got {value!r}")
88
+ return f"NEW-{parsed}"
89
+
90
+
91
+ def _coerce_new_ids(value: object, *, allow_many: bool) -> NewIdField:
92
+ values = value if isinstance(value, list) else [value]
93
+ coerced = _dedupe_preserving_order([_coerce_new_id(item) for item in values])
94
+ if not coerced:
95
+ raise ValueError("new_id must include at least one NEW id")
96
+ if not allow_many and len(coerced) != 1:
97
+ raise ValueError(f"new_id expected one NEW id, got {coerced!r}")
98
+ return coerced if len(coerced) > 1 else coerced[0]
99
+
100
+
101
+ def _new_ids_from_field(value: NewIdField) -> list[str]:
102
+ return list(value) if isinstance(value, list) else [value]
103
+
36
104
 
37
105
  def _coerce_existing_position(value: object) -> int:
38
106
  """Accept either a bare int position or an ``"EXISTING-N"`` label.
@@ -71,12 +139,16 @@ def _coerce_existing_position(value: object) -> int:
71
139
  # ``bool`` is a subclass of ``int`` in Python; reject explicitly so a
72
140
  # stray ``True`` doesn't silently become position 1.
73
141
  raise ValueError(f"existing-position must be int, got bool: {value!r}")
142
+ if isinstance(value, list):
143
+ if len(value) != 1:
144
+ raise ValueError(f"existing-position expected one id, got {value!r}")
145
+ return _coerce_existing_position(value[0])
74
146
  if isinstance(value, int):
75
147
  if value < 0:
76
148
  raise ValueError(f"existing-position must be >= 0, got {value!r}")
77
149
  return value
78
150
  if isinstance(value, str):
79
- stripped = value.strip()
151
+ stripped = _strip_prompt_brackets(value)
80
152
  for prefix in ("EXISTING-", "EXISTING_", "existing-", "existing_"):
81
153
  if stripped.startswith(prefix):
82
154
  stripped = stripped[len(prefix) :]
@@ -95,6 +167,30 @@ def _coerce_existing_position(value: object) -> int:
95
167
  )
96
168
 
97
169
 
170
+ def _coerce_existing_positions(value: object, *, allow_many: bool) -> ExistingIdField:
171
+ values = value if isinstance(value, list) else [value]
172
+ coerced = [_coerce_existing_position(item) for item in values]
173
+ if not coerced:
174
+ raise ValueError("existing-position must include at least one id")
175
+ if not allow_many and len(coerced) != 1:
176
+ raise ValueError(f"existing-position expected one id, got {coerced!r}")
177
+ return coerced if len(coerced) > 1 else coerced[0]
178
+
179
+
180
+ def _existing_ids_from_field(value: ExistingIdField) -> list[int]:
181
+ return list(value) if isinstance(value, list) else [value]
182
+
183
+
184
+ def _new_id_log_label(value: NewIdField) -> str:
185
+ return ",".join(_new_ids_from_field(value)) if isinstance(value, list) else value
186
+
187
+
188
+ def _existing_id_log_label(value: ExistingIdField) -> str:
189
+ if isinstance(value, list):
190
+ return ",".join(str(item) for item in value)
191
+ return str(value)
192
+
193
+
98
194
  class UnifyDecision(BaseModel):
99
195
  """Collapse NEW (+ 0..N EXISTING) into one row with LLM-supplied content.
100
196
 
@@ -112,13 +208,18 @@ class UnifyDecision(BaseModel):
112
208
  """
113
209
 
114
210
  kind: Literal["unify"] = "unify"
115
- new_id: str
211
+ new_id: NewIdField
116
212
  archive_existing_ids: list[int] = Field(default_factory=list)
117
213
  content: str
118
214
  trigger: str
119
215
  rationale: str
120
216
  reason: str = ""
121
217
 
218
+ @field_validator("new_id", mode="before")
219
+ @classmethod
220
+ def _coerce_new_id(cls, value: object) -> NewIdField:
221
+ return _coerce_new_ids(value, allow_many=True)
222
+
122
223
  @field_validator("archive_existing_ids", mode="before")
123
224
  @classmethod
124
225
  def _coerce_archive_ids(cls, value: object) -> object:
@@ -126,7 +227,11 @@ class UnifyDecision(BaseModel):
126
227
  return []
127
228
  if isinstance(value, list):
128
229
  return [_coerce_existing_position(item) for item in value]
129
- return value
230
+ return [_coerce_existing_position(value)]
231
+
232
+ @property
233
+ def new_ids(self) -> list[str]:
234
+ return _new_ids_from_field(self.new_id)
130
235
 
131
236
  model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
132
237
 
@@ -142,14 +247,27 @@ class RejectNewDecision(BaseModel):
142
247
  """
143
248
 
144
249
  kind: Literal["reject_new"] = "reject_new"
145
- new_id: str
146
- superseded_by_existing_id: int
250
+ new_id: NewIdField
251
+ superseded_by_existing_id: ExistingIdField
147
252
  reason: str = ""
148
253
 
254
+ @field_validator("new_id", mode="before")
255
+ @classmethod
256
+ def _coerce_new_id(cls, value: object) -> NewIdField:
257
+ return _coerce_new_ids(value, allow_many=True)
258
+
149
259
  @field_validator("superseded_by_existing_id", mode="before")
150
260
  @classmethod
151
- def _coerce_superseded_id(cls, value: object) -> int:
152
- return _coerce_existing_position(value)
261
+ def _coerce_superseded_id(cls, value: object) -> ExistingIdField:
262
+ return _coerce_existing_positions(value, allow_many=True)
263
+
264
+ @property
265
+ def new_ids(self) -> list[str]:
266
+ return _new_ids_from_field(self.new_id)
267
+
268
+ @property
269
+ def superseded_by_existing_ids(self) -> list[int]:
270
+ return _existing_ids_from_field(self.superseded_by_existing_id)
153
271
 
154
272
  model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
155
273
 
@@ -171,10 +289,18 @@ class DifferentiateDecision(BaseModel):
171
289
  refined_existing_trigger: str
172
290
  reason: str = ""
173
291
 
292
+ @field_validator("new_id", mode="before")
293
+ @classmethod
294
+ def _coerce_new_id(cls, value: object) -> str:
295
+ return _coerce_new_id(value)
296
+
174
297
  @field_validator("existing_id", mode="before")
175
298
  @classmethod
176
299
  def _coerce_existing_id(cls, value: object) -> int:
177
- return _coerce_existing_position(value)
300
+ coerced = _coerce_existing_positions(value, allow_many=False)
301
+ if not isinstance(coerced, int):
302
+ raise ValueError(f"existing_id expected one id, got {coerced!r}")
303
+ return coerced
178
304
 
179
305
  @field_validator("refined_new_trigger", "refined_existing_trigger")
180
306
  @classmethod
@@ -190,9 +316,18 @@ class IndependentDecision(BaseModel):
190
316
  """Unrelated to any existing row: insert new as-is, no archive."""
191
317
 
192
318
  kind: Literal["independent"] = "independent"
193
- new_id: str
319
+ new_id: NewIdField
194
320
  reason: str = ""
195
321
 
322
+ @field_validator("new_id", mode="before")
323
+ @classmethod
324
+ def _coerce_new_id(cls, value: object) -> NewIdField:
325
+ return _coerce_new_ids(value, allow_many=True)
326
+
327
+ @property
328
+ def new_ids(self) -> list[str]:
329
+ return _new_ids_from_field(self.new_id)
330
+
196
331
  model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
197
332
 
198
333
 
@@ -679,8 +814,22 @@ class PlaybookConsolidator(BaseDeduplicator):
679
814
  )
680
815
  except Exception as exc: # noqa: BLE001 — per-decision isolation
681
816
  result_counters.failed_count += 1
682
- new_id_str = getattr(decision, "new_id", "unknown")
683
- existing_id_str = getattr(decision, "existing_id", "unknown")
817
+ raw_new_id = getattr(decision, "new_id", "unknown")
818
+ new_id_str = (
819
+ _new_id_log_label(raw_new_id)
820
+ if isinstance(raw_new_id, (str, list))
821
+ else "unknown"
822
+ )
823
+ raw_existing_id = getattr(
824
+ decision,
825
+ "existing_id",
826
+ getattr(decision, "superseded_by_existing_id", "unknown"),
827
+ )
828
+ existing_id_str = (
829
+ _existing_id_log_label(raw_existing_id)
830
+ if isinstance(raw_existing_id, (int, list))
831
+ else "unknown"
832
+ )
684
833
  with sentry_tags(
685
834
  subsystem="playbook_consolidator",
686
835
  op="apply_decision",
@@ -779,6 +928,7 @@ class PlaybookConsolidator(BaseDeduplicator):
779
928
  if isinstance(decision, RejectNewDecision):
780
929
  return self._apply_reject_new(
781
930
  decision,
931
+ candidates_by_id=candidates_by_id,
782
932
  existing_by_id=existing_by_id,
783
933
  existing_by_position=existing_by_position,
784
934
  )
@@ -834,14 +984,18 @@ class PlaybookConsolidator(BaseDeduplicator):
834
984
  so the merge is materialized by the caller, not here.
835
985
 
836
986
  Raises:
837
- KeyError: If ``decision.new_id`` does not resolve to a known
838
- candidate.
987
+ KeyError: If any id in ``decision.new_ids`` does not resolve to a
988
+ known candidate.
839
989
  ValueError: If an ``archive_existing_ids`` entry has no matching
840
990
  ``EXISTING-{idx}`` row in the position map.
841
991
  """
842
- candidate = candidates_by_id.get(decision.new_id)
843
- if candidate is None:
844
- raise KeyError(f"unify references unknown NEW id: {decision.new_id}")
992
+ new_ids = decision.new_ids
993
+ candidates: list[UserPlaybook] = []
994
+ for new_id in new_ids:
995
+ candidate = candidates_by_id.get(new_id)
996
+ if candidate is None:
997
+ raise KeyError(f"unify references unknown NEW id: {new_id}")
998
+ candidates.append(candidate)
845
999
 
846
1000
  existing_members: list[UserPlaybook] = []
847
1001
  for existing_position in decision.archive_existing_ids:
@@ -870,74 +1024,92 @@ class PlaybookConsolidator(BaseDeduplicator):
870
1024
  # apply logic; the merge still proceeds.
871
1025
  logger.warning(
872
1026
  "event=consolidation_over_budget new_id=%s len=%d budget=%d",
873
- decision.new_id,
1027
+ ",".join(new_ids),
874
1028
  content_len,
875
1029
  budget,
876
1030
  )
877
1031
 
878
- combined_source_ids = self._merge_source_ids([candidate, *existing_members])
1032
+ primary_candidate = candidates[0]
1033
+ combined_source_ids = self._merge_source_ids([*candidates, *existing_members])
879
1034
  unified_row = UserPlaybook(
880
1035
  user_playbook_id=0,
881
- user_id=candidate.user_id,
882
- agent_version=candidate.agent_version,
1036
+ user_id=primary_candidate.user_id,
1037
+ agent_version=primary_candidate.agent_version,
883
1038
  request_id=request_id,
884
- playbook_name=candidate.playbook_name,
1039
+ playbook_name=primary_candidate.playbook_name,
885
1040
  created_at=int(datetime.now(UTC).timestamp()),
886
1041
  content=decision.content,
887
1042
  trigger=decision.trigger,
888
1043
  rationale=decision.rationale,
889
- status=candidate.status,
890
- source=candidate.source,
1044
+ status=primary_candidate.status,
1045
+ source=primary_candidate.source,
891
1046
  source_interaction_ids=combined_source_ids,
892
1047
  )
893
- return [unified_row], [decision.new_id], merge_source_ids
1048
+ return [unified_row], new_ids, merge_source_ids
894
1049
 
895
1050
  def _apply_reject_new(
896
1051
  self,
897
1052
  decision: RejectNewDecision,
898
1053
  *,
1054
+ candidates_by_id: dict[str, UserPlaybook],
899
1055
  existing_by_id: dict[int, UserPlaybook],
900
1056
  existing_by_position: dict[str, UserPlaybook],
901
1057
  ) -> tuple[list[UserPlaybook], list[str], list[int]]:
902
- """No-op apply: the existing row wins and the new candidate is dropped.
1058
+ """No-op apply: the existing row(s) win and the new candidate(s) dropped.
903
1059
 
904
- Resolve the integer against the rendered ``EXISTING-N`` position first,
905
- then as a DB ``user_playbook_id`` for backwards compatibility. If it
906
- does not resolve to a known existing row, the decision is treated as
907
- malformed: we log a warning and return ``([], [])`` so the safety
908
- fallback re-inserts the candidate rather than silently dropping
909
- extracted data.
1060
+ Resolve each superseding integer against the rendered ``EXISTING-N``
1061
+ position first, then as a DB ``user_playbook_id`` for backwards
1062
+ compatibility. If ANY referenced existing row does not resolve, the
1063
+ decision is treated as malformed: we log a warning and return
1064
+ ``([], [], [])`` so the safety fallback re-inserts every named
1065
+ candidate rather than silently dropping extracted data.
910
1066
 
911
1067
  Args:
912
1068
  decision: The ``RejectNewDecision`` to apply.
1069
+ candidates_by_id: Mapping ``"NEW-N"`` -> candidate playbook.
913
1070
  existing_by_id: Mapping ``user_playbook_id`` -> existing playbook,
914
- used as a fallback for ``decision.superseded_by_existing_id``.
1071
+ used as a fallback for ``decision.superseded_by_existing_ids``.
915
1072
  existing_by_position: Mapping ``"EXISTING-M"`` -> existing playbook.
916
1073
 
917
1074
  Returns:
918
- Tuple of ``([], [consumed NEW-N id], [])`` when the existing id
919
- resolves, or ``([], [], [])`` when the existing id is unknown.
920
- Never produces a merge group — the existing row is kept as-is (no
921
- archive, no survivor).
1075
+ Tuple of ``([], [consumed NEW-N ids], [])`` when every superseding
1076
+ id resolves, or ``([], [], [])`` when any is unknown. Never produces
1077
+ a merge group — the existing rows are kept as-is (no archive, no
1078
+ survivor).
1079
+
1080
+ Raises:
1081
+ KeyError: If any id in ``decision.new_ids`` does not resolve to a
1082
+ known candidate.
922
1083
  """
923
- existing = self._resolve_existing_reference(
924
- decision.superseded_by_existing_id,
925
- existing_by_position=existing_by_position,
926
- existing_by_id=existing_by_id,
927
- )
928
- if existing is None:
1084
+ new_ids = decision.new_ids
1085
+ missing_new_ids = [
1086
+ new_id for new_id in new_ids if new_id not in candidates_by_id
1087
+ ]
1088
+ if missing_new_ids:
1089
+ raise KeyError(f"reject_new references unknown NEW ids: {missing_new_ids}")
1090
+
1091
+ existing_ids = decision.superseded_by_existing_ids
1092
+ existing_members = [
1093
+ self._resolve_existing_reference(
1094
+ existing_id,
1095
+ existing_by_position=existing_by_position,
1096
+ existing_by_id=existing_by_id,
1097
+ )
1098
+ for existing_id in existing_ids
1099
+ ]
1100
+ if any(existing is None for existing in existing_members):
929
1101
  logger.warning(
930
- "event=consolidation_reject_new_invalid new_id=%s existing_id=%d",
931
- decision.new_id,
932
- decision.superseded_by_existing_id,
1102
+ "event=consolidation_reject_new_invalid new_id=%s existing_id=%s",
1103
+ ",".join(new_ids),
1104
+ existing_ids,
933
1105
  )
934
1106
  return [], [], []
935
1107
  logger.info(
936
- "event=consolidation_reject_new new_id=%s existing_id=%d",
937
- decision.new_id,
938
- decision.superseded_by_existing_id,
1108
+ "event=consolidation_reject_new new_id=%s existing_id=%s",
1109
+ ",".join(new_ids),
1110
+ existing_ids,
939
1111
  )
940
- return [], [decision.new_id], []
1112
+ return [], new_ids, []
941
1113
 
942
1114
  def _apply_differentiate(
943
1115
  self,
@@ -1028,10 +1200,13 @@ class PlaybookConsolidator(BaseDeduplicator):
1028
1200
  Tuple of ``([candidate row], [consumed NEW-N id], [])`` — no archive,
1029
1201
  so never a merge group.
1030
1202
  """
1031
- candidate = candidates_by_id.get(decision.new_id)
1032
- if candidate is None:
1033
- raise KeyError(f"independent references unknown NEW id: {decision.new_id}")
1034
- return [candidate], [decision.new_id], []
1203
+ rows: list[UserPlaybook] = []
1204
+ for new_id in decision.new_ids:
1205
+ candidate = candidates_by_id.get(new_id)
1206
+ if candidate is None:
1207
+ raise KeyError(f"independent references unknown NEW id: {new_id}")
1208
+ rows.append(candidate)
1209
+ return rows, decision.new_ids, []
1035
1210
 
1036
1211
  @staticmethod
1037
1212
  def _merge_source_ids(playbooks: list[UserPlaybook]) -> list[int]:
@@ -1104,8 +1279,14 @@ class PlaybookConsolidator(BaseDeduplicator):
1104
1279
  existing_by_id: Mapping ``user_playbook_id`` -> existing playbook.
1105
1280
  """
1106
1281
  kind = decision.kind
1107
- new_id: str = getattr(decision, "new_id", "")
1108
- new_pb = candidates_by_id.get(new_id)
1282
+ raw_new_id = getattr(decision, "new_id", "")
1283
+ new_ids = (
1284
+ _new_ids_from_field(raw_new_id)
1285
+ if isinstance(raw_new_id, (str, list))
1286
+ else []
1287
+ )
1288
+ new_id_label = ",".join(new_ids)
1289
+ new_pb = candidates_by_id.get(new_ids[0]) if new_ids else None
1109
1290
 
1110
1291
  # UnifyDecision archives by position (EXISTING-{idx}) rather than a
1111
1292
  # single existing_id; log a synthetic "multi" so the probe parser sees
@@ -1118,34 +1299,42 @@ class PlaybookConsolidator(BaseDeduplicator):
1118
1299
  "playbook_consolidation.decision kind=%s new_id=%s existing_id=%s "
1119
1300
  "trigger_match=%s",
1120
1301
  kind,
1121
- new_id,
1302
+ new_id_label,
1122
1303
  existing_id_label,
1123
1304
  "unknown",
1124
1305
  )
1125
1306
  return
1126
1307
 
1127
- # RejectNewDecision exposes ``superseded_by_existing_id``; the other
1128
- # two surviving kinds expose ``existing_id`` directly.
1129
- existing_id_raw: int = getattr(
1130
- decision,
1131
- "existing_id",
1132
- getattr(decision, "superseded_by_existing_id", 0),
1133
- )
1134
- existing_pb = PlaybookConsolidator._resolve_existing_reference(
1135
- existing_id_raw,
1136
- existing_by_position=existing_by_position,
1137
- existing_by_id=existing_by_id,
1308
+ existing_ids: list[int] = []
1309
+ if isinstance(decision, RejectNewDecision):
1310
+ existing_ids = decision.superseded_by_existing_ids
1311
+ elif isinstance(decision, DifferentiateDecision):
1312
+ existing_ids = [decision.existing_id]
1313
+
1314
+ existing_pb = (
1315
+ PlaybookConsolidator._resolve_existing_reference(
1316
+ existing_ids[0],
1317
+ existing_by_position=existing_by_position,
1318
+ existing_by_id=existing_by_id,
1319
+ )
1320
+ if existing_ids
1321
+ else None
1138
1322
  )
1139
1323
  trigger_match = (
1140
1324
  new_pb is not None
1141
1325
  and existing_pb is not None
1142
1326
  and new_pb.trigger == existing_pb.trigger
1143
1327
  )
1328
+ existing_id_label = (
1329
+ ",".join(str(existing_id) for existing_id in existing_ids)
1330
+ if existing_ids
1331
+ else "none"
1332
+ )
1144
1333
  logger.info(
1145
1334
  "playbook_consolidation.decision kind=%s new_id=%s existing_id=%s "
1146
1335
  "trigger_match=%s",
1147
1336
  kind,
1148
- new_id,
1149
- existing_id_raw,
1337
+ new_id_label,
1338
+ existing_id_label,
1150
1339
  str(trigger_match).lower(),
1151
1340
  )
@@ -22,11 +22,15 @@ from reflexio.models.api_schema.service_schemas import (
22
22
  UserPlaybook,
23
23
  )
24
24
  from reflexio.models.config_schema import PlaybookConfig
25
+ from reflexio.server.extensions import get_service
25
26
  from reflexio.server.operation_limiter import run_with_operation_limit
26
27
  from reflexio.server.services.base_generation_service import (
27
28
  BaseGenerationService,
28
29
  StatusChangeOperation,
29
30
  )
31
+ from reflexio.server.services.playbook.aggregation_prompt_processing import (
32
+ AGGREGATION_PROMPT_PROCESSOR,
33
+ )
30
34
  from reflexio.server.services.playbook.components.aggregator import PlaybookAggregator
31
35
  from reflexio.server.services.playbook.components.extractor import PlaybookExtractor
32
36
  from reflexio.server.services.playbook.playbook_service_constants import (
@@ -537,16 +541,12 @@ class PlaybookGenerationService(
537
541
  )
538
542
 
539
543
  # Initialize and run aggregator (synchronous)
540
- from reflexio.server.services.playbook.user_detail_stripping import (
541
- create_aggregation_user_detail_stripper,
542
- )
543
-
544
- user_detail_stripper = create_aggregation_user_detail_stripper(
545
- self.request_context.configurator
546
- )
544
+ aggregation_prompt_processor = get_service(AGGREGATION_PROMPT_PROCESSOR)
547
545
  aggregator_kwargs = {}
548
- if user_detail_stripper is not None:
549
- aggregator_kwargs["user_detail_stripper"] = user_detail_stripper
546
+ if aggregation_prompt_processor is not None:
547
+ aggregator_kwargs["aggregation_prompt_processor"] = (
548
+ aggregation_prompt_processor
549
+ )
550
550
  aggregator = PlaybookAggregator(
551
551
  llm_client=self.client,
552
552
  request_context=self.request_context,
@@ -279,7 +279,7 @@ class ProfileExtractor:
279
279
  raw_profiles: list[dict],
280
280
  user_id: str,
281
281
  request_id: str,
282
- source_interaction_ids: list[int],
282
+ source_interaction_ids: list[int] | None = None,
283
283
  ) -> list[UserProfile]:
284
284
  """
285
285
  Convert raw profile dicts from LLM to UserProfile objects.
@@ -294,6 +294,7 @@ class ProfileExtractor:
294
294
  List of UserProfile objects
295
295
  """
296
296
  new_profiles = []
297
+ profile_source_interaction_ids = list(source_interaction_ids or [])
297
298
  for profile_content in raw_profiles:
298
299
  if (
299
300
  not isinstance(profile_content, dict)
@@ -322,7 +323,7 @@ class ProfileExtractor:
322
323
  expiration_timestamp=calculate_expiration_timestamp(now_ts, ttl),
323
324
  custom_features=custom_features or None,
324
325
  extractor_names=None,
325
- source_interaction_ids=source_interaction_ids,
326
+ source_interaction_ids=profile_source_interaction_ids,
326
327
  )
327
328
 
328
329
  new_profiles.append(added_profile)