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
@@ -1,20 +1,34 @@
1
- """Process-local scheduler for lineage tombstone garbage collection.
1
+ """Process-local scheduler for lineage tombstone garbage collection and expiry reclamation.
2
2
 
3
- The scheduler is per-org and config-gated: each tick it discovers every org,
4
- reads that org's ``lineage_gc`` config, and if enabled — hard-deletes
5
- tombstone rows older than the configured grace window. One org's failure
6
- never stalls the loop; errors are captured as Sentry anomalies and the
7
- scheduler continues to the next org.
3
+ Startup runs when EITHER ``lineage_gc.enabled`` OR ``expiry_reclamation.enabled``
4
+ OR ``governance_retention.audit_events_retention_enabled`` is set in the bootstrap
5
+ org's config. Each tick evaluates every org independently.
6
+
7
+ - **Class A** (profile expiry sweep + tombstone GC): gated on ``lineage_gc.enabled``.
8
+ Hard-deletes expired tombstones per that org's ``lineage_gc`` config.
9
+ - **Class B** (plain-row direct-delete sweeps, no PII/audit obligation): gated on
10
+ ``expiry_reclamation.enabled``. Currently sweeps expired share links and expired
11
+ pending tool calls.
12
+ - **Per-org sweeps**: registered via :func:`register_per_org_sweep`; invoked
13
+ unconditionally once per org after the Class-B block. Enterprise registers a
14
+ governance-retention closure here at startup (see ``reflexio_ext``).
15
+
16
+ One org's failure never stalls the loop; errors are captured as Sentry anomalies
17
+ and the loop continues to the next org.
18
+
19
+ Note: the poll interval is always taken from ``lineage_gc.poll_interval_seconds``
20
+ even when only ``expiry_reclamation`` or ``governance_retention`` is enabled;
21
+ all sweep classes share that cadence.
8
22
  """
9
23
 
10
24
  from __future__ import annotations
11
25
 
12
26
  import logging
13
- import threading
14
27
  import time
15
28
  from collections.abc import Callable
16
29
 
17
30
  from reflexio.server.api_endpoints.request_context import RequestContext
31
+ from reflexio.server.scheduling import ThreadedScheduler
18
32
  from reflexio.server.tracing import capture_anomaly
19
33
 
20
34
  logger = logging.getLogger(__name__)
@@ -28,57 +42,157 @@ _HIGH_VOLUME_THRESHOLD = 1000
28
42
 
29
43
  _ENTITY_TYPES = ("user_playbook", "agent_playbook", "profile")
30
44
 
45
+ # Class B: direct-delete sweeps for expired plain rows (no PII/audit obligation).
46
+ # Each entry is (storage_method_name, grace_seconds, batch_limit).
47
+ # Methods added in Tasks 2.2 / 2.3; getattr-guarded so missing impls are skipped.
48
+ _CLASS_B_SWEEPS: tuple[tuple[str, int, int], ...] = (
49
+ ("delete_expired_share_links", 7 * 86400, 1000),
50
+ ("delete_expired_pending_tool_calls", 1 * 86400, 1000),
51
+ )
52
+
53
+ # Injection seam: a deployment (e.g. enterprise/managed) sets a tenant-enumerating
54
+ # provider here at startup so the single OSS-started scheduler sweeps ALL tenant
55
+ # orgs. maybe_start_lineage_gc reads this when no explicit provider is passed.
56
+ # None (OSS default) keeps behavior byte-for-byte identical to before.
57
+ _org_id_provider_hook: Callable[[], list[str]] | None = None
58
+
59
+
60
+ def set_org_id_provider(provider: Callable[[], list[str]] | None) -> None:
61
+ """Register the org-id provider consulted by ``maybe_start_lineage_gc``.
62
+
63
+ Managed multi-tenant deployments call this at app-construction time so the
64
+ lineage GC / expiry sweep reaches every tenant org (``storage.list_org_ids()``
65
+ raises ``NotImplementedError`` on Supabase, degrading to bootstrap-only).
66
+ Pass ``None`` to clear the hook (used by tests to restore OSS defaults).
67
+
68
+ Args:
69
+ provider (Callable[[], list[str]] | None): Callable returning the tenant
70
+ org ids to sweep, or ``None`` to clear.
71
+ """
72
+ global _org_id_provider_hook # noqa: PLW0603
73
+ _org_id_provider_hook = provider
74
+
75
+
76
+ # Global sweeps run once per tick (for GLOBAL tables like invitation_codes),
77
+ # gated on expiry_reclamation.enabled. Each fn takes `now` (unix epoch) and
78
+ # returns a deleted-row count. Enterprise registers its sweeps here at startup;
79
+ # empty (OSS default) keeps behavior byte-for-byte identical to before.
80
+ _global_sweep_hooks: list[Callable[[int], int]] = []
81
+
82
+
83
+ def register_global_sweep(fn: Callable[[int], int]) -> None:
84
+ """Register a global (once-per-tick) reclamation sweep.
85
+
86
+ Note: a registered sweep that absorbs its own exceptions (returns instead
87
+ of raising) will NOT trigger the generic ``lineage.global_sweep.failed``
88
+ backstop; such a sweep must emit its own failure anomaly.
89
+
90
+ Args:
91
+ fn (Callable[[int], int]): Called with the current unix epoch; returns
92
+ the number of rows it deleted.
93
+ """
94
+ _global_sweep_hooks.append(fn)
95
+
96
+
97
+ def clear_global_sweeps() -> None:
98
+ """Clear all registered global sweeps (tests restore OSS defaults)."""
99
+ _global_sweep_hooks.clear()
31
100
 
32
- class LineageGCScheduler:
33
- """Polling daemon that garbage-collects expired lineage tombstones per org."""
101
+
102
+ # Per-org sweeps run once per org per tick (for per-org reclamation concerns).
103
+ # Each fn takes (org_id, now) and returns a deleted-row count. Enterprise
104
+ # registers its closure here at startup so governance retention folds into the
105
+ # shared scheduler loop; empty (OSS default) keeps behavior identical to before.
106
+ _per_org_sweep_hooks: list[Callable[[str, int], int]] = []
107
+
108
+
109
+ def register_per_org_sweep(fn: Callable[[str, int], int]) -> None:
110
+ """Register a per-org reclamation sweep.
111
+
112
+ The sweep is invoked once per org inside ``_gc_tick``'s org loop, after
113
+ the Class-B block, unconditionally (not gated on ``lineage_gc`` or
114
+ ``expiry_reclamation`` — the real gate lives in the enterprise closure).
115
+
116
+ Note: a registered sweep that absorbs its own exceptions (returns instead
117
+ of raising) will NOT trigger the generic ``lineage.per_org_sweep.failed``
118
+ backstop; such a sweep must emit its own failure anomaly.
119
+
120
+ Args:
121
+ fn (Callable[[str, int], int]): Called with ``(org_id, now)`` where
122
+ ``now`` is the current unix epoch; returns the number of rows deleted.
123
+ """
124
+ _per_org_sweep_hooks.append(fn)
125
+
126
+
127
+ def clear_per_org_sweeps() -> None:
128
+ """Clear all registered per-org sweeps (tests restore OSS defaults)."""
129
+ _per_org_sweep_hooks.clear()
130
+
131
+
132
+ class LineageGCScheduler(ThreadedScheduler):
133
+ """Polling daemon that hard-deletes expired tombstones per org."""
34
134
 
35
135
  def __init__(
36
136
  self,
37
137
  *,
38
138
  request_context_factory: Callable[[str], RequestContext],
39
139
  bootstrap_org_id: str,
140
+ org_id_provider: Callable[[], list[str]] | None = None,
40
141
  ) -> None:
142
+ super().__init__(thread_name="reflexio-lineage-gc-scheduler")
41
143
  self.request_context_factory = request_context_factory
42
144
  self.bootstrap_org_id = bootstrap_org_id
43
- self._stop_event = threading.Event()
44
- self._thread: threading.Thread | None = None
145
+ # Optional injectable org-id source. When set (managed/multi-tenant mode),
146
+ # it is the authoritative enumeration seam; enterprise supplies a
147
+ # provider that lists tenant orgs via the control-plane repository — the
148
+ # only seam that surfaces managed tenants where storage.list_org_ids()
149
+ # raises NotImplementedError on Supabase. When None (OSS default),
150
+ # discovery falls back to storage.list_org_ids() exactly as before.
151
+ self.org_id_provider = org_id_provider
45
152
 
46
- def start(self) -> None:
47
- """Start the daemon thread (idempotent if already running)."""
48
- if self._thread is not None and self._thread.is_alive():
49
- return
50
- self._stop_event.clear()
51
- self._thread = threading.Thread(
52
- target=self._run_loop,
53
- name="reflexio-lineage-gc-scheduler",
54
- daemon=True,
55
- )
56
- self._thread.start()
153
+ def _on_started(self) -> None:
57
154
  logger.info("event=lineage_gc_scheduler_started")
58
155
 
59
- def stop(self, *, timeout_seconds: float = 5.0) -> None:
60
- """Signal the daemon to stop and wait for it to finish."""
61
- self._stop_event.set()
62
- if self._thread is not None:
63
- self._thread.join(timeout=timeout_seconds)
64
- self._thread = None
156
+ def _on_stopped(self) -> None:
65
157
  logger.info("event=lineage_gc_scheduler_stopped")
66
158
 
67
159
  def _discover_org_ids(self, bootstrap_ctx: RequestContext) -> list[str]:
68
- """Return every known org, always including the bootstrap org."""
69
- storage = getattr(bootstrap_ctx, "storage", None)
70
- org_ids: list[str] = []
71
- if storage is not None:
160
+ """Return every known org, always including the bootstrap org.
161
+
162
+ When an ``org_id_provider`` was injected (managed/multi-tenant mode), it
163
+ is the authoritative org-id source and ``storage.list_org_ids()`` is not
164
+ consulted. Otherwise the OSS single-tenant path is used unchanged
165
+ (``storage.list_org_ids()`` with a visible NotImplementedError fallback).
166
+ The bootstrap org is always unioned in either way.
167
+ """
168
+ if self.org_id_provider is not None:
72
169
  try:
73
- org_ids = storage.list_org_ids()
74
- except NotImplementedError:
75
- logger.warning(
76
- "event=lineage_gc_list_org_ids_not_implemented "
77
- "backend=%s bootstrap_org_id=%s — GC will only process bootstrap org",
78
- type(storage).__name__,
79
- bootstrap_ctx.org_id,
170
+ org_ids = list(self.org_id_provider())
171
+ except Exception:
172
+ capture_anomaly(
173
+ "lineage.gc.org_id_provider_failed",
174
+ bootstrap_org_id=self.bootstrap_org_id,
175
+ )
176
+ logger.exception(
177
+ "event=lineage_gc_org_id_provider_failed bootstrap_org_id=%s "
178
+ "— falling back to bootstrap org only",
179
+ self.bootstrap_org_id,
80
180
  )
81
181
  org_ids = []
182
+ else:
183
+ storage = getattr(bootstrap_ctx, "storage", None)
184
+ org_ids = []
185
+ if storage is not None:
186
+ try:
187
+ org_ids = storage.list_org_ids()
188
+ except NotImplementedError:
189
+ logger.warning(
190
+ "event=lineage_gc_list_org_ids_not_implemented "
191
+ "backend=%s bootstrap_org_id=%s — GC will only process bootstrap org",
192
+ type(storage).__name__,
193
+ bootstrap_ctx.org_id,
194
+ )
195
+ org_ids = []
82
196
  if bootstrap_ctx.org_id not in org_ids:
83
197
  org_ids = [bootstrap_ctx.org_id, *org_ids]
84
198
  return org_ids
@@ -96,61 +210,201 @@ class LineageGCScheduler:
96
210
  break
97
211
  try:
98
212
  ctx = self.request_context_factory(org_id)
99
- cfg = ctx.configurator.get_config()
100
- if not cfg.lineage_gc.enabled:
101
- continue
102
213
  if ctx.storage is None:
103
214
  continue
104
- older_than_epoch = (
105
- int(time.time())
106
- - cfg.lineage_gc.tombstone_grace_window_days * 86400
107
- )
108
- total_deleted = 0
109
- for entity_type in _ENTITY_TYPES:
110
- count = ctx.storage.gc_expired_tombstones(
111
- entity_type=entity_type,
112
- older_than_epoch=older_than_epoch,
215
+ cfg = ctx.configurator.get_config()
216
+ except Exception:
217
+ capture_anomaly("lineage.gc.run_failed", org_id=org_id)
218
+ logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
219
+ continue
220
+
221
+ # Class A: profile expiry sweep (requires PII/grace sign-off; gated on
222
+ # lineage_gc.enabled independently of Class B).
223
+ if cfg.lineage_gc.enabled:
224
+ try:
225
+ expired_tombstoned = ctx.storage.expire_active_profiles(
226
+ now=int(time.time())
227
+ )
228
+ if expired_tombstoned:
229
+ logger.info(
230
+ "event=expiry_sweep org_id=%s profiles_tombstoned=%d",
231
+ org_id,
232
+ expired_tombstoned,
233
+ )
234
+ if expired_tombstoned > _HIGH_VOLUME_THRESHOLD:
235
+ capture_anomaly(
236
+ "lineage.expiry_sweep.high_volume",
237
+ org_id=org_id,
238
+ count=expired_tombstoned,
239
+ )
240
+ except Exception:
241
+ capture_anomaly("lineage.expiry_sweep.failed", org_id=org_id)
242
+ logger.exception(
243
+ "event=lineage_expiry_sweep_failed org_id=%s", org_id
244
+ )
245
+
246
+ # Tombstone GC: each entity type is independent within the loop.
247
+ try:
248
+ older_than_epoch = (
249
+ int(time.time())
250
+ - cfg.lineage_gc.tombstone_grace_window_days * 86400
251
+ )
252
+ tombstone_deleted = 0
253
+ for entity_type in _ENTITY_TYPES:
254
+ tombstone_deleted += ctx.storage.gc_expired_tombstones(
255
+ entity_type=entity_type,
256
+ older_than_epoch=older_than_epoch,
257
+ )
258
+ if tombstone_deleted:
259
+ logger.info(
260
+ "event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
261
+ org_id,
262
+ tombstone_deleted,
263
+ )
264
+ if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
265
+ capture_anomaly(
266
+ "lineage.gc.high_volume",
267
+ org_id=org_id,
268
+ count=tombstone_deleted,
269
+ )
270
+ except Exception:
271
+ capture_anomaly("lineage.gc.tombstone_gc_failed", org_id=org_id)
272
+ logger.exception(
273
+ "event=lineage_gc_tombstone_gc_failed org_id=%s", org_id
113
274
  )
114
- total_deleted += count
115
- if total_deleted:
275
+
276
+ # Class B: direct-delete of expired plain rows (no audit/grace
277
+ # obligation; independent of lineage_gc). Each sweep is isolated so
278
+ # one failing method does not skip the rest.
279
+ if (
280
+ getattr(cfg, "expiry_reclamation", None) is not None
281
+ and cfg.expiry_reclamation.enabled
282
+ ):
283
+ now = int(time.time())
284
+ for method_name, grace, limit in _CLASS_B_SWEEPS:
285
+ method = getattr(ctx.storage, method_name, None)
286
+ if method is None:
287
+ continue
288
+ try:
289
+ deleted = method(now=now, grace_seconds=grace, limit=limit)
290
+ if deleted:
291
+ logger.info(
292
+ "event=class_b_reclaim org_id=%s method=%s deleted=%d",
293
+ org_id,
294
+ method_name,
295
+ deleted,
296
+ )
297
+ except Exception:
298
+ capture_anomaly(
299
+ "lineage.class_b_reclaim.failed",
300
+ org_id=org_id,
301
+ method=method_name,
302
+ )
303
+ logger.exception(
304
+ "event=class_b_reclaim_failed org_id=%s method=%s",
305
+ org_id,
306
+ method_name,
307
+ )
308
+
309
+ # Per-org sweeps: invoked unconditionally once per org (the real gate
310
+ # lives in each enterprise closure). Extracted to keep _gc_tick's
311
+ # cyclomatic complexity in check and to mirror _run_global_sweeps.
312
+ self._run_per_org_sweeps(org_id)
313
+
314
+ def _run_per_org_sweeps(self, org_id: str) -> None:
315
+ """Invoke each registered per-org sweep once for this org.
316
+
317
+ Invoked unconditionally (not gated on ``lineage_gc``/``expiry_reclamation``
318
+ — the real gate lives in each enterprise closure). Per-sweep failure
319
+ isolation: one sweep raising does not skip the others; a sweep that
320
+ absorbs its own exceptions bypasses the ``lineage.per_org_sweep.failed``
321
+ backstop and must emit its own anomaly.
322
+
323
+ Args:
324
+ org_id: The org being swept this tick.
325
+ """
326
+ now = int(time.time())
327
+ for sweep in _per_org_sweep_hooks:
328
+ sweep_id = getattr(sweep, "__qualname__", repr(sweep))
329
+ try:
330
+ deleted = sweep(org_id, now)
331
+ if deleted:
116
332
  logger.info(
117
- "event=lineage_gc_tick org_id=%s deleted=%d",
333
+ "event=per_org_sweep org_id=%s sweep=%s deleted=%d",
118
334
  org_id,
119
- total_deleted,
120
- )
121
- if total_deleted > _HIGH_VOLUME_THRESHOLD:
122
- capture_anomaly(
123
- "lineage.gc.high_volume",
124
- org_id=org_id,
125
- count=total_deleted,
335
+ sweep_id,
336
+ deleted,
126
337
  )
127
338
  except Exception:
128
- capture_anomaly("lineage.gc.run_failed", org_id=org_id)
129
- logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
339
+ capture_anomaly(
340
+ "lineage.per_org_sweep.failed",
341
+ org_id=org_id,
342
+ sweep=sweep_id,
343
+ )
344
+ logger.exception(
345
+ "event=per_org_sweep_failed org_id=%s sweep=%s",
346
+ org_id,
347
+ sweep_id,
348
+ )
130
349
 
131
- def _run_loop(self) -> None:
132
- while not self._stop_event.is_set():
133
- poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
350
+ def _run_global_sweeps(self, cfg: object) -> None:
351
+ """Invoke each registered global sweep once, gated on expiry_reclamation.
352
+
353
+ Per-sweep failure isolation: one sweep raising does not skip the others.
354
+ The specific per-entity anomaly is emitted inside each registered sweep;
355
+ this is a generic backstop.
356
+
357
+ Args:
358
+ cfg: The bootstrap-org config read for this tick.
359
+ """
360
+ expiry = getattr(cfg, "expiry_reclamation", None)
361
+ if expiry is None or not expiry.enabled:
362
+ return
363
+ now = int(time.time())
364
+ for sweep in _global_sweep_hooks:
134
365
  try:
135
- bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
136
- cfg = bootstrap_ctx.configurator.get_config()
137
- poll_interval = cfg.lineage_gc.poll_interval_seconds
138
- org_ids = self._discover_org_ids(bootstrap_ctx)
139
- self._gc_tick(org_ids)
366
+ deleted = sweep(now)
367
+ if deleted:
368
+ logger.info("event=global_sweep deleted=%d", deleted)
140
369
  except Exception:
141
- logger.exception("event=lineage_gc_scheduler_tick_failed")
142
- self._stop_event.wait(max(poll_interval, _MIN_POLL_SECONDS))
370
+ sweep_id = getattr(sweep, "__qualname__", repr(sweep))
371
+ capture_anomaly("lineage.global_sweep.failed", sweep=sweep_id)
372
+ logger.exception("event=global_sweep_failed sweep=%s", sweep_id)
373
+
374
+ def _run_once(self) -> float:
375
+ poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
376
+ try:
377
+ bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
378
+ cfg = bootstrap_ctx.configurator.get_config()
379
+ poll_interval = cfg.lineage_gc.poll_interval_seconds
380
+ org_ids = self._discover_org_ids(bootstrap_ctx)
381
+ self._gc_tick(org_ids)
382
+ self._run_global_sweeps(cfg)
383
+ except Exception:
384
+ logger.exception("event=lineage_gc_scheduler_tick_failed")
385
+ return max(poll_interval, _MIN_POLL_SECONDS)
143
386
 
144
387
 
145
388
  def maybe_start_lineage_gc(
146
389
  request_context_factory: Callable[[str], RequestContext],
147
390
  *,
148
391
  bootstrap_org_id: str,
392
+ org_id_provider: Callable[[], list[str]] | None = None,
149
393
  ) -> LineageGCScheduler | None:
150
- """Start the GC scheduler only when the bootstrap-org config enables it.
394
+ """Start the scheduler when bootstrap config enables tombstone GC or expiry reclamation.
395
+
396
+ Startup runs when the bootstrap-org config sets ``lineage_gc.enabled``
397
+ (Class A: profile expiry + tombstone GC), ``expiry_reclamation.enabled``
398
+ (Class B: plain-row direct-delete sweeps), or
399
+ ``governance_retention.audit_events_retention_enabled`` — OR when any sweep
400
+ hook has been registered via :func:`register_per_org_sweep` /
401
+ :func:`register_global_sweep`. Registered hooks carry their own per-org gate
402
+ in the enterprise closure, so the scheduler must start to evaluate them even
403
+ when none of the bootstrap-org flags are set. Returns ``None`` only if none
404
+ of these conditions hold.
151
405
 
152
- Off by default. Enablement criteria (must ALL hold before enabling for a
153
- production org):
406
+ Tombstone-GC enablement criteria (must ALL hold before enabling ``lineage_gc``
407
+ for a production org):
154
408
 
155
409
  1. **Mechanism**: GC ages tombstones by ``retired_at`` (the INTEGER epoch
156
410
  written at every tombstone write-path). Rows with ``retired_at = NULL``
@@ -165,17 +419,67 @@ def maybe_start_lineage_gc(
165
419
  4. **DPO sign-off**: obtain sign-off on the PII-lifetime and audit-depth
166
420
  implications before enabling in any deployment that processes personal data.
167
421
 
422
+ Note: the poll cadence is always controlled by ``lineage_gc.poll_interval_seconds``
423
+ even when only ``expiry_reclamation`` is enabled; Class B shares that interval.
424
+
168
425
  Args:
169
426
  request_context_factory: Builds an org-scoped :class:`RequestContext`.
170
427
  bootstrap_org_id: Org used to read config and seed cross-org discovery.
428
+ org_id_provider: Optional tenant-enumerating org-id source. When ``None``
429
+ (OSS default), falls back to the module-level provider hook set via
430
+ :func:`set_org_id_provider`; when both are ``None`` discovery uses
431
+ ``storage.list_org_ids()`` exactly as before.
171
432
 
172
433
  Returns:
173
- LineageGCScheduler: The started scheduler, or ``None`` if not enabled.
434
+ LineageGCScheduler: The started scheduler, or ``None`` if no start
435
+ condition holds (see the startup criteria above).
174
436
  """
