claude-smart 0.2.46 → 0.2.47

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 (85) 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/opencode/dist/server.mjs +76 -2
  10. package/plugin/opencode/server.mts +79 -2
  11. package/plugin/pyproject.toml +6 -2
  12. package/plugin/scripts/smart-install.sh +7 -1
  13. package/plugin/src/claude_smart/cli.py +210 -22
  14. package/plugin/src/claude_smart/context_format.py +9 -9
  15. package/plugin/src/claude_smart/cs_cite.py +66 -19
  16. package/plugin/uv.lock +5 -5
  17. package/plugin/vendor/reflexio/README.md +3 -3
  18. package/plugin/vendor/reflexio/pyproject.toml +1 -1
  19. package/plugin/vendor/reflexio/reflexio/README.md +3 -1
  20. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  21. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  23. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  24. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  25. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  26. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  27. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  28. package/plugin/vendor/reflexio/reflexio/lib/_search.py +23 -5
  29. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  30. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  33. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +16 -0
  34. package/plugin/vendor/reflexio/reflexio/server/README.md +14 -2
  35. package/plugin/vendor/reflexio/reflexio/server/api.py +176 -29
  36. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  37. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  38. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  39. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  40. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1 -1
  41. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  43. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -1
  44. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  45. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  46. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +45 -19
  49. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  51. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +129 -111
  52. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  53. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  54. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  56. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +14 -2
  59. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +2 -0
  60. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  61. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +1965 -0
  62. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +5 -3
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +262 -107
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  66. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +952 -0
  68. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +838 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +19 -3
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +148 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +13 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -0,0 +1,333 @@
1
+ """Abstract user playbook CRUD + search declarations."""
2
+
3
+ from abc import abstractmethod
4
+
5
+ from reflexio.models.api_schema.common import BlockingIssue
6
+ from reflexio.models.api_schema.domain import Status, UserPlaybook
7
+ from reflexio.models.api_schema.retriever_schema import SearchUserPlaybookRequest
8
+ from reflexio.models.config_schema import SearchOptions
9
+
10
+
11
+ class UserPlaybookStoreMixin:
12
+ """Abstract user playbook CRUD + search methods."""
13
+
14
+ @abstractmethod
15
+ def save_user_playbooks(self, user_playbooks: list[UserPlaybook]) -> None:
16
+ raise NotImplementedError
17
+
18
+ @abstractmethod
19
+ def get_user_playbooks(
20
+ self,
21
+ limit: int = 100,
22
+ user_id: str | None = None,
23
+ playbook_name: str | None = None,
24
+ agent_version: str | None = None,
25
+ status_filter: list[Status | None] | None = None,
26
+ start_time: int | None = None,
27
+ end_time: int | None = None,
28
+ include_embedding: bool = False,
29
+ tags: list[str] | None = None,
30
+ offset: int = 0,
31
+ user_playbook_id: int | None = None,
32
+ request_id: str | None = None,
33
+ query: str | None = None,
34
+ ) -> list[UserPlaybook]:
35
+ """Get user playbooks from storage.
36
+
37
+ Args:
38
+ limit (int): Maximum number of playbooks to return
39
+ user_playbook_id (int, optional): Exact user playbook ID to retrieve.
40
+ user_id (str, optional): The user ID to filter by. If None, returns playbooks for all users.
41
+ request_id (str, optional): Request ID that generated the playbook.
42
+ query (str, optional): Case-insensitive text filter across visible fields.
43
+ playbook_name (str, optional): The playbook name to filter by. If None, returns all user playbooks.
44
+ agent_version (str, optional): The agent version to filter by. If None, returns all agent versions.
45
+ status_filter (list[Optional[Status]], optional): List of status values to filter by.
46
+ Can include None (current), Status.PENDING (from rerun), Status.ARCHIVED (old).
47
+ If None, returns playbooks with all statuses.
48
+ start_time (int, optional): Unix timestamp. Only return playbooks created at or after this time.
49
+ end_time (int, optional): Unix timestamp. Only return playbooks created at or before this time.
50
+ include_embedding (bool): If True, fetch and parse embedding vectors. Defaults to False.
51
+ tags (list[str], optional): Match playbooks having any of these tags.
52
+ offset (int): Number of matching rows to skip. Defaults to 0.
53
+
54
+ Returns:
55
+ list[UserPlaybook]: List of user playbook objects
56
+ """
57
+ raise NotImplementedError
58
+
59
+ @abstractmethod
60
+ def count_user_playbooks(
61
+ self,
62
+ user_id: str | None = None,
63
+ playbook_name: str | None = None,
64
+ min_user_playbook_id: int | None = None,
65
+ agent_version: str | None = None,
66
+ status_filter: list[Status | None] | None = None,
67
+ ) -> int:
68
+ """Count user playbooks in storage efficiently.
69
+
70
+ Args:
71
+ user_id (str, optional): The user ID to filter by. If None, counts playbooks for all users.
72
+ playbook_name (str, optional): The playbook name to filter by. If None, counts all user playbooks.
73
+ min_user_playbook_id (int, optional): Only count playbooks with user_playbook_id greater than this value.
74
+ agent_version (str, optional): The agent version to filter by. If None, counts all agent versions.
75
+ status_filter (list[Optional[Status]], optional): List of status values to filter by.
76
+ Can include None (current), Status.PENDING (from rerun), Status.ARCHIVED (old).
77
+ If None, returns playbooks with all statuses.
78
+
79
+ Returns:
80
+ int: Count of user playbooks matching the filters
81
+ """
82
+ raise NotImplementedError
83
+
84
+ @abstractmethod
85
+ def count_user_playbooks_by_session(self, session_id: str) -> int:
86
+ """Count user playbooks linked to a session via request_id -> requests.session_id.
87
+
88
+ Args:
89
+ session_id (str): The session ID to count user playbooks for
90
+
91
+ Returns:
92
+ int: Count of user playbooks linked to the session
93
+ """
94
+ raise NotImplementedError
95
+
96
+ @abstractmethod
97
+ def delete_all_user_playbooks(self) -> None:
98
+ """Delete all user playbooks from storage."""
99
+ raise NotImplementedError
100
+
101
+ @abstractmethod
102
+ def delete_all_user_playbooks_by_playbook_name(
103
+ self, playbook_name: str, agent_version: str | None = None
104
+ ) -> None:
105
+ """Delete all user playbooks by playbook name from storage.
106
+
107
+ Args:
108
+ playbook_name (str): The playbook name to delete
109
+ agent_version (str, optional): The agent version to filter by. If None, deletes all agent versions.
110
+ """
111
+ raise NotImplementedError
112
+
113
+ @abstractmethod
114
+ def delete_user_playbook(self, user_playbook_id: int) -> None:
115
+ """Delete a user playbook by ID.
116
+
117
+ Args:
118
+ user_playbook_id (int): The ID of the user playbook to delete
119
+ """
120
+ raise NotImplementedError
121
+
122
+ @abstractmethod
123
+ def update_all_user_playbooks_status(
124
+ self,
125
+ old_status: Status | None,
126
+ new_status: Status | None,
127
+ agent_version: str | None = None,
128
+ playbook_name: str | None = None,
129
+ ) -> int:
130
+ """Update all user playbooks with old_status to new_status atomically.
131
+
132
+ Args:
133
+ old_status: The current status to match (None for CURRENT)
134
+ new_status: The new status to set (None for CURRENT)
135
+ agent_version: Optional filter by agent version
136
+ playbook_name: Optional filter by playbook name
137
+
138
+ Returns:
139
+ int: Number of user playbooks updated
140
+ """
141
+ raise NotImplementedError
142
+
143
+ @abstractmethod
144
+ def delete_all_user_playbooks_by_status(
145
+ self,
146
+ status: Status,
147
+ agent_version: str | None = None,
148
+ playbook_name: str | None = None,
149
+ ) -> int:
150
+ """Delete all user playbooks with the given status atomically.
151
+
152
+ Args:
153
+ status: The status of user playbooks to delete
154
+ agent_version: Optional filter by agent version
155
+ playbook_name: Optional filter by playbook name
156
+
157
+ Returns:
158
+ int: Number of user playbooks deleted
159
+ """
160
+ raise NotImplementedError
161
+
162
+ @abstractmethod
163
+ def delete_user_playbooks_by_ids(
164
+ self, user_playbook_ids: list[int], *, emit_hard_delete: bool = True
165
+ ) -> int:
166
+ """Delete user playbooks by their IDs.
167
+
168
+ Args:
169
+ user_playbook_ids: List of user_playbook_id values to delete
170
+ emit_hard_delete: When True (default), append a ``hard_delete``
171
+ lineage event per id (genuine erasure). Set False for rollback
172
+ cleanup of a never-live row (e.g. a lost supersede CAS), so no
173
+ spurious audit event is recorded.
174
+
175
+ Returns:
176
+ int: Number of user playbooks deleted
177
+ """
178
+ raise NotImplementedError
179
+
180
+ @abstractmethod
181
+ def get_user_playbooks_by_ids(
182
+ self,
183
+ user_id: str,
184
+ user_playbook_ids: list[int],
185
+ status_filter: list[Status | None] | None = None,
186
+ ) -> list[UserPlaybook]:
187
+ """Fetch the subset of a user's playbooks whose ids are in the list.
188
+
189
+ Server-side filter on (``user_id``, ``user_playbook_id IN (...)``)
190
+ so callers (e.g. the reflection service resolving a small set of
191
+ cited playbook ids) avoid scanning every playbook for the user.
192
+
193
+ Args:
194
+ user_id (str): Owning user id.
195
+ user_playbook_ids (list[int]): Playbook ids to fetch. Empty
196
+ list returns ``[]`` without hitting storage.
197
+ status_filter (list[Status | None] | None): Statuses to
198
+ include. ``None`` (default) means CURRENT only — same
199
+ default as ``get_user_playbooks`` for consistency.
200
+
201
+ Returns:
202
+ list[UserPlaybook]: Matching playbooks. Order is unspecified.
203
+ Ids that do not exist (or do not match the user / status
204
+ filter) are silently omitted.
205
+ """
206
+ raise NotImplementedError
207
+
208
+ @abstractmethod
209
+ def get_user_playbook_by_id(
210
+ self, user_playbook_id: int, *, include_tombstones: bool = False
211
+ ) -> UserPlaybook | None:
212
+ """Fetch one user playbook by primary key.
213
+
214
+ Args:
215
+ user_playbook_id: The user_playbook_id to look up.
216
+ include_tombstones: When False (default), MERGED/SUPERSEDED rows
217
+ return None. Set to True for lineage resolution (resolve_current).
218
+
219
+ Returns:
220
+ The UserPlaybook if found and not filtered, otherwise None.
221
+ """
222
+ raise NotImplementedError
223
+
224
+ @abstractmethod
225
+ def get_user_playbooks_by_ids_any_user(
226
+ self,
227
+ user_playbook_ids: list[int],
228
+ status_filter: list[Status | None] | None = None,
229
+ ) -> list[UserPlaybook]:
230
+ """Fetch user playbooks by ids without requiring a single owner id."""
231
+ raise NotImplementedError
232
+
233
+ @abstractmethod
234
+ def archive_user_playbook_by_id(self, user_id: str, user_playbook_id: int) -> bool:
235
+ """Atomically archive a single user playbook by id, only if CURRENT.
236
+
237
+ Flips the row's ``status`` from ``None`` (CURRENT) to
238
+ ``Status.ARCHIVED``. No-op when the playbook does not exist, has
239
+ a different ``user_id``, or is already non-current.
240
+
241
+ Args:
242
+ user_id (str): Owning user id; used as a guard so callers
243
+ cannot accidentally archive another user's playbook.
244
+ user_playbook_id (int): The user_playbook_id to archive.
245
+
246
+ Returns:
247
+ bool: True if a row was archived; False otherwise.
248
+ """
249
+ raise NotImplementedError
250
+
251
+ @abstractmethod
252
+ def has_user_playbooks_with_status(
253
+ self,
254
+ status: Status | None,
255
+ agent_version: str | None = None,
256
+ playbook_name: str | None = None,
257
+ ) -> bool:
258
+ """Check if any user playbooks exist with given status and filters.
259
+
260
+ Args:
261
+ status: The status to check for (None for CURRENT)
262
+ agent_version: Optional filter by agent version
263
+ playbook_name: Optional filter by playbook name
264
+
265
+ Returns:
266
+ bool: True if any matching user playbooks exist
267
+ """
268
+ raise NotImplementedError
269
+
270
+ @abstractmethod
271
+ def update_user_playbook(
272
+ self,
273
+ user_playbook_id: int,
274
+ playbook_name: str | None = None,
275
+ content: str | None = None,
276
+ trigger: str | None = None,
277
+ rationale: str | None = None,
278
+ blocking_issue: BlockingIssue | None = None,
279
+ tags: list[str] | None = None,
280
+ ) -> None:
281
+ """Update editable fields of a user playbook. Only non-None fields are updated.
282
+
283
+ Args:
284
+ user_playbook_id (int): The ID of the user playbook to update
285
+ playbook_name (str, optional): New playbook name
286
+ content (str, optional): New content text
287
+ trigger (str, optional): New trigger text
288
+ rationale (str, optional): New rationale text
289
+ blocking_issue (BlockingIssue, optional): New blocking issue
290
+ tags (list[str], optional): Replacement tags
291
+
292
+ Raises:
293
+ ValueError: If user playbook with the given ID is not found
294
+ """
295
+ raise NotImplementedError
296
+
297
+ @abstractmethod
298
+ def supersede_user_playbooks_by_ids(
299
+ self, user_playbook_ids: list[int], request_id: str
300
+ ) -> int:
301
+ """Soft-delete user playbooks by setting status to SUPERSEDED.
302
+
303
+ Eligible rows (CURRENT, PENDING, or ARCHIVED; not already MERGED /
304
+ SUPERSEDED) are transitioned to SUPERSEDED and emit one status_change
305
+ lineage event under the shared request id. This is the user-playbook
306
+ analogue of the existing agent/profile soft-supersede helpers and
307
+ preserves dead-source content for point-in-time attribution reads.
308
+
309
+ Args:
310
+ user_playbook_ids (list[int]): User playbook ids to supersede.
311
+ request_id (str): Shared request id for all emitted lineage events.
312
+
313
+ Returns:
314
+ int: Number of user playbooks actually updated.
315
+ """
316
+ raise NotImplementedError
317
+
318
+ @abstractmethod
319
+ def search_user_playbooks(
320
+ self,
321
+ request: SearchUserPlaybookRequest,
322
+ options: SearchOptions | None = None,
323
+ ) -> list[UserPlaybook]:
324
+ """Search user playbooks with advanced filtering including semantic search.
325
+
326
+ Args:
327
+ request (SearchUserPlaybookRequest): Search request with query, filters, and pagination
328
+ options (SearchOptions, optional): Engine-level search parameters (e.g. pre-computed embedding)
329
+
330
+ Returns:
331
+ list[UserPlaybook]: List of matching user playbook objects
332
+ """
333
+ raise NotImplementedError
@@ -17,6 +17,7 @@ from collections import OrderedDict
17
17
  from collections.abc import Callable
