shellbrain 0.1.0__py3-none-any.whl

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 (165) hide show
  1. app/__init__.py +1 -0
  2. app/__main__.py +7 -0
  3. app/boot/__init__.py +1 -0
  4. app/boot/admin_db.py +88 -0
  5. app/boot/config.py +14 -0
  6. app/boot/create_policy.py +52 -0
  7. app/boot/db.py +70 -0
  8. app/boot/embeddings.py +55 -0
  9. app/boot/home.py +45 -0
  10. app/boot/migrations.py +61 -0
  11. app/boot/read_policy.py +179 -0
  12. app/boot/repos.py +15 -0
  13. app/boot/retrieval.py +3 -0
  14. app/boot/thresholds.py +19 -0
  15. app/boot/update_policy.py +34 -0
  16. app/boot/use_cases.py +22 -0
  17. app/config/__init__.py +1 -0
  18. app/config/defaults/create_policy.yaml +7 -0
  19. app/config/defaults/read_policy.yaml +25 -0
  20. app/config/defaults/runtime.yaml +10 -0
  21. app/config/defaults/thresholds.yaml +3 -0
  22. app/config/defaults/update_policy.yaml +5 -0
  23. app/config/loader.py +58 -0
  24. app/core/__init__.py +1 -0
  25. app/core/contracts/__init__.py +1 -0
  26. app/core/contracts/errors.py +29 -0
  27. app/core/contracts/requests.py +211 -0
  28. app/core/contracts/responses.py +15 -0
  29. app/core/entities/__init__.py +1 -0
  30. app/core/entities/associations.py +58 -0
  31. app/core/entities/episodes.py +66 -0
  32. app/core/entities/evidence.py +29 -0
  33. app/core/entities/facts.py +30 -0
  34. app/core/entities/guidance.py +47 -0
  35. app/core/entities/identity.py +48 -0
  36. app/core/entities/memory.py +34 -0
  37. app/core/entities/runtime_context.py +19 -0
  38. app/core/entities/session_state.py +31 -0
  39. app/core/entities/telemetry.py +152 -0
  40. app/core/entities/utility.py +14 -0
  41. app/core/interfaces/__init__.py +1 -0
  42. app/core/interfaces/clock.py +12 -0
  43. app/core/interfaces/config.py +28 -0
  44. app/core/interfaces/embeddings.py +12 -0
  45. app/core/interfaces/idgen.py +11 -0
  46. app/core/interfaces/repos.py +279 -0
  47. app/core/interfaces/retrieval.py +20 -0
  48. app/core/interfaces/session_state_store.py +33 -0
  49. app/core/interfaces/unit_of_work.py +50 -0
  50. app/core/policies/__init__.py +1 -0
  51. app/core/policies/_shared/__init__.py +1 -0
  52. app/core/policies/_shared/executor.py +132 -0
  53. app/core/policies/_shared/side_effects.py +9 -0
  54. app/core/policies/create_policy/__init__.py +1 -0
  55. app/core/policies/create_policy/pipeline.py +96 -0
  56. app/core/policies/read_policy/__init__.py +1 -0
  57. app/core/policies/read_policy/bm25.py +114 -0
  58. app/core/policies/read_policy/context_pack_builder.py +140 -0
  59. app/core/policies/read_policy/expansion.py +132 -0
  60. app/core/policies/read_policy/fusion_rrf.py +34 -0
  61. app/core/policies/read_policy/lexical_query.py +101 -0
  62. app/core/policies/read_policy/pipeline.py +93 -0
  63. app/core/policies/read_policy/scenario_lift.py +11 -0
  64. app/core/policies/read_policy/scoring.py +61 -0
  65. app/core/policies/read_policy/seed_retrieval.py +54 -0
  66. app/core/policies/read_policy/utility_prior.py +11 -0
  67. app/core/policies/update_policy/__init__.py +1 -0
  68. app/core/policies/update_policy/pipeline.py +80 -0
  69. app/core/use_cases/__init__.py +1 -0
  70. app/core/use_cases/build_guidance.py +85 -0
  71. app/core/use_cases/create_memory.py +26 -0
  72. app/core/use_cases/manage_session_state.py +159 -0
  73. app/core/use_cases/read_memory.py +21 -0
  74. app/core/use_cases/record_episode_sync_telemetry.py +19 -0
  75. app/core/use_cases/record_operation_telemetry.py +32 -0
  76. app/core/use_cases/sync_episode.py +162 -0
  77. app/core/use_cases/update_memory.py +40 -0
  78. app/migrations/__init__.py +1 -0
  79. app/migrations/env.py +65 -0
  80. app/migrations/versions/20260226_0001_initial_schema.py +232 -0
  81. app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
  82. app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
  83. app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
  84. app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
  85. app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
  86. app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
  87. app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
  88. app/migrations/versions/__init__.py +1 -0
  89. app/periphery/__init__.py +1 -0
  90. app/periphery/admin/__init__.py +1 -0
  91. app/periphery/admin/backup.py +360 -0
  92. app/periphery/admin/destructive_guard.py +32 -0
  93. app/periphery/admin/doctor.py +192 -0
  94. app/periphery/admin/init.py +996 -0
  95. app/periphery/admin/instance_guard.py +211 -0
  96. app/periphery/admin/machine_state.py +354 -0
  97. app/periphery/admin/privileges.py +42 -0
  98. app/periphery/admin/repo_state.py +266 -0
  99. app/periphery/admin/restore.py +30 -0
  100. app/periphery/cli/__init__.py +1 -0
  101. app/periphery/cli/handlers.py +830 -0
  102. app/periphery/cli/hydration.py +119 -0
  103. app/periphery/cli/main.py +710 -0
  104. app/periphery/cli/presenter_json.py +10 -0
  105. app/periphery/cli/schema_validation.py +201 -0
  106. app/periphery/db/__init__.py +1 -0
  107. app/periphery/db/engine.py +10 -0
  108. app/periphery/db/models/__init__.py +1 -0
  109. app/periphery/db/models/associations.py +55 -0
  110. app/periphery/db/models/episodes.py +55 -0
  111. app/periphery/db/models/evidence.py +19 -0
  112. app/periphery/db/models/experiences.py +33 -0
  113. app/periphery/db/models/instance_metadata.py +17 -0
  114. app/periphery/db/models/memories.py +39 -0
  115. app/periphery/db/models/metadata.py +6 -0
  116. app/periphery/db/models/registry.py +18 -0
  117. app/periphery/db/models/telemetry.py +174 -0
  118. app/periphery/db/models/utility.py +19 -0
  119. app/periphery/db/models/views.py +154 -0
  120. app/periphery/db/repos/__init__.py +1 -0
  121. app/periphery/db/repos/relational/__init__.py +1 -0
  122. app/periphery/db/repos/relational/associations_repo.py +117 -0
  123. app/periphery/db/repos/relational/episodes_repo.py +188 -0
  124. app/periphery/db/repos/relational/evidence_repo.py +82 -0
  125. app/periphery/db/repos/relational/experiences_repo.py +41 -0
  126. app/periphery/db/repos/relational/memories_repo.py +99 -0
  127. app/periphery/db/repos/relational/read_policy_repo.py +202 -0
  128. app/periphery/db/repos/relational/telemetry_repo.py +161 -0
  129. app/periphery/db/repos/relational/utility_repo.py +30 -0
  130. app/periphery/db/repos/semantic/__init__.py +1 -0
  131. app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
  132. app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
  133. app/periphery/db/session.py +10 -0
  134. app/periphery/db/uow.py +75 -0
  135. app/periphery/embeddings/__init__.py +1 -0
  136. app/periphery/embeddings/local_provider.py +35 -0
  137. app/periphery/embeddings/query_vector_search.py +18 -0
  138. app/periphery/episodes/__init__.py +1 -0
  139. app/periphery/episodes/claude_code.py +387 -0
  140. app/periphery/episodes/codex.py +423 -0
  141. app/periphery/episodes/launcher.py +66 -0
  142. app/periphery/episodes/normalization.py +31 -0
  143. app/periphery/episodes/poller.py +299 -0
  144. app/periphery/episodes/source_discovery.py +66 -0
  145. app/periphery/episodes/tool_filter.py +165 -0
  146. app/periphery/identity/__init__.py +1 -0
  147. app/periphery/identity/claude_hook_install.py +67 -0
  148. app/periphery/identity/claude_runtime.py +83 -0
  149. app/periphery/identity/codex_runtime.py +32 -0
  150. app/periphery/identity/compatibility.py +38 -0
  151. app/periphery/identity/resolver.py +163 -0
  152. app/periphery/session_state/__init__.py +1 -0
  153. app/periphery/session_state/file_store.py +100 -0
  154. app/periphery/telemetry/__init__.py +33 -0
  155. app/periphery/telemetry/operation_summary.py +299 -0
  156. app/periphery/telemetry/session_selection.py +156 -0
  157. app/periphery/telemetry/sync_summary.py +65 -0
  158. app/periphery/validation/__init__.py +1 -0
  159. app/periphery/validation/integrity_validation.py +253 -0
  160. app/periphery/validation/semantic_validation.py +94 -0
  161. shellbrain-0.1.0.dist-info/METADATA +130 -0
  162. shellbrain-0.1.0.dist-info/RECORD +165 -0
  163. shellbrain-0.1.0.dist-info/WHEEL +5 -0
  164. shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
  165. shellbrain-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,830 @@
