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,32 @@
1
+ """Codex runtime identity helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+
9
+ from app.core.entities.identity import CallerIdentity, IdentityTrustLevel
10
+ from app.periphery.episodes.codex import resolve_codex_transcript_path
11
+
12
+
13
+ def resolve_codex_caller_identity() -> CallerIdentity | None:
14
+ """Resolve one trusted Codex caller from the runtime environment when present."""
15
+
16
+ thread_id = os.getenv("CODEX_THREAD_ID")
17
+ if not thread_id:
18
+ return None
19
+ return CallerIdentity(
20
+ host_app="codex",
21
+ host_session_key=thread_id,
22
+ trust_level=IdentityTrustLevel.TRUSTED,
23
+ )
24
+
25
+
26
+ def resolve_codex_transcript_for_caller(*, caller_identity: CallerIdentity, search_roots: Sequence[Path]) -> Path:
27
+ """Resolve the Codex transcript path for one trusted caller."""
28
+
29
+ return resolve_codex_transcript_path(
30
+ host_session_key=caller_identity.host_session_key,
31
+ search_roots=list(search_roots),
32
+ )
@@ -0,0 +1,38 @@
1
+ """Compatibility-safe caller-identity errors with concrete remediation guidance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.core.contracts.errors import ErrorCode, ErrorDetail
6
+
7
+
8
+ def host_hook_missing_error() -> ErrorDetail:
9
+ """Return the canonical error for missing Claude hook identity injection."""
10
+
11
+ return ErrorDetail(
12
+ code=ErrorCode.HOST_HOOK_MISSING,
13
+ message="Claude Code runtime detected but Shellbrain hook identity is missing. Run `shellbrain admin install-claude-hook` in this repo and restart Claude Code.",
14
+ )
15
+
16
+
17
+ def host_identity_unsupported_error() -> ErrorDetail:
18
+ """Return the canonical error for unsupported host identity layouts."""
19
+
20
+ return ErrorDetail(
21
+ code=ErrorCode.HOST_IDENTITY_UNSUPPORTED,
22
+ message="This host runtime does not expose enough identity data for Shellbrain to isolate the caller safely.",
23
+ )
24
+
25
+
26
+ def host_identity_drifted_error(*, caller_id: str) -> ErrorDetail:
27
+ """Return the canonical error for trusted identities that can no longer be resolved."""
28
+
29
+ return ErrorDetail(
30
+ code=ErrorCode.HOST_IDENTITY_DRIFTED,
31
+ message=f"Trusted caller identity drifted and could not be resolved for `{caller_id}`. Verify the host thread/session still exists and rerun `events`.",
32
+ )
33
+
34
+
35
+ def transcript_source_not_found_error(*, message: str) -> ErrorDetail:
36
+ """Return the canonical error for transcript-source resolution failures."""
37
+
38
+ return ErrorDetail(code=ErrorCode.TRANSCRIPT_SOURCE_NOT_FOUND, message=message)
@@ -0,0 +1,163 @@
1
+ """Resolve caller identity and exact event sources across supported hosts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+
9
+ from app.core.contracts.errors import ErrorDetail
10
+ from app.core.entities.identity import CallerIdentity, IdentityTrustLevel
11
+ from app.periphery.episodes.source_discovery import SUPPORTED_HOSTS, default_search_roots
12
+ from app.periphery.identity.claude_runtime import (
13
+ detect_claude_runtime_without_hook,
14
+ resolve_trusted_claude_caller_identity,
15
+ resolve_trusted_claude_transcript_path,
16
+ )
17
+ from app.periphery.identity.codex_runtime import (
18
+ resolve_codex_caller_identity,
19
+ resolve_codex_transcript_for_caller,
20
+ )
21
+ from app.periphery.identity.compatibility import (
22
+ host_hook_missing_error,
23
+ host_identity_drifted_error,
24
+ host_identity_unsupported_error,
25
+ )
26
+ from app.periphery.telemetry.session_selection import discover_events_candidate
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CallerIdentityResolution:
31
+ """Resolved caller identity plus any compatibility error and trusted path hint."""
32
+
33
+ caller_identity: CallerIdentity | None
34
+ transcript_path_hint: Path | None = None
35
+ error: ErrorDetail | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ResolvedEventsSource:
40
+ """Resolved exact or fallback events source for one operation."""
41
+
42
+ caller_identity: CallerIdentity | None
43
+ host_app: str | None = None
44
+ host_session_key: str | None = None
45
+ canonical_thread_id: str | None = None
46
+ transcript_path: Path | None = None
47
+ search_roots: tuple[Path, ...] = ()
48
+ matching_candidate_count: int = 0
49
+ selection_ambiguous: bool = False
50
+ trusted: bool = False
51
+ error: ErrorDetail | None = None
52
+
53
+
54
+ def resolve_caller_identity() -> CallerIdentityResolution:
55
+ """Resolve caller identity from trusted runtimes before falling back to none."""
56
+
57
+ codex_identity = resolve_codex_caller_identity()
58
+ if codex_identity is not None:
59
+ return CallerIdentityResolution(caller_identity=codex_identity)
60
+
61
+ claude_identity = resolve_trusted_claude_caller_identity()
62
+ if claude_identity is not None:
63
+ if claude_identity.trust_level == IdentityTrustLevel.UNSUPPORTED:
64
+ return CallerIdentityResolution(caller_identity=None, error=host_identity_unsupported_error())
65
+ return CallerIdentityResolution(
66
+ caller_identity=claude_identity,
67
+ transcript_path_hint=resolve_trusted_claude_transcript_path(),
68
+ )
69
+
70
+ if detect_claude_runtime_without_hook():
71
+ return CallerIdentityResolution(caller_identity=None, error=host_hook_missing_error())
72
+ return CallerIdentityResolution(caller_identity=None)
73
+
74
+
75
+ def resolve_trusted_events_source(
76
+ *,
77
+ caller_identity: CallerIdentity,
78
+ repo_root: Path,
79
+ search_roots_by_host: dict[str, list[Path]] | None = None,
80
+ ) -> ResolvedEventsSource:
81
+ """Resolve the exact transcript source for one trusted caller."""
82
+
83
+ search_roots = _search_roots_for_host(
84
+ repo_root=repo_root,
85
+ host_app=caller_identity.host_app,
86
+ search_roots_by_host=search_roots_by_host,
87
+ )
88
+ try:
89
+ if caller_identity.host_app == "codex":
90
+ transcript_path = resolve_codex_transcript_for_caller(
91
+ caller_identity=caller_identity,
92
+ search_roots=search_roots,
93
+ )
94
+ elif caller_identity.host_app == "claude_code":
95
+ transcript_path = resolve_trusted_claude_transcript_path()
96
+ if transcript_path is None:
97
+ return ResolvedEventsSource(
98
+ caller_identity=caller_identity,
99
+ error=host_identity_drifted_error(caller_id=caller_identity.canonical_id or ""),
100
+ )
101
+ else:
102
+ return ResolvedEventsSource(caller_identity=caller_identity, error=host_identity_unsupported_error())
103
+ except FileNotFoundError:
104
+ return ResolvedEventsSource(
105
+ caller_identity=caller_identity,
106
+ error=host_identity_drifted_error(caller_id=caller_identity.canonical_id or ""),
107
+ )
108
+
109
+ return ResolvedEventsSource(
110
+ caller_identity=caller_identity,
111
+ host_app=caller_identity.host_app,
112
+ host_session_key=caller_identity.host_session_key,
113
+ canonical_thread_id=caller_identity.canonical_id,
114
+ transcript_path=transcript_path,
115
+ search_roots=tuple(search_roots),
116
+ matching_candidate_count=1,
117
+ selection_ambiguous=False,
118
+ trusted=True,
119
+ )
120
+
121
+
122
+ def discover_untrusted_events_candidate(
123
+ *,
124
+ repo_root: Path,
125
+ search_roots_by_host: dict[str, list[Path]] | None = None,
126
+ ) -> ResolvedEventsSource | None:
127
+ """Discover the newest repo-matching host session as an untrusted fallback."""
128
+
129
+ discovery = discover_events_candidate(repo_root=repo_root, search_roots_by_host=search_roots_by_host)
130
+ if discovery is None:
131
+ return None
132
+ caller_identity = CallerIdentity(
133
+ host_app=discovery.host_app,
134
+ host_session_key=discovery.host_session_key,
135
+ canonical_id=discovery.summary.selected_thread_id,
136
+ trust_level=IdentityTrustLevel.UNTRUSTED,
137
+ )
138
+ return ResolvedEventsSource(
139
+ caller_identity=caller_identity,
140
+ host_app=discovery.host_app,
141
+ host_session_key=discovery.host_session_key,
142
+ canonical_thread_id=discovery.summary.selected_thread_id,
143
+ transcript_path=discovery.transcript_path,
144
+ search_roots=tuple(discovery.search_roots),
145
+ matching_candidate_count=discovery.summary.matching_candidate_count,
146
+ selection_ambiguous=discovery.summary.selection_ambiguous,
147
+ trusted=False,
148
+ )
149
+
150
+
151
+ def _search_roots_for_host(
152
+ *,
153
+ repo_root: Path,
154
+ host_app: str,
155
+ search_roots_by_host: dict[str, list[Path]] | None,
156
+ ) -> list[Path]:
157
+ """Resolve bounded search roots for one host with optional test overrides."""
158
+
159
+ if search_roots_by_host is not None:
160
+ return [Path(path) for path in search_roots_by_host.get(host_app, [])]
161
+ if host_app not in SUPPORTED_HOSTS:
162
+ return [repo_root]
163
+ return default_search_roots(repo_root=repo_root, host_app=host_app)
@@ -0,0 +1 @@
1
+ """Repo-local per-caller session-state storage."""
@@ -0,0 +1,100 @@
1
+ """JSON file-backed implementation of repo-local per-caller session state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from datetime import datetime, timezone
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from tempfile import NamedTemporaryFile
11
+
12
+ from app.core.entities.session_state import SessionState
13
+ from app.core.interfaces.session_state_store import ISessionStateStore
14
+
15
+
16
+ class FileSessionStateStore(ISessionStateStore):
17
+ """Persist trusted per-caller session state under one repo-local runtime directory."""
18
+
19
+ def load(self, *, repo_root: Path, caller_id: str) -> SessionState | None:
20
+ """Load one caller state when the corresponding file exists."""
21
+
22
+ path = self._path_for(repo_root=repo_root, caller_id=caller_id)
23
+ try:
24
+ payload = json.loads(path.read_text(encoding="utf-8"))
25
+ except (FileNotFoundError, json.JSONDecodeError):
26
+ return None
27
+ if not isinstance(payload, dict):
28
+ return None
29
+ return SessionState(**payload)
30
+
31
+ def save(self, *, repo_root: Path, state: SessionState) -> None:
32
+ """Persist one caller state under its canonical caller id."""
33
+
34
+ path = self._path_for(repo_root=repo_root, caller_id=state.caller_id, host_app=state.host_app)
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ payload = json.dumps(asdict(state), indent=2, sort_keys=True)
37
+ with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
38
+ handle.write(payload)
39
+ temp_path = Path(handle.name)
40
+ os.replace(temp_path, path)
41
+
42
+ def delete(self, *, repo_root: Path, caller_id: str) -> None:
43
+ """Delete one caller state when its file exists."""
44
+
45
+ path = self._path_for(repo_root=repo_root, caller_id=caller_id)
46
+ try:
47
+ path.unlink()
48
+ except FileNotFoundError:
49
+ return
50
+
51
+ def list(self, *, repo_root: Path) -> list[SessionState]:
52
+ """Return every parseable caller state stored for the repo root."""
53
+
54
+ session_root = Path(repo_root).resolve() / ".shellbrain" / "session_state"
55
+ if not session_root.exists():
56
+ return []
57
+ states: list[SessionState] = []
58
+ for path in session_root.rglob("*.json"):
59
+ try:
60
+ payload = json.loads(path.read_text(encoding="utf-8"))
61
+ except (FileNotFoundError, json.JSONDecodeError):
62
+ continue
63
+ if not isinstance(payload, dict):
64
+ continue
65
+ states.append(SessionState(**payload))
66
+ return states
67
+
68
+ def gc(self, *, repo_root: Path, older_than_iso: str) -> list[str]:
69
+ """Delete caller states last seen before the given cutoff."""
70
+
71
+ cutoff = _parse_iso(older_than_iso)
72
+ deleted: list[str] = []
73
+ for state in self.list(repo_root=repo_root):
74
+ try:
75
+ last_seen = _parse_iso(state.last_seen_at)
76
+ except ValueError:
77
+ last_seen = datetime.min.replace(tzinfo=timezone.utc)
78
+ if last_seen >= cutoff:
79
+ continue
80
+ self.delete(repo_root=repo_root, caller_id=state.caller_id)
81
+ deleted.append(state.caller_id)
82
+ return deleted
83
+
84
+ def _path_for(self, *, repo_root: Path, caller_id: str, host_app: str | None = None) -> Path:
85
+ """Return the storage path for one caller id."""
86
+
87
+ repo_root = Path(repo_root).resolve()
88
+ if host_app is None:
89
+ host_app = caller_id.split(":", 1)[0]
90
+ filename = f"{caller_id.replace(':', '__')}.json"
91
+ return repo_root / ".shellbrain" / "session_state" / host_app / filename
92
+
93
+
94
+ def _parse_iso(value: str) -> datetime:
95
+ """Parse one ISO timestamp into a timezone-aware datetime."""
96
+
97
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
98
+ if parsed.tzinfo is None:
99
+ return parsed.replace(tzinfo=timezone.utc)
100
+ return parsed.astimezone(timezone.utc)
@@ -0,0 +1,33 @@
1
+ """Internal runtime context helpers for per-command telemetry capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar, Token
6
+
7
+ from app.core.entities.runtime_context import RuntimeContext
8
+
9
+
10
+ _OPERATION_TELEMETRY_CONTEXT: ContextVar[RuntimeContext | None] = ContextVar(
11
+ "shellbrain_operation_telemetry_context",
12
+ default=None,
13
+ )
14
+
15
+
16
+ def get_operation_telemetry_context() -> RuntimeContext | None:
17
+ """Return the current command-level telemetry context when one exists."""
18
+
19
+ return _OPERATION_TELEMETRY_CONTEXT.get()
20
+
21
+
22
+ def set_operation_telemetry_context(
23
+ context: RuntimeContext,
24
+ ) -> Token[RuntimeContext | None]:
25
+ """Install one command-level telemetry context for the current execution flow."""
26
+
27
+ return _OPERATION_TELEMETRY_CONTEXT.set(context)
28
+
29
+
30
+ def reset_operation_telemetry_context(token: Token[RuntimeContext | None]) -> None:
31
+ """Restore the previous telemetry context after a command finishes."""
32
+
33
+ _OPERATION_TELEMETRY_CONTEXT.reset(token)
@@ -0,0 +1,299 @@
1
+ """Helpers for assembling operation telemetry records from handler inputs and outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from app.core.contracts.errors import ErrorCode
9
+ from app.core.contracts.requests import MemoryBatchUpdateRequest, MemoryCreateRequest, MemoryReadRequest, MemoryUpdateRequest
10
+ from app.core.entities.runtime_context import RuntimeContext
11
+ from app.core.entities.telemetry import (
12
+ OperationInvocationRecord,
13
+ ReadResultItemRecord,
14
+ ReadSummaryRecord,
15
+ SessionSelectionSummary,
16
+ WriteEffectItemRecord,
17
+ WriteSummaryRecord,
18
+ )
19
+
20
+
21
+ def build_operation_invocation_record(
22
+ *,
23
+ command: str,
24
+ repo_id: str,
25
+ runtime_context: RuntimeContext,
26
+ selection_summary: SessionSelectionSummary,
27
+ result: dict[str, Any],
28
+ error_stage: str | None,
29
+ total_latency_ms: int,
30
+ ) -> OperationInvocationRecord:
31
+ """Build the parent invocation row from one finished handler result."""
32
+
33
+ first_error = _first_error(result)
34
+ caller_identity = runtime_context.caller_identity
35
+ guidance_codes = _guidance_codes(result)
36
+ return OperationInvocationRecord(
37
+ id=runtime_context.invocation_id,
38
+ command=command,
39
+ repo_id=repo_id,
40
+ repo_root=runtime_context.repo_root,
41
+ no_sync=runtime_context.no_sync,
42
+ caller_id=caller_identity.canonical_id if caller_identity is not None else None,
43
+ caller_trust_level=caller_identity.trust_level.value if caller_identity is not None else None,
44
+ identity_failure_code=runtime_context.caller_identity_error.code.value
45
+ if runtime_context.caller_identity_error is not None
46
+ else None,
47
+ selected_host_app=selection_summary.selected_host_app,
48
+ selected_host_session_key=selection_summary.selected_host_session_key,
49
+ selected_thread_id=selection_summary.selected_thread_id,
50
+ selected_episode_id=selection_summary.selected_episode_id,
51
+ matching_candidate_count=selection_summary.matching_candidate_count,
52
+ selection_ambiguous=selection_summary.selection_ambiguous,
53
+ outcome=str(result.get("status") or "error"),
54
+ error_stage=error_stage,
55
+ error_code=first_error["code"],
56
+ error_message=first_error["message"],
57
+ total_latency_ms=total_latency_ms,
58
+ poller_start_attempted=False,
59
+ poller_started=False,
60
+ guidance_codes=guidance_codes,
61
+ created_at=datetime.now(timezone.utc),
62
+ )
63
+
64
+
65
+ def build_read_summary_records(
66
+ *,
67
+ invocation_id: str,
68
+ agent_payload: dict[str, Any],
69
+ request: MemoryReadRequest,
70
+ pack: dict[str, Any],
71
+ ) -> tuple[ReadSummaryRecord, list[ReadResultItemRecord]]:
72
+ """Build one read summary row and one item row per displayed memory."""
73
+
74
+ direct = list(pack.get("direct", []))
75
+ explicit_related = list(pack.get("explicit_related", []))
76
+ implicit_related = list(pack.get("implicit_related", []))
77
+ items: list[ReadResultItemRecord] = []
78
+ ordinal = 1
79
+ for section_name, bucket in (
80
+ ("direct", direct),
81
+ ("explicit_related", explicit_related),
82
+ ("implicit_related", implicit_related),
83
+ ):
84
+ for item in bucket:
85
+ items.append(
86
+ ReadResultItemRecord(
87
+ invocation_id=invocation_id,
88
+ ordinal=ordinal,
89
+ memory_id=str(item["memory_id"]),
90
+ kind=str(item["kind"]),
91
+ section=section_name,
92
+ priority=ordinal,
93
+ why_included=str(item.get("why_included") or ""),
94
+ anchor_memory_id=_optional_string(item.get("anchor_memory_id")),
95
+ relation_type=_optional_string(item.get("relation_type")),
96
+ )
97
+ )
98
+ ordinal += 1
99
+
100
+ summary = ReadSummaryRecord(
101
+ invocation_id=invocation_id,
102
+ query_text=request.query,
103
+ mode=request.mode,
104
+ requested_limit=agent_payload.get("limit") if isinstance(agent_payload.get("limit"), int) else None,
105
+ effective_limit=int(request.limit or len(items) or 0),
106
+ include_global=request.include_global,
107
+ kinds_filter=list(request.kinds) if request.kinds is not None else None,
108
+ direct_count=len(direct),
109
+ explicit_related_count=len(explicit_related),
110
+ implicit_related_count=len(implicit_related),
111
+ total_returned=len(items),
112
+ zero_results=len(items) == 0,
113
+ created_at=datetime.now(timezone.utc),
114
+ )
115
+ return summary, items
116
+
117
+
118
+ def build_write_summary_records(
119
+ *,
120
+ invocation_id: str,
121
+ command: str,
122
+ request: MemoryCreateRequest | MemoryUpdateRequest | MemoryBatchUpdateRequest,
123
+ planned_side_effects: list[dict[str, Any]],
124
+ ) -> tuple[WriteSummaryRecord, list[WriteEffectItemRecord]]:
125
+ """Build one write summary row and one compact effect row per planned side effect."""
126
+
127
+ created_memory_count = 0
128
+ archived_memory_count = 0
129
+ utility_observation_count = 0
130
+ association_effect_count = 0
131
+ fact_update_count = 0
132
+ effect_items: list[WriteEffectItemRecord] = []
133
+
134
+ for ordinal, effect in enumerate(planned_side_effects, start=1):
135
+ effect_type = str(effect["effect_type"])
136
+ params = effect["params"]
137
+ assert isinstance(params, dict)
138
+ if effect_type == "memory.create":
139
+ created_memory_count += 1
140
+ elif effect_type == "memory.archive_state" and bool(params.get("archived")):
141
+ archived_memory_count += 1
142
+ elif effect_type == "utility_observation.append":
143
+ utility_observation_count += 1
144
+ elif effect_type == "association.upsert_and_observe":
145
+ association_effect_count += 1
146
+ elif effect_type == "fact_update.create":
147
+ fact_update_count += 1
148
+
149
+ effect_items.append(
150
+ WriteEffectItemRecord(
151
+ invocation_id=invocation_id,
152
+ ordinal=ordinal,
153
+ effect_type=effect_type,
154
+ repo_id=str(getattr(request, "repo_id")),
155
+ primary_memory_id=_primary_memory_id(params),
156
+ secondary_memory_id=_secondary_memory_id(params),
157
+ params_json=_compact_effect_params(params),
158
+ )
159
+ )
160
+
161
+ if isinstance(request, MemoryCreateRequest):
162
+ evidence_ref_count = len(request.memory.evidence_refs)
163
+ target_memory_id = _target_memory_id_from_create(planned_side_effects)
164
+ target_kind = request.memory.kind
165
+ update_type = None
166
+ scope = request.memory.scope
167
+ elif isinstance(request, MemoryBatchUpdateRequest):
168
+ evidence_ref_count = sum(len(item.update.evidence_refs or []) for item in request.updates)
169
+ target_memory_id = request.updates[0].update.problem_id
170
+ target_kind = None
171
+ update_type = "utility_vote_batch"
172
+ scope = None
173
+ else:
174
+ evidence_ref_count = len(getattr(request.update, "evidence_refs", []) or [])
175
+ target_memory_id = request.memory_id
176
+ target_kind = None
177
+ update_type = request.update.type
178
+ scope = None
179
+
180
+ summary = WriteSummaryRecord(
181
+ invocation_id=invocation_id,
182
+ operation_command=command,
183
+ target_memory_id=target_memory_id,
184
+ target_kind=target_kind,
185
+ update_type=update_type,
186
+ scope=scope,
187
+ evidence_ref_count=evidence_ref_count,
188
+ planned_effect_count=len(planned_side_effects),
189
+ created_memory_count=created_memory_count,
190
+ archived_memory_count=archived_memory_count,
191
+ utility_observation_count=utility_observation_count,
192
+ association_effect_count=association_effect_count,
193
+ fact_update_count=fact_update_count,
194
+ created_at=datetime.now(timezone.utc),
195
+ )
196
+ return summary, effect_items
197
+
198
+
199
+ def infer_error_stage_from_errors(errors: list[dict[str, Any]], *, default_stage: str) -> str:
200
+ """Map structured error codes to telemetry stages when validation failed."""
201
+
202
+ if not errors:
203
+ return default_stage
204
+ code = errors[0].get("code")
205
+ normalized = code.value if isinstance(code, ErrorCode) else str(code)
206
+ if normalized == ErrorCode.SCHEMA_ERROR.value and default_stage == "schema_validation":
207
+ return "schema_validation"
208
+ if normalized == ErrorCode.SCHEMA_ERROR.value and default_stage == "contract_validation":
209
+ return "contract_validation"
210
+ if normalized == ErrorCode.SEMANTIC_ERROR.value:
211
+ return "semantic_validation"
212
+ if normalized == ErrorCode.INTEGRITY_ERROR.value:
213
+ return "integrity_validation"
214
+ return default_stage
215
+
216
+
217
+ def _first_error(result: dict[str, Any]) -> dict[str, str | None]:
218
+ """Return the first response error in a stable telemetry-friendly shape."""
219
+
220
+ errors = result.get("errors")
221
+ if not isinstance(errors, list) or not errors:
222
+ return {"code": None, "message": None}
223
+ first = errors[0]
224
+ if not isinstance(first, dict):
225
+ return {"code": None, "message": str(first)}
226
+ code = first.get("code")
227
+ normalized_code = code.value if isinstance(code, ErrorCode) else (str(code) if code is not None else None)
228
+ message = first.get("message")
229
+ return {
230
+ "code": normalized_code,
231
+ "message": str(message) if message is not None else None,
232
+ }
233
+
234
+
235
+ def _guidance_codes(result: dict[str, Any]) -> list[str]:
236
+ """Return stable guidance codes from one successful result."""
237
+
238
+ data = result.get("data")
239
+ if not isinstance(data, dict):
240
+ return []
241
+ guidance = data.get("guidance")
242
+ if not isinstance(guidance, list):
243
+ return []
244
+ codes: list[str] = []
245
+ for item in guidance:
246
+ if not isinstance(item, dict):
247
+ continue
248
+ code = item.get("code")
249
+ if isinstance(code, str):
250
+ codes.append(code)
251
+ return codes
252
+
253
+
254
+ def _target_memory_id_from_create(planned_side_effects: list[dict[str, Any]]) -> str:
255
+ """Extract the created memory id from one create side-effect plan."""
256
+
257
+ for effect in planned_side_effects:
258
+ if str(effect.get("effect_type")) != "memory.create":
259
+ continue
260
+ params = effect.get("params")
261
+ if isinstance(params, dict) and isinstance(params.get("memory_id"), str):
262
+ return str(params["memory_id"])
263
+ raise ValueError("Create telemetry expected one memory.create side effect.")
264
+
265
+
266
+ def _compact_effect_params(params: dict[str, Any]) -> dict[str, Any]:
267
+ """Drop bulky fields so telemetry stores compact, queryable side-effect metadata."""
268
+
269
+ return {
270
+ str(key): value
271
+ for key, value in params.items()
272
+ if key not in {"text", "vector"}
273
+ }
274
+
275
+
276
+ def _primary_memory_id(params: dict[str, Any]) -> str | None:
277
+ """Extract the primary memory identifier from one side-effect payload when present."""
278
+
279
+ for key in ("memory_id", "from_memory_id", "old_fact_id", "problem_id", "change_id"):
280
+ value = params.get(key)
281
+ if isinstance(value, str):
282
+ return value
283
+ return None
284
+
285
+
286
+ def _secondary_memory_id(params: dict[str, Any]) -> str | None:
287
+ """Extract the secondary memory identifier from one side-effect payload when present."""
288
+
289
+ for key in ("to_memory_id", "new_fact_id", "attempt_id"):
290
+ value = params.get(key)
291
+ if isinstance(value, str):
292
+ return value
293
+ return None
294
+
295
+
296
+ def _optional_string(value: object) -> str | None:
297
+ """Return a string value or None when the field is absent."""
298
+
299
+ return str(value) if isinstance(value, str) else None