175
437
  try:
176
438
  ctx = request_context_factory(bootstrap_org_id)
177
439
  cfg = ctx.configurator.get_config()
178
- if not cfg.lineage_gc.enabled:
440
+
441
+ # Dead-knob warning: audit-event retention is an ENTERPRISE-only feature
442
+ # (handled by an enterprise per-org reclamation sweep registered via
443
+ # register_per_org_sweep). In an OSS-only deployment the knob is accepted
444
+ # but does nothing — warn loudly. Detected via the configurator class:
445
+ # enterprise swaps in EnterpriseConfigurator at construction, so the OSS
446
+ # DefaultConfigurator means "no enterprise here".
447
+ from reflexio.server.services.configurator.configurator import ( # noqa: PLC0415
448
+ DefaultConfigurator,
449
+ get_configurator_class,
450
+ )
451
+
452
+ gr = getattr(cfg, "governance_retention", None)
453
+ if (
454
+ gr is not None
455
+ and getattr(gr, "audit_events_retention_enabled", False)
456
+ and get_configurator_class() is DefaultConfigurator
457
+ ):
458
+ logger.warning(
459
+ "event=governance_retention_knob_ignored "
460
+ "audit_events_retention_enabled=True but this is an OSS-only "
461
+ "deployment — audit-event retention is an enterprise-only feature "
462
+ "and will NOT run here."
463
+ )
464
+
465
+ expiry_reclamation_enabled = getattr(
466
+ getattr(cfg, "expiry_reclamation", None), "enabled", False
467
+ )
468
+ governance_retention_enabled = getattr(
469
+ gr, "audit_events_retention_enabled", False
470
+ )
471
+ # Also start when any reclamation sweep has been registered — registered
472
+ # hooks carry their own per-org gates (in the enterprise closure), so the
473
+ # scheduler must run even if the bootstrap org's config has all flags off.
474
+ # This preserves the "start unconditionally, gate per-org" invariant of
475
+ # the deleted GovernanceRetentionScheduler.
476
+ if not (
477
+ cfg.lineage_gc.enabled
478
+ or expiry_reclamation_enabled
479
+ or governance_retention_enabled
480
+ or bool(_per_org_sweep_hooks)
481
+ or bool(_global_sweep_hooks)
482
+ ):
179
483
  return None