1
+ """This module defines CLI command handlers that dispatch to core use-case functions."""
2
+
3
+ from dataclasses import replace
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from pathlib import Path
7
+ from time import perf_counter
8
+ from uuid import uuid4
9
+
10
+ from app.boot.create_policy import get_create_hydration_defaults, get_create_policy_settings, validate_create_policy_settings
11
+ from app.boot.read_policy import get_read_hydration_defaults
12
+ from app.boot.update_policy import get_update_policy_settings, validate_update_policy_settings
13
+ from app.core.contracts.errors import ErrorCode, ErrorDetail
14
+ from app.core.contracts.requests import (
15
+ EpisodeEventsRequest,
16
+ MemoryBatchUpdateRequest,
17
+ MemoryCreateRequest,
18
+ MemoryUpdateRequest,
19
+ )
20
+ from app.core.contracts.responses import OperationResult
21
+ from app.core.entities.guidance import GuidanceDecision
22
+ from app.core.entities.identity import CallerIdentity, IdentityTrustLevel
23
+ from app.core.entities.telemetry import OperationDispatchTelemetryContext, SessionSelectionSummary
24
+ from app.core.use_cases.build_guidance import build_pending_utility_guidance
25
+ from app.core.use_cases.manage_session_state import SessionStateManager
26
+ from app.core.use_cases.create_memory import execute_create_memory
27
+ from app.core.use_cases.read_memory import execute_read_memory
28
+ from app.core.use_cases.record_episode_sync_telemetry import record_episode_sync_telemetry
29
+ from app.core.use_cases.record_operation_telemetry import record_operation_telemetry
30
+ from app.core.use_cases.sync_episode import sync_episode
31
+ from app.core.use_cases.update_memory import execute_update_memory
32
+ from app.periphery.cli.hydration import (
33
+ hydrate_create_payload,
34
+ hydrate_events_payload,
35
+ hydrate_read_payload,
36
+ hydrate_update_payload,
37
+ )
38
+ from app.periphery.cli.schema_validation import (
39
+ validate_create_schema,
40
+ validate_events_schema,
41
+ validate_internal_create_contract,
42
+ validate_internal_events_contract,
43
+ validate_internal_read_contract,
44
+ validate_internal_update_contract,
45
+ validate_read_schema,
46
+ validate_update_schema,
47
+ )
48
+ from app.periphery.episodes.normalization import normalize_host_transcript
49
+ from app.periphery.identity.resolver import (
50
+ discover_untrusted_events_candidate,
51
+ resolve_caller_identity,
52
+ resolve_trusted_events_source,
53
+ )
54
+ from app.periphery.session_state.file_store import FileSessionStateStore
55
+ from app.periphery.telemetry import get_operation_telemetry_context
56
+ from app.periphery.telemetry.operation_summary import (
57
+ build_operation_invocation_record,
58
+ build_read_summary_records,
59
+ build_write_summary_records,
60
+ infer_error_stage_from_errors,
61
+ )
62
+ from app.periphery.telemetry.session_selection import (
63
+ EventsDiscoveryCandidate,
64
+ summarize_runtime_selection,
65
+ )
66
+ from app.periphery.telemetry.sync_summary import build_episode_sync_records
67
+ from app.periphery.validation.integrity_validation import validate_create_integrity, validate_update_integrity
68
+ from app.periphery.validation.semantic_validation import validate_create_semantics, validate_update_semantics
69
+
70
+
71
+ def _error_response(errors: list[ErrorDetail]) -> dict:
72
+ """This function builds a standardized error response envelope for CLI handlers."""
73
+
74
+ return OperationResult(status="error", errors=errors).model_dump(mode="python")
75
+
76
+
77
+ def _validate_create_request(request: MemoryCreateRequest, *, uow, gates: list[str]) -> list[ErrorDetail]:
78
+ """Run non-schema create validations before invoking core execution."""
79
+
80
+ if "semantic" in gates:
81
+ semantic_errors = validate_create_semantics(request)
82
+ if semantic_errors:
83
+ return semantic_errors
84
+ if "integrity" in gates:
85
+ return validate_create_integrity(request, uow)
86
+ return []
87
+
88
+
89
+ def _validate_update_request(request: MemoryUpdateRequest | MemoryBatchUpdateRequest, *, uow, gates: list[str]) -> list[ErrorDetail]:
90
+ """Run non-schema update validations before invoking core execution."""
91
+
92
+ if "semantic" in gates:
93
+ semantic_errors = validate_update_semantics(request)
94
+ if semantic_errors:
95
+ return semantic_errors
96
+ if "integrity" in gates:
97
+ return validate_update_integrity(request, uow)
98
+ return []
99
+
100
+
101
+ def handle_create(
102
+ payload: dict,
103
+ *,
104
+ uow_factory,
105
+ embedding_provider_factory,
106
+ embedding_model: str,
107
+ inferred_repo_id: str,
108
+ defaults: dict | None = None,
109
+ telemetry_context: OperationDispatchTelemetryContext | None = None,
110
+ repo_root: Path | None = None,
111
+ ):
112
+ """This function validates and dispatches a create payload to the create use-case."""
113
+
114
+ started_at = perf_counter()
115
+ resolved_repo_root = (repo_root or Path.cwd()).resolve()
116
+ resolved_telemetry_context = _ensure_telemetry_context(telemetry_context=telemetry_context, repo_root=resolved_repo_root)
117
+ session_manager = SessionStateManager(store=FileSessionStateStore())
118
+ request: MemoryCreateRequest | None = None
119
+ result: dict | None = None
120
+ error_stage: str | None = None
121
+
122
+ try:
123
+ policy_errors = validate_create_policy_settings()
124
+ if policy_errors:
125
+ error_stage = infer_error_stage_from_errors(_dump_errors(policy_errors), default_stage="contract_validation")
126
+ result = _error_response(policy_errors)
127
+ else:
128
+ policy = get_create_policy_settings()
129
+ agent_request, errors = validate_create_schema(payload)
130
+ if errors:
131
+ error_stage = infer_error_stage_from_errors(_dump_errors(errors), default_stage="schema_validation")
132
+ result = _error_response(errors)
133
+ else:
134
+ assert agent_request is not None
135
+ resolved_defaults = defaults if defaults is not None else get_create_hydration_defaults()
136
+ hydrated_payload = hydrate_create_payload(
137
+ agent_request.model_dump(mode="python", exclude_none=True),
138
+ inferred_repo_id=inferred_repo_id,
139
+ defaults=resolved_defaults,
140
+ )
141
+ request, contract_errors = validate_internal_create_contract(hydrated_payload)
142
+ if contract_errors:
143
+ error_stage = infer_error_stage_from_errors(
144
+ _dump_errors(contract_errors),
145
+ default_stage="contract_validation",
146
+ )
147
+ result = _error_response(contract_errors)
148
+ else:
149
+ assert request is not None
150
+ with uow_factory() as uow:
151
+ validation_errors = _validate_create_request(request, uow=uow, gates=policy["gates"])
152
+ if validation_errors:
153
+ error_stage = infer_error_stage_from_errors(
154
+ _dump_errors(validation_errors),
155
+ default_stage="semantic_validation",
156
+ )
157
+ result = _error_response(validation_errors)
158
+ else:
159
+ embedding_provider = embedding_provider_factory()
160
+ result = execute_create_memory(
161
+ request,
162
+ uow,
163
+ embedding_provider=embedding_provider,
164
+ embedding_model=embedding_model,
165
+ ).model_dump(mode="python")
166
+ if result.get("status") == "ok":
167
+ session_state = session_manager.load_active_state(
168
+ repo_root=resolved_repo_root,
169
+ caller_identity=resolved_telemetry_context.caller_identity,
170
+ )
171
+ request_links = request.memory.links
172
+ if request.memory.kind == "problem":
173
+ session_state = session_manager.record_problem(
174
+ repo_root=resolved_repo_root,
175
+ caller_identity=resolved_telemetry_context.caller_identity,
176
+ problem_id=str(result["data"]["memory_id"]),
177
+ )
178
+ elif request.memory.kind in {"solution", "failed_tactic"} and request_links.problem_id:
179
+ session_state = session_manager.record_problem(
180
+ repo_root=resolved_repo_root,
181
+ caller_identity=resolved_telemetry_context.caller_identity,
182
+ problem_id=request_links.problem_id,
183
+ )
184
+ strong_guidance = request.memory.kind == "solution"
185
+ guidance = _build_guidance_payloads(
186
+ uow_factory=uow_factory,
187
+ repo_id=inferred_repo_id,
188
+ caller_identity=resolved_telemetry_context.caller_identity,
189
+ session_state=session_state,
190
+ strong=strong_guidance,
191
+ )
192
+ if guidance:
193
+ _attach_guidance(result, guidance)
194
+ if session_state is not None and session_state.current_problem_id is not None:
195
+ session_manager.record_guidance(
196
+ repo_root=resolved_repo_root,
197
+ caller_identity=resolved_telemetry_context.caller_identity,
198
+ problem_id=session_state.current_problem_id,
199
+ )
200
+ except Exception as exc: # pragma: no cover - defensive fallback envelope
201
+ error_stage = "internal_error"
202
+ result = _error_response([ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=str(exc))])
203
+
204
+ assert result is not None
205
+ _persist_operation_telemetry_best_effort(
206
+ command="create",
207
+ uow_factory=uow_factory,
208
+ repo_id=inferred_repo_id,
209
+ telemetry_context=resolved_telemetry_context,
210
+ result=result,
211
+ error_stage=error_stage,
212
+ request=request,
213
+ agent_payload=payload,
214
+ total_latency_ms=int((perf_counter() - started_at) * 1000),
215
+ )
216
+ return result
217
+
218
+
219
+ def handle_read(
220
+ payload: dict,
221
+ *,
222
+ uow_factory,
223
+ inferred_repo_id: str,
224
+ defaults: dict | None = None,
225
+ telemetry_context: OperationDispatchTelemetryContext | None = None,
226
+ repo_root: Path | None = None,
227
+ ):
228
+ """This function validates and dispatches a read payload to the read use-case."""
229
+
230
+ started_at = perf_counter()
231
+ resolved_repo_root = (repo_root or Path.cwd()).resolve()
232
+ resolved_telemetry_context = _ensure_telemetry_context(telemetry_context=telemetry_context, repo_root=resolved_repo_root)
233
+ session_manager = SessionStateManager(store=FileSessionStateStore())
234
+ session_manager.load_active_state(
235
+ repo_root=resolved_repo_root,
236
+ caller_identity=resolved_telemetry_context.caller_identity,
237
+ )
238
+ request = None
239
+ result: dict | None = None
240
+ error_stage: str | None = None
241
+ try:
242
+ agent_request, errors = validate_read_schema(payload)
243
+ if errors:
244
+ error_stage = infer_error_stage_from_errors(_dump_errors(errors), default_stage="schema_validation")
245
+ result = _error_response(errors)
246
+ else:
247
+ assert agent_request is not None
248
+ resolved_defaults = defaults if defaults is not None else get_read_hydration_defaults()
249
+ hydrated_payload = hydrate_read_payload(
250
+ agent_request.model_dump(mode="python", exclude_none=True),
251
+ inferred_repo_id=inferred_repo_id,
252
+ defaults=resolved_defaults,
253
+ )
254
+ request, contract_errors = validate_internal_read_contract(hydrated_payload)
255
+ if contract_errors:
256
+ error_stage = infer_error_stage_from_errors(
257
+ _dump_errors(contract_errors),
258
+ default_stage="contract_validation",
259
+ )
260
+ result = _error_response(contract_errors)
261
+ else:
262
+ assert request is not None
263
+ with uow_factory() as uow:
264
+ result = execute_read_memory(request, uow).model_dump(mode="python")
265
+ except Exception as exc: # pragma: no cover - defensive fallback envelope
266
+ error_stage = "internal_error"
267
+ result = _error_response([ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=str(exc))])
268
+
269
+ assert result is not None
270
+ _persist_operation_telemetry_best_effort(
271
+ command="read",
272
+ uow_factory=uow_factory,
273
+ repo_id=inferred_repo_id,
274
+ telemetry_context=resolved_telemetry_context,
275
+ result=result,
276
+ error_stage=error_stage,
277
+ request=request,
278
+ agent_payload=payload,
279
+ total_latency_ms=int((perf_counter() - started_at) * 1000),
280
+ )
281
+ return result
282
+
283
+
284
+ def handle_events(
285
+ payload: dict,
286
+ *,
287
+ uow_factory,
288
+ inferred_repo_id: str,
289
+ repo_root: Path | None = None,
290
+ search_roots_by_host: dict[str, list[Path]] | None = None,
291
+ telemetry_context: OperationDispatchTelemetryContext | None = None,
292
+ ):
293
+ """Validate and dispatch an events payload to the active-episode browsing flow."""
294
+
295
+ started_at = perf_counter()
296
+ resolved_repo_root = (repo_root or Path.cwd()).resolve()
297
+ resolved_telemetry_context = _ensure_telemetry_context(
298
+ telemetry_context=telemetry_context,
299
+ repo_root=resolved_repo_root,
300
+ )
301
+ session_manager = SessionStateManager(store=FileSessionStateStore())
302
+ request = None
303
+ result: dict | None = None
304
+ error_stage: str | None = None
305
+ selection_summary = SessionSelectionSummary()
306
+ sync_run = None
307
+ sync_tool_types = ()
308
+ try:
309
+ agent_request, errors = validate_events_schema(payload)
310
+ if errors:
311
+ error_stage = infer_error_stage_from_errors(_dump_errors(errors), default_stage="schema_validation")
312
+ result = _error_response(errors)
313
+ else:
314
+ assert agent_request is not None
315
+ hydrated_payload = hydrate_events_payload(
316
+ agent_request.model_dump(mode="python", exclude_none=True),
317
+ inferred_repo_id=inferred_repo_id,
318
+ )
319
+ request, contract_errors = validate_internal_events_contract(hydrated_payload)
320
+ if contract_errors:
321
+ error_stage = infer_error_stage_from_errors(
322
+ _dump_errors(contract_errors),
323
+ default_stage="contract_validation",
324
+ )
325
+ result = _error_response(contract_errors)
326
+ else:
327
+ assert request is not None
328
+ source = _resolve_events_source(
329
+ repo_root=resolved_repo_root,
330
+ search_roots_by_host=search_roots_by_host,
331
+ runtime_context=resolved_telemetry_context,
332
+ )
333
+ selection_summary = _selection_summary_from_events_source(source)
334
+ sync_started_at = perf_counter()
335
+ try:
336
+ normalized_events = normalize_host_transcript(
337
+ host_app=str(source.host_app),
338
+ host_session_key=str(source.host_session_key),
339
+ transcript_path=Path(str(source.transcript_path)),
340
+ )
341
+ with uow_factory() as uow:
342
+ sync_result = sync_episode(
343
+ repo_id=request.repo_id,
344
+ host_app=str(source.host_app),
345
+ host_session_key=str(source.host_session_key),
346
+ thread_id=str(source.canonical_thread_id),
347
+ transcript_path=str(source.transcript_path),
348
+ normalized_events=normalized_events,
349
+ uow=uow,
350
+ )
351
+ events = uow.episodes.list_recent_events(
352
+ repo_id=request.repo_id,
353
+ episode_id=str(sync_result["episode_id"]),
354
+ limit=request.limit,
355
+ )
356
+ result = OperationResult(
357
+ status="ok",
358
+ data={
359
+ "episode_id": sync_result["episode_id"],
360
+ "host_app": source.host_app,
361
+ "thread_id": sync_result["thread_id"],
362
+ "events": [_serialize_episode_event(event) for event in events],
363
+ },
364
+ ).model_dump(mode="python")
365
+ selection_summary = replace(selection_summary, selected_episode_id=str(sync_result["episode_id"]))
366
+ if source.trusted:
367
+ session_manager.record_events(
368
+ repo_root=resolved_repo_root,
369
+ caller_identity=resolved_telemetry_context.caller_identity,
370
+ episode_id=str(sync_result["episode_id"]),
371
+ event_ids=[str(event["id"]) for event in result["data"]["events"]],
372
+ )
373
+ sync_run, sync_tool_types = build_episode_sync_records(
374
+ sync_run_id=str(uuid4()),
375
+ source="events_inline",
376
+ invocation_id=resolved_telemetry_context.invocation_id,
377
+ repo_id=request.repo_id,
378
+ host_app=str(source.host_app),
379
+ host_session_key=str(source.host_session_key),
380
+ thread_id=str(sync_result["thread_id"]),
381
+ episode_id=str(sync_result["episode_id"]),
382
+ transcript_path=str(sync_result["transcript_path"]),
383
+ outcome="ok",
384
+ error_stage=None,
385
+ error_message=None,
386
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
387
+ imported_event_count=int(sync_result["imported_event_count"]),
388
+ total_event_count=int(sync_result["total_event_count"]),
389
+ user_event_count=int(sync_result["user_event_count"]),
390
+ assistant_event_count=int(sync_result["assistant_event_count"]),
391
+ tool_event_count=int(sync_result["tool_event_count"]),
392
+ system_event_count=int(sync_result["system_event_count"]),
393
+ tool_type_counts=dict(sync_result["tool_type_counts"]),
394
+ )
395
+ except Exception as exc:
396
+ error_stage = "sync"
397
+ result = _error_response([ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=str(exc))])
398
+ sync_run, sync_tool_types = build_episode_sync_records(
399
+ sync_run_id=str(uuid4()),
400
+ source="events_inline",
401
+ invocation_id=resolved_telemetry_context.invocation_id,
402
+ repo_id=request.repo_id,
403
+ host_app=str(source.host_app),
404
+ host_session_key=str(source.host_session_key),
405
+ thread_id=selection_summary.selected_thread_id or str(source.canonical_thread_id),
406
+ episode_id=selection_summary.selected_episode_id,
407
+ transcript_path=str(source.transcript_path),
408
+ outcome="error",
409
+ error_stage="sync",
410
+ error_message=str(exc),
411
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
412
+ imported_event_count=0,
413
+ total_event_count=0,
414
+ user_event_count=0,
415
+ assistant_event_count=0,
416
+ tool_event_count=0,
417
+ system_event_count=0,
418
+ tool_type_counts={},
419
+ )
420
+ except _EventsSelectionError as exc:
421
+ error_stage = "session_selection"
422
+ result = _error_response([exc.error])
423
+ except Exception as exc: # pragma: no cover - defensive fallback envelope
424
+ error_stage = "internal_error"
425
+ result = _error_response([ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=str(exc))])
426
+
427
+ assert result is not None
428
+ _persist_operation_telemetry_best_effort(
429
+ command="events",
430
+ uow_factory=uow_factory,
431
+ repo_id=inferred_repo_id,
432
+ telemetry_context=resolved_telemetry_context,
433
+ result=result,
434
+ error_stage=error_stage,
435
+ request=request,
436
+ selection_summary=selection_summary,
437
+ sync_run=sync_run,
438
+ sync_tool_types=sync_tool_types,
439
+ total_latency_ms=int((perf_counter() - started_at) * 1000),
440
+ )
441
+ return result
442
+
443
+
444
+ def handle_update(
445
+ payload: dict,
446
+ *,
447
+ uow_factory,
448
+ inferred_repo_id: str,
449
+ telemetry_context: OperationDispatchTelemetryContext | None = None,
450
+ repo_root: Path | None = None,
451
+ ):
452
+ """This function validates and dispatches an update payload to the update use-case."""
453
+
454
+ started_at = perf_counter()
455
+ resolved_repo_root = (repo_root or Path.cwd()).resolve()
456
+ resolved_telemetry_context = _ensure_telemetry_context(telemetry_context=telemetry_context, repo_root=resolved_repo_root)
457
+ session_manager = SessionStateManager(store=FileSessionStateStore())
458
+ session_state = session_manager.load_active_state(
459
+ repo_root=resolved_repo_root,
460
+ caller_identity=resolved_telemetry_context.caller_identity,
461
+ )
462
+ request = None
463
+ result: dict | None = None
464
+ error_stage: str | None = None
465
+ try:
466
+ policy_errors = validate_update_policy_settings()
467
+ if policy_errors:
468
+ error_stage = infer_error_stage_from_errors(_dump_errors(policy_errors), default_stage="contract_validation")
469
+ result = _error_response(policy_errors)
470
+ else:
471
+ policy = get_update_policy_settings()
472
+ agent_request, errors = validate_update_schema(payload)
473
+ if errors:
474
+ error_stage = infer_error_stage_from_errors(_dump_errors(errors), default_stage="schema_validation")
475
+ result = _error_response(errors)
476
+ else:
477
+ assert agent_request is not None
478
+ hydrated_payload = hydrate_update_payload(
479
+ agent_request.model_dump(mode="python", exclude_none=True),
480
+ inferred_repo_id=inferred_repo_id,
481
+ )
482
+ request, contract_errors = validate_internal_update_contract(hydrated_payload)
483
+ if contract_errors:
484
+ error_stage = infer_error_stage_from_errors(
485
+ _dump_errors(contract_errors),
486
+ default_stage="contract_validation",
487
+ )
488
+ result = _error_response(contract_errors)
489
+ else:
490
+ assert request is not None
491
+ request, hydration_errors = _hydrate_update_request_evidence_from_session_state(
492
+ request=request,
493
+ session_state=session_state,
494
+ )
495
+ if hydration_errors:
496
+ error_stage = infer_error_stage_from_errors(
497
+ _dump_errors(hydration_errors),
498
+ default_stage="semantic_validation",
499
+ )
500
+ result = _error_response(hydration_errors)
501
+ request = None
502
+ raise _ReturnHandledError()
503
+ with uow_factory() as uow:
504
+ validation_errors = _validate_update_request(request, uow=uow, gates=policy["gates"])
505
+ if validation_errors:
506
+ error_stage = infer_error_stage_from_errors(
507
+ _dump_errors(validation_errors),
508
+ default_stage="semantic_validation",
509
+ )
510
+ result = _error_response(validation_errors)
511
+ else:
512
+ result = execute_update_memory(request, uow).model_dump(mode="python")
513
+ guidance = _build_guidance_payloads(
514
+ uow_factory=uow_factory,
515
+ repo_id=inferred_repo_id,
516
+ caller_identity=resolved_telemetry_context.caller_identity,
517
+ session_state=session_state,
518
+ strong=False,
519
+ )
520
+ if guidance:
521
+ _attach_guidance(result, guidance)
522
+ if session_state is not None and session_state.current_problem_id is not None:
523
+ session_manager.record_guidance(
524
+ repo_root=resolved_repo_root,
525
+ caller_identity=resolved_telemetry_context.caller_identity,
526
+ problem_id=session_state.current_problem_id,
527
+ )
528
+ except _ReturnHandledError:
529
+ pass
530
+ except Exception as exc: # pragma: no cover - defensive fallback envelope
531
+ error_stage = "internal_error"
532
+ result = _error_response([ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=str(exc))])
533
+
534
+ assert result is not None
535
+ _persist_operation_telemetry_best_effort(
536
+ command="update",
537
+ uow_factory=uow_factory,
538
+ repo_id=inferred_repo_id,
539
+ telemetry_context=resolved_telemetry_context,
540
+ result=result,
541
+ error_stage=error_stage,
542
+ request=request,
543
+ agent_payload=payload,
544
+ total_latency_ms=int((perf_counter() - started_at) * 1000),
545
+ )
546
+ return result
547
+
548
+
549
+ class _EventsSelectionError(Exception):
550
+ """Internal control-flow exception for expected events selection failures."""
551
+
552
+ def __init__(self, error: ErrorDetail) -> None:
553
+ super().__init__(error.message)
554
+ self.error = error
555
+
556
+
557
+ class _ReturnHandledError(Exception):
558
+ """Internal control-flow exception for already-materialized responses."""
559
+
560
+
561
+ def _resolve_events_source(
562
+ *,
563
+ repo_root: Path,
564
+ search_roots_by_host: dict[str, list[Path]] | None,
565
+ runtime_context: OperationDispatchTelemetryContext,
566
+ ):
567
+ """Resolve the exact trusted events source or an untrusted fallback candidate."""
568
+
569
+ caller_identity = runtime_context.caller_identity
570
+ if runtime_context.caller_identity_error is not None:
571
+ raise _EventsSelectionError(runtime_context.caller_identity_error)
572
+
573
+ if caller_identity is not None and caller_identity.trust_level == IdentityTrustLevel.TRUSTED:
574
+ source = resolve_trusted_events_source(
575
+ caller_identity=caller_identity,
576
+ repo_root=repo_root,
577
+ search_roots_by_host=search_roots_by_host,
578
+ )
579
+ if source.error is not None:
580
+ raise _EventsSelectionError(source.error)
581
+ return source
582
+
583
+ fallback = discover_untrusted_events_candidate(
584
+ repo_root=repo_root,
585
+ search_roots_by_host=search_roots_by_host,
586
+ )
587
+ if fallback is None:
588
+ raise _EventsSelectionError(
589
+ ErrorDetail(
590
+ code=ErrorCode.NOT_FOUND,
591
+ message="No active host session found for this repo",
592
+ )
593
+ )
594
+ return fallback
595
+
596
+
597
+ def _serialize_episode_event(event) -> dict:
598
+ """Render one stored episode event into deterministic JSON-safe output."""
599
+
600
+ content = str(event.content)
601
+ try:
602
+ parsed_content = json.loads(content)
603
+ except json.JSONDecodeError:
604
+ parsed_content = content
605
+ created_at = event.created_at.isoformat() if event.created_at is not None else None
606
+ return {
607
+ "id": event.id,
608
+ "seq": event.seq,
609
+ "source": event.source.value if hasattr(event.source, "value") else str(event.source),
610
+ "content": parsed_content,
611
+ "created_at": created_at,
612
+ }
613
+
614
+
615
+ def _ensure_telemetry_context(
616
+ *,
617
+ telemetry_context: OperationDispatchTelemetryContext | None,
618
+ repo_root: Path | None,
619
+ ) -> OperationDispatchTelemetryContext:
620
+ """Return the active handler telemetry context or synthesize one for direct calls."""
621
+
622
+ if telemetry_context is not None:
623
+ return telemetry_context
624
+ inherited = get_operation_telemetry_context()
625
+ if inherited is not None:
626
+ return inherited
627
+ caller_identity_resolution = resolve_caller_identity()
628
+ return OperationDispatchTelemetryContext(
629
+ invocation_id=str(uuid4()),
630
+ repo_root=str((repo_root or Path.cwd()).resolve()),
631
+ no_sync=False,
632
+ caller_identity=caller_identity_resolution.caller_identity,
633
+ caller_identity_error=caller_identity_resolution.error,
634
+ )
635
+
636
+
637
+ def _dump_errors(errors: list[ErrorDetail]) -> list[dict]:
638
+ """Render structured errors into plain dicts for telemetry stage mapping."""
639
+
640
+ return [error.model_dump(mode="python") for error in errors]
641
+
642
+
643
+ def _persist_operation_telemetry_best_effort(
644
+ *,
645
+ command: str,
646
+ uow_factory,
647
+ repo_id: str,
648
+ telemetry_context: OperationDispatchTelemetryContext,
649
+ result: dict,
650
+ error_stage: str | None,
651
+ request=None,
652
+ agent_payload: dict | None = None,
653
+ selection_summary: SessionSelectionSummary | None = None,
654
+ sync_run=None,
655
+ sync_tool_types=(),
656
+ total_latency_ms: int | None = None,
657
+ ) -> None:
658
+ """Persist invocation telemetry in a second best-effort transaction."""
659
+
660
+ try:
661
+ with uow_factory() as telemetry_uow:
662
+ resolved_selection = selection_summary
663
+ if resolved_selection is None:
664
+ resolved_selection = _selection_summary_from_runtime_context(
665
+ caller_identity=telemetry_context.caller_identity,
666
+ repo_id=repo_id,
667
+ repo_root=Path(telemetry_context.repo_root),
668
+ uow=telemetry_uow,
669
+ )
670
+ invocation = build_operation_invocation_record(
671
+ command=command,
672
+ repo_id=repo_id,
673
+ runtime_context=telemetry_context,
674
+ selection_summary=resolved_selection,
675
+ result=result,
676
+ error_stage=error_stage,
677
+ total_latency_ms=total_latency_ms if total_latency_ms is not None else 0,
678
+ )
679
+
680
+ read_summary = None
681
+ read_items = ()
682
+ write_summary = None
683
+ write_items = ()
684
+
685
+ if result.get("status") == "ok" and command == "read" and request is not None:
686
+ pack = result.get("data", {}).get("pack", {})
687
+ if isinstance(pack, dict):
688
+ read_summary, read_items = build_read_summary_records(
689
+ invocation_id=telemetry_context.invocation_id,
690
+ agent_payload=agent_payload or {},
691
+ request=request,
692
+ pack=pack,
693
+ )
694
+
695
+ if result.get("status") == "ok" and command in {"create", "update"} and request is not None:
696
+ planned_side_effects = result.get("data", {}).get("planned_side_effects", [])
697
+ if isinstance(planned_side_effects, list):
698
+ write_summary, write_items = build_write_summary_records(
699
+ invocation_id=telemetry_context.invocation_id,
700
+ command=command,
701
+ request=request,
702
+ planned_side_effects=planned_side_effects,
703
+ )
704
+
705
+ record_operation_telemetry(
706
+ uow=telemetry_uow,
707
+ invocation=invocation,
708
+ read_summary=read_summary,
709
+ read_items=read_items,
710
+ write_summary=write_summary,
711
+ write_items=write_items,
712
+ )
713
+ if sync_run is not None:
714
+ record_episode_sync_telemetry(
715
+ uow=telemetry_uow,
716
+ run=sync_run,
717
+ tool_types=sync_tool_types,
718
+ )
719
+ except Exception:
720
+ return
721
+
722
+
723
+ def _selection_summary_from_events_source(source) -> SessionSelectionSummary:
724
+ """Build telemetry selection summary from one resolved events source."""
725
+
726
+ return SessionSelectionSummary(
727
+ selected_host_app=source.host_app,
728
+ selected_host_session_key=source.host_session_key,
729
+ selected_thread_id=source.canonical_thread_id,
730
+ matching_candidate_count=source.matching_candidate_count,
731
+ selection_ambiguous=source.selection_ambiguous,
732
+ )
733
+
734
+
735
+ def _selection_summary_from_runtime_context(*, caller_identity: CallerIdentity | None, repo_id: str, repo_root: Path, uow) -> SessionSelectionSummary:
736
+ """Build lightweight non-events selection summary from trusted caller identity when present."""
737
+
738
+ if caller_identity is None or caller_identity.trust_level != IdentityTrustLevel.TRUSTED:
739
+ return summarize_runtime_selection(repo_root=repo_root, repo_id=repo_id, uow=uow)
740
+ selected_episode_id = None
741
+ episode = uow.episodes.get_episode_by_thread(repo_id=repo_id, thread_id=caller_identity.canonical_id or "")
742
+ if episode is not None:
743
+ selected_episode_id = episode.id
744
+ return SessionSelectionSummary(
745
+ selected_host_app=caller_identity.host_app,
746
+ selected_host_session_key=caller_identity.host_session_key,
747
+ selected_thread_id=caller_identity.canonical_id,
748
+ selected_episode_id=selected_episode_id,
749
+ matching_candidate_count=1,
750
+ selection_ambiguous=False,
751
+ )
752
+
753
+
754
+ def _build_guidance_payloads(*, uow_factory, repo_id: str, caller_identity: CallerIdentity | None, session_state, strong: bool) -> list[dict]:
755
+ """Build public guidance payloads from telemetry and session state."""
756
+
757
+ if session_state is None:
758
+ return []
759
+ with uow_factory() as guidance_uow:
760
+ decisions = build_pending_utility_guidance(
761
+ repo_id=repo_id,
762
+ caller_identity=caller_identity,
763
+ session_state=session_state,
764
+ telemetry=guidance_uow.telemetry,
765
+ now_iso=datetime.now(timezone.utc).isoformat(),
766
+ strong=strong,
767
+ )
768
+ return [decision.to_payload() for decision in decisions]
769
+
770
+
771
+ def _attach_guidance(result: dict, guidance_payloads: list[dict]) -> None:
772
+ """Attach one or more guidance payloads to a successful result."""
773
+
774
+ data = result.setdefault("data", {})
775
+ if not isinstance(data, dict):
776
+ return
777
+ data["guidance"] = guidance_payloads
778
+
779
+
780
+ def _hydrate_update_request_evidence_from_session_state(*, request, session_state):
781
+ """Auto-fill missing utility evidence refs from session state when possible."""
782
+
783
+ if session_state is None:
784
+ if isinstance(request, MemoryBatchUpdateRequest):
785
+ if any(item.update.evidence_refs for item in request.updates):
786
+ return request, []
787
+ return request, _missing_events_evidence_errors(request)
788
+ if request.update.type != "utility_vote" or request.update.evidence_refs:
789
+ return request, []
790
+ return request, _missing_events_evidence_errors(request)
791
+
792
+ if isinstance(request, MemoryBatchUpdateRequest):
793
+ if any(item.update.evidence_refs for item in request.updates):
794
+ return request, []
795
+ if not session_state.last_events_event_ids:
796
+ return request, _missing_events_evidence_errors(request)
797
+ payload = request.model_dump(mode="python")
798
+ for item in payload["updates"]:
799
+ item["update"]["evidence_refs"] = list(session_state.last_events_event_ids)
800
+ return MemoryBatchUpdateRequest.model_validate(payload), []
801
+
802
+ if request.update.type != "utility_vote" or request.update.evidence_refs:
803
+ return request, []
804
+ if not session_state.last_events_event_ids:
805
+ return request, _missing_events_evidence_errors(request)
806
+ payload = request.model_dump(mode="python")
807
+ payload["update"]["evidence_refs"] = list(session_state.last_events_event_ids)
808
+ return MemoryUpdateRequest.model_validate(payload), []
809
+
810
+
811
+ def _missing_events_evidence_errors(request) -> list[ErrorDetail]:
812
+ """Return the canonical semantic error when utility evidence cannot be auto-filled."""
813
+
814
+ if isinstance(request, MemoryBatchUpdateRequest):
815
+ return [
816
+ ErrorDetail(
817
+ code=ErrorCode.SEMANTIC_ERROR,
818
+ message="Batch utility votes require recent episode evidence; run `events` first.",
819
+ field="updates",
820
+ )
821
+ ]
822
+ if getattr(request.update, "type", None) == "utility_vote":
823
+ return [
824
+ ErrorDetail(
825
+ code=ErrorCode.SEMANTIC_ERROR,
826
+ message="utility_vote requires recent episode evidence; run `events` first.",
827
+ field="update.evidence_refs",
828
+ )
829
+ ]
830
+ return []