18
18
  from concurrent.futures import Future, ThreadPoolExecutor
19
19
  from concurrent.futures import TimeoutError as FuturesTimeoutError
20
+ from datetime import UTC, datetime
20
21
  from typing import TYPE_CHECKING, Any, cast
21
22
 
22
23
  from reflexio.models.api_schema.retriever_schema import (
@@ -41,6 +42,13 @@ from reflexio.models.config_schema import (
41
42
  from reflexio.server.llm.litellm_client import LiteLLMClient
42
43
  from reflexio.server.prompt.prompt_manager import PromptManager
43
44
  from reflexio.server.services.pre_retrieval import QueryReformulator
45
+ from reflexio.server.services.retrieval.recency import (
46
+ RecencyConfig,
47
+ ScoredItem,
48
+ additive_penalty,
49
+ decay_for_item,
50
+ multiplicative_factor,
51
+ )
44
52
  from reflexio.server.services.retrieval.relevance_floor import apply_relevance_floors
45
53
  from reflexio.server.services.storage.storage_base import BaseStorage
46
54
  from reflexio.server.tracing import profile_step, set_span_data
@@ -102,6 +110,7 @@ def run_unified_search(
102
110
  prompt_manager: PromptManager,
103
111
  pre_retrieval_model_name: str | None = None,
104
112
  retrieval_floor: RetrievalFloorConfig | None = None,
113
+ recency: RecencyConfig | None = None,
105
114
  ) -> UnifiedSearchResponse:
106
115
  """
107
116
  Search across all entity types (profiles, agent playbooks, user playbooks) in parallel.
@@ -129,7 +138,12 @@ def run_unified_search(
129
138
 
130
139
  floor_cfg = retrieval_floor or RetrievalFloorConfig()
131
140
  floor_on = floor_cfg.enabled
132
- fetch_k = max(top_k, floor_cfg.pool_size) if floor_on else top_k
141
+ recency_on = bool(recency and recency.enabled)
142
+ fetch_k = max(
143
+ top_k,
144
+ floor_cfg.pool_size if floor_on else 0,
145
+ recency.pool_size if recency_on and recency is not None else 0,
146
+ )
133
147
 
134
148
  # --- Phase A: query reformulation + embedding generation ---
135
149
  reformulated_query, embedding = _run_phase_a(
@@ -153,6 +167,7 @@ def run_unified_search(
153
167
  query=reformulated_query,
154
168
  top_k=fetch_k,
155
169
  threshold=threshold,
170
+ recency_on=recency_on,
156
171
  )
157
172
 
158
173
  if profiles is None:
@@ -166,7 +181,28 @@ def run_unified_search(
166
181
  user_playbooks=user_playbooks, # type: ignore[arg-type]
167
182
  top_k=top_k,
168
183
  cfg=floor_cfg,
184
+ recency=recency if recency_on else None,
185
+ )
186
+ elif recency_on and recency is not None:
187
+ profiles = _apply_combined_score_recency(
188
+ profiles or [], entity_type="profiles", top_k=top_k, cfg=recency
189
+ )
190
+ agent_playbooks = _apply_combined_score_recency(
191
+ agent_playbooks or [],
192
+ entity_type="agent_playbooks",
193
+ top_k=top_k,
194
+ cfg=recency,
195
+ )
196
+ user_playbooks = _apply_combined_score_recency(
197
+ user_playbooks or [],
198
+ entity_type="user_playbooks",
199
+ top_k=top_k,
200
+ cfg=recency,
169
201
  )
202
+ else:
203
+ profiles = _unwrap_items(profiles or [])[:top_k]
204
+ agent_playbooks = _unwrap_items(agent_playbooks or [])[:top_k]
205
+ user_playbooks = _unwrap_items(user_playbooks or [])[:top_k]
170
206
 
171
207
  user_playbooks = _suppress_source_user_playbooks(
172
208
  storage=storage,
@@ -284,10 +320,11 @@ def _run_phase_b(
284
320
  query: str,
285
321
  top_k: int,
286
322
  threshold: float,
323
+ recency_on: bool = False,
287
324
  ) -> tuple[
288
- list[UserProfile] | None,
289
- list[AgentPlaybook] | None,
290
- list[UserPlaybook] | None,
325
+ list[Any] | None,
326
+ list[Any] | None,
327
+ list[Any] | None,
291
328
  ]:
292
329
  """Run parallel searches across all entity types by delegating to storage methods.
293
330
 
@@ -314,9 +351,18 @@ def _run_phase_b(
314
351
  entity_types=sorted(entity_types),
315
352
  top_k=top_k,
316
353
  ) as span:
317
- if (
354
+ # Recency needs the per-row ``combined_score``, which only the scored
355
+ # single-RPC method threads back. Backends that don't advertise
356
+ # ``supports_unified_hybrid_search`` (e.g. native Postgres, which still
357
+ # inherits ``unified_hybrid_search_scored`` and runs it via the same
358
+ # ``_rpc`` it already uses for ``hybrid_match_*``) opt into the scored
359
+ # path only when recency is on, so non-recency routing is unchanged.
360
+ wants_scored_single_rpc = recency_on and callable(
361
+ getattr(storage, "unified_hybrid_search_scored", None)
362
+ )
363
+ if _unified_single_rpc_enabled() and (
318
364
  getattr(storage, "supports_unified_hybrid_search", False)
319
- and _unified_single_rpc_enabled()
365
+ or wants_scored_single_rpc
320
366
  ):
321
367
  combined = _run_phase_b_single_rpc(
322
368
  request=request,
@@ -327,6 +373,7 @@ def _run_phase_b(
327
373
  threshold=threshold,
328
374
  entity_types=entity_types,
329
375
  allowed_agent_statuses=allowed_agent_statuses,
376
+ recency_on=recency_on,
330
377
  )
331
378
  if combined is not None:
332
379
  profiles, agent_playbooks, user_playbooks = combined
@@ -437,7 +484,8 @@ def _run_phase_b_single_rpc(
437
484
  threshold: float,
438
485
  entity_types: set[str],
439
486
  allowed_agent_statuses: list[PlaybookStatus] | None,
440
- ) -> tuple[list[UserProfile], list[AgentPlaybook], list[UserPlaybook]] | None:
487
+ recency_on: bool = False,
488
+ ) -> tuple[list[Any], list[Any], list[Any]] | None:
441
489
  """Run all Phase B arms through one combined storage round trip.
442
490
 
443
491
  Trades the per-arm round-trip overhead for serialized execution of the
@@ -458,8 +506,16 @@ def _run_phase_b_single_rpc(
458
506
  )
459
507
  # Resolve storage.unified_hybrid_search before submit so missing or stale
460
508
  # capability flags can fall back to the fan-out path.
461
- unified_hybrid_search = getattr(storage, "unified_hybrid_search", None)
509
+ method_name = (
510
+ "unified_hybrid_search_scored" if recency_on else "unified_hybrid_search"
511
+ )
512
+ unified_hybrid_search = getattr(storage, method_name, None)
462
513
  if not callable(unified_hybrid_search):
514
+ if recency_on:
515
+ logger.warning(
516
+ "event=search_recency_missing_scores source=single_rpc method=%s",
517
+ method_name,
518
+ )
463
519
  return None
464
520
 
465
521
  future = _submit_with_current_context(
@@ -490,13 +546,14 @@ def _run_phase_b_single_rpc(
490
546
  return None
491
547
 
492
548
  # Mirror _search_agent_playbooks_via_storage: dedupe by id, cap at top_k.
493
- deduped: list[AgentPlaybook] = []
549
+ deduped: list[Any] = []
494
550
  seen_ids: set[str] = set()
495
- for playbook in agent_playbooks:
551
+ for candidate in agent_playbooks:
552
+ playbook = _unwrap_item(candidate)
496
553
  playbook_id = str(getattr(playbook, "agent_playbook_id", ""))
497
554
  if playbook_id and playbook_id not in seen_ids:
498
555
  seen_ids.add(playbook_id)
499
- deduped.append(playbook)
556
+ deduped.append(candidate)
500
557
  if len(deduped) >= top_k:
501
558
  break
502
559
  return profiles, deduped, user_playbooks
@@ -509,6 +566,7 @@ def _apply_floors(
509
566
  user_playbooks: list[UserPlaybook],
510
567
  top_k: int,
511
568
  cfg: RetrievalFloorConfig,
569
+ recency: RecencyConfig | None = None,
512
570
  ) -> tuple[list[UserProfile], list[AgentPlaybook], list[UserPlaybook]]:
513
571
  """Apply the per-arm relevance floor with one batched cross-encoder call."""
514
572
  floored_profiles, floored_agent, floored_user = apply_relevance_floors(
@@ -519,8 +577,91 @@ def _apply_floors(
519
577
  ("user_playbooks", user_playbooks, cfg.user_playbook_floor),
520
578
  ],
521
579
  top_k,
580
+ content_of=lambda item: _unwrap_item(item).content,
522
581
  )
523
- return floored_profiles, floored_agent, floored_user
582
+ return (
583
+ _finalize_floor_arm(
584
+ floored_profiles, entity_type="profiles", top_k=top_k, recency=recency
585
+ ),
586
+ _finalize_floor_arm(
587
+ floored_agent,
588
+ entity_type="agent_playbooks",
589
+ top_k=top_k,
590
+ recency=recency,
591
+ ),
592
+ _finalize_floor_arm(
593
+ floored_user,
594
+ entity_type="user_playbooks",
595
+ top_k=top_k,
596
+ recency=recency,
597
+ ),
598
+ )
599
+
600
+
601
+ def _finalize_floor_arm(
602
+ result: Any,
603
+ *,
604
+ entity_type: str,
605
+ top_k: int,
606
+ recency: RecencyConfig | None,
607
+ ) -> list[Any]:
608
+ if not recency or not recency.enabled:
609
+ return _unwrap_items(result.items)[:top_k]
610
+ if result.scores is None:
611
+ return _apply_combined_score_recency(
612
+ result.items, entity_type=entity_type, top_k=top_k, cfg=recency
613
+ )
614
+ now = int(datetime.now(UTC).timestamp())
615
+ rescored = []
616
+ for item, score in zip(result.items, result.scores, strict=True):
617
+ unwrapped = _unwrap_item(item)
618
+ freshness = decay_for_item(unwrapped, entity_type=entity_type, now=now)
619
+ rescored.append(
620
+ (unwrapped, score - additive_penalty(freshness, recency.max_penalty_logit))
621
+ )
622
+ rescored.sort(key=lambda pair: pair[1], reverse=True)
623
+ return [item for item, _score in rescored[:top_k]]
624
+
625
+
626
+ def _apply_combined_score_recency(
627
+ items: list[Any],
628
+ *,
629
+ entity_type: str,
630
+ top_k: int,
631
+ cfg: RecencyConfig,
632
+ ) -> list[Any]:
633
+ if not items:
634
+ return []
635
+ scored_items: list[tuple[Any, float]] = []
636
+ for item in items:
637
+ if not isinstance(item, ScoredItem) or item.score is None:
638
+ logger.warning(
639
+ "event=search_recency_missing_scores entity_type=%s items=%d",
640
+ entity_type,
641
+ len(items),
642
+ )
643
+ return _unwrap_items(items)[:top_k]
644
+ scored_items.append((item.item, item.score))
645
+ now = int(datetime.now(UTC).timestamp())
646
+ rescored = []
647
+ for item, score in scored_items:
648
+ freshness = decay_for_item(item, entity_type=entity_type, now=now)
649
+ rescored.append(
650
+ (
651
+ item,
652
+ score * multiplicative_factor(freshness, cfg.max_penalty_frac),
653
+ )
654
+ )
655
+ rescored.sort(key=lambda pair: pair[1], reverse=True)
656
+ return [item for item, _score in rescored[:top_k]]
657
+
658
+
659
+ def _unwrap_item(item: Any) -> Any:
660
+ return item.item if isinstance(item, ScoredItem) else item
661
+
662
+
663
+ def _unwrap_items(items: list[Any]) -> list[Any]:
664
+ return [_unwrap_item(item) for item in items]
524
665
 
525
666
 
526
667
  def _suppress_source_user_playbooks(
@@ -1,84 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Callable
4
- from dataclasses import dataclass, field
5
- from typing import Protocol
6
-
7
-
8
- @dataclass(frozen=True)
9
- class DetectedEntity:
10
- start: int
11
- end: int
12
- entity_type: str
13
- replacement: str
14
- confidence: float
15
- source: str
16
-
17
-
18
- @dataclass(frozen=True)
19
- class StrippingResult:
20
- text: str
21
- detections: list[DetectedEntity] = field(default_factory=list)
22
-
23
-
24
- class UserDetailDetector(Protocol):
25
- def detect(self, text: str) -> list[DetectedEntity]: ...
26
-
27
-
28
- class UserDetailStripper(Protocol):
29
- prompt_extra_instructions: str | None
30
-
31
- def strip_user_details(
32
- self,
33
- text: str,
34
- shared_mapping: dict[str, int] | None = None,
35
- ) -> StrippingResult: ...
36
-
37
- def sanitize_aggregation_output_text(
38
- self,
39
- text: str | None,
40
- ) -> tuple[str | None, int]: ...
41
-
42
-
43
- class PassthroughStripper:
44
- prompt_extra_instructions: str | None = None
45
-
46
- def strip_user_details(
47
- self,
48
- text: str,
49
- shared_mapping: dict[str, int] | None = None, # noqa: ARG002
50
- ) -> StrippingResult:
51
- return StrippingResult(text=text, detections=[])
52
-
53
- def sanitize_aggregation_output_text(
54
- self,
55
- text: str | None,
56
- ) -> tuple[str | None, int]:
57
- return text, 0
58
-
59
-
60
- UserDetailStripperFactory = Callable[[object], UserDetailStripper | None]
61
-
62
-
63
- def _default_user_detail_stripper_factory(
64
- _configurator: object,
65
- ) -> UserDetailStripper | None:
66
- return None
67
-
68
-
69
- _user_detail_stripper_factory: UserDetailStripperFactory = (
70
- _default_user_detail_stripper_factory
71
- )
72
-
73
-
74
- def set_user_detail_stripper_factory(factory: UserDetailStripperFactory) -> None:
75
- """Register the deployment-specific aggregation stripper factory."""
76
- global _user_detail_stripper_factory # noqa: PLW0603
77
- _user_detail_stripper_factory = factory
78
-
79
-
80
- def create_aggregation_user_detail_stripper(
81
- configurator: object,
82
- ) -> UserDetailStripper | None:
83
- """Create the deployment-specific stripper for aggregation, if any."""
84
- return _user_detail_stripper_factory(configurator)