180
484
  except Exception as exc:
181
485
  logger.warning(
@@ -188,6 +492,9 @@ def maybe_start_lineage_gc(
188
492
  scheduler = LineageGCScheduler(
189
493
  request_context_factory=request_context_factory,
190
494
  bootstrap_org_id=bootstrap_org_id,
495
+ org_id_provider=(
496
+ org_id_provider if org_id_provider is not None else _org_id_provider_hook
497
+ ),
191
498
  )
192
499
  scheduler.start()
193
500
  return scheduler
@@ -16,7 +16,7 @@ Description: Playbook extraction, aggregation, and consolidation pipeline
16
16
  |------|---------|
17
17
  | `playbook_service_constants.py` | Prompt IDs for all playbook operations |
18
18
  | `playbook_service_utils.py` | Request dataclasses, Pydantic output schemas, message construction utilities |
19
- | `user_detail_stripping.py` | Optional aggregation-boundary interfaces and helpers for prompt-only user-detail normalization |
19
+ | `aggregation_prompt_processing.py` | Optional aggregation-boundary interfaces and helpers for prompt preprocessing, contextual prompt guidance, and output post-processing |
20
20
 
21
21
  ## Architecture
22
22
 
@@ -49,6 +49,14 @@ Triggered manually via `/api/run_playbook_aggregation`. Clusters user playbooks
49
49
  **Key Methods**:
50
50
  - `get_clusters(user_playbooks, config)` - HDBSCAN/Agglomerative clustering on embeddings
51
51
  - `aggregate()` - Full aggregation pipeline with LLM-based consolidation
52
+
53
+ **Optional prompt processing**: deployments can register an
54
+ `AggregationPromptProcessor` via the `AGGREGATION_PROMPT_PROCESSOR` ServiceKey
55
+ (`register_service`). The aggregator applies the processor only at the
56
+ aggregation prompt boundary, carries an opaque per-cluster processing context,
57
+ injects extra prompt guidance only when preprocessing changed prompt input, and
58
+ post-processes generated outputs before storage or model-response logging.
59
+
52
60
  **Change Log**: The legacy `playbook_aggregation_change_logs` table is retired (Track B, 2026-06-24) — the aggregator no longer writes it. The change-log view is reconstructed on demand from `lineage_event` via `reconstruct_playbook_aggregation_change_log` (`lib/_agent_playbook.py`): each run emits `op=aggregate` events (the "added" side) and `status_change→superseded` events from the supersede calls (the "removed" side), grouped by the run's `request_id`. Per-row `updated` pairing is not reconstructed (`updated_agent_playbooks=[]`, a tolerated parity delta).
53
61
 
54
62
  **Clustering**: Embeds user playbooks -> HDBSCAN clustering -> falls back to Agglomerative if too few clusters
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Protocol
5
+
6
+ from reflexio.server.extensions import ServiceKey
7
+
8
+
9
+ @dataclass
10
+ class AggregationPromptProcessingContext:
11
+ """Per-cluster state shared across aggregation prompt-processing hooks.
12
+
13
+ ``changed`` controls whether contextual prompt guidance is requested.
14
+ ``data`` is an opaque deployment-owned mapping. The aggregator initializes
15
+ ``agent_version`` and ``org_id`` keys when those values are available.
16
+ """
17
+
18
+ changed: bool = False
19
+ data: dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PromptPreprocessResult:
24
+ text: str
25
+ changed: bool = False
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PromptPostprocessResult:
30
+ value: object
31
+ artifacts_removed: int = 0
32
+
33
+
34
+ class AggregationPromptProcessor(Protocol):
35
+ """Optional deployment hook for the playbook aggregation boundary.
36
+
37
+ Implementations may transform prompt text before the aggregation LLM call,
38
+ provide contextual prompt instructions when preprocessing changed prompt
39
+ input, and post-process aggregation output before storage or response
40
+ logging. Deployments supply one by registering it via the
41
+ ``AGGREGATION_PROMPT_PROCESSOR`` ServiceKey (``register_service``).
42
+ """
43
+
44
+ def preprocess_prompt_text(
45
+ self,
46
+ text: str,
47
+ *,
48
+ shared_state: dict[str, Any] | None = None,
49
+ context: AggregationPromptProcessingContext | None = None,
50
+ ) -> PromptPreprocessResult:
51
+ """Return text to send to the aggregation prompt."""
52
+ ...
53
+
54
+ def prompt_instructions(
55
+ self,
56
+ context: AggregationPromptProcessingContext,
57
+ ) -> str | None:
58
+ """Return extra prompt guidance for this aggregation context, if any."""
59
+ ...
60
+
61
+ def postprocess_aggregation_output(
62
+ self,
63
+ value: object,
64
+ *,
65
+ context: AggregationPromptProcessingContext | None = None,
66
+ ) -> PromptPostprocessResult:
67
+ """Return output to store or log after the aggregation LLM call."""
68
+ ...
69
+
70
+
71
+ class PassthroughPromptProcessor:
72
+ def preprocess_prompt_text(
73
+ self,
74
+ text: str,
75
+ *,
76
+ shared_state: dict[str, Any] | None = None, # noqa: ARG002
77
+ context: AggregationPromptProcessingContext | None = None, # noqa: ARG002
78
+ ) -> PromptPreprocessResult:
79
+ return PromptPreprocessResult(text=text)
80
+
81
+ def prompt_instructions(
82
+ self,
83
+ context: AggregationPromptProcessingContext, # noqa: ARG002
84
+ ) -> str | None:
85
+ return None
86
+
87
+ def postprocess_aggregation_output(
88
+ self,
89
+ value: object,
90
+ *,
91
+ context: AggregationPromptProcessingContext | None = None, # noqa: ARG002
92
+ ) -> PromptPostprocessResult:
93
+ return PromptPostprocessResult(value=value)
94
+
95
+
96
+ # Singleton runtime-service key (round-5 RD-NEC-002): one processor registered at
97
+ # the enterprise composition root; None in local OSS (passthrough, no redaction).
98
+ AGGREGATION_PROMPT_PROCESSOR: ServiceKey[AggregationPromptProcessor] = ServiceKey(
99
+ "aggregation_prompt_processor"
100
+ )