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,299 @@
1
+ """Repo-local background loop for episodic transcript sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+ from time import perf_counter
12
+ from uuid import uuid4
13
+
14
+ from app.boot.use_cases import get_uow_factory
15
+ from app.core.use_cases.record_episode_sync_telemetry import record_episode_sync_telemetry
16
+ from app.core.use_cases.sync_episode import sync_episode_from_host
17
+ from app.periphery.episodes.source_discovery import (
18
+ SUPPORTED_HOSTS,
19
+ default_search_roots,
20
+ discover_active_host_session,
21
+ resolve_host_transcript_source,
22
+ )
23
+ from app.periphery.telemetry.sync_summary import build_episode_sync_records
24
+
25
+
26
+ POLL_INTERVAL_SECONDS = 5
27
+ IDLE_EXIT_SECONDS = 15 * 60
28
+
29
+
30
+ @dataclass
31
+ class _HostState:
32
+ """In-shellbrain state for one tracked host while the poller is alive."""
33
+
34
+ session_key: str
35
+ transcript_path: Path
36
+ last_mtime: float
37
+
38
+
39
+ def main() -> int:
40
+ """Run the repo-local background episode sync loop."""
41
+
42
+ parser = argparse.ArgumentParser(prog="shellbrain-episode-poller")
43
+ parser.add_argument("--repo-id", required=True)
44
+ parser.add_argument("--repo-root", required=True)
45
+ args = parser.parse_args()
46
+
47
+ run_episode_poller(repo_id=args.repo_id, repo_root=Path(args.repo_root))
48
+ return 0
49
+
50
+
51
+ def run_episode_poller(*, repo_id: str, repo_root: Path) -> None:
52
+ """Run until the repo appears idle for long enough."""
53
+
54
+ repo_root = repo_root.resolve()
55
+ known_state: dict[str, _HostState] = {}
56
+ last_change_at = time.monotonic()
57
+ uow_factory = get_uow_factory()
58
+
59
+ while True:
60
+ saw_change = False
61
+ for host_app in SUPPORTED_HOSTS:
62
+ search_roots = default_search_roots(repo_root=repo_root, host_app=host_app)
63
+ candidate = discover_active_host_session(
64
+ host_app=host_app,
65
+ repo_root=repo_root,
66
+ search_roots=search_roots,
67
+ )
68
+
69
+ if candidate is None:
70
+ if host_app in known_state:
71
+ _record_missing_source(
72
+ repo_root=repo_root,
73
+ host_app=host_app,
74
+ host_session_key=known_state[host_app].session_key,
75
+ search_roots=search_roots,
76
+ last_known_path=known_state[host_app].transcript_path,
77
+ )
78
+ continue
79
+
80
+ transcript_path = Path(candidate["transcript_path"])
81
+ state = known_state.get(host_app)
82
+ session_changed = state is not None and state.session_key != candidate["host_session_key"]
83
+ if session_changed:
84
+ _close_episode(
85
+ repo_id=repo_id,
86
+ host_app=host_app,
87
+ host_session_key=state.session_key,
88
+ uow_factory=uow_factory,
89
+ )
90
+
91
+ mtime = transcript_path.stat().st_mtime if transcript_path.exists() else 0.0
92
+ should_sync = state is None or session_changed or state.last_mtime != mtime
93
+ known_state[host_app] = _HostState(
94
+ session_key=str(candidate["host_session_key"]),
95
+ transcript_path=transcript_path,
96
+ last_mtime=mtime,
97
+ )
98
+ if not should_sync:
99
+ continue
100
+
101
+ sync_started_at = perf_counter()
102
+ try:
103
+ with uow_factory() as uow:
104
+ sync_result = sync_episode_from_host(
105
+ repo_id=repo_id,
106
+ host_app=host_app,
107
+ host_session_key=str(candidate["host_session_key"]),
108
+ uow=uow,
109
+ search_roots=search_roots,
110
+ last_known_path=transcript_path,
111
+ )
112
+ _record_status(
113
+ repo_root=repo_root,
114
+ host_app=host_app,
115
+ host_session_key=str(candidate["host_session_key"]),
116
+ last_successful_sync_at=_utc_now().isoformat(),
117
+ last_error=None,
118
+ )
119
+ _record_sync_telemetry_best_effort(
120
+ uow_factory=uow_factory,
121
+ repo_id=repo_id,
122
+ host_app=host_app,
123
+ host_session_key=str(candidate["host_session_key"]),
124
+ thread_id=str(sync_result["thread_id"]),
125
+ episode_id=str(sync_result["episode_id"]),
126
+ transcript_path=str(sync_result["transcript_path"]),
127
+ outcome="ok",
128
+ error_stage=None,
129
+ error_message=None,
130
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
131
+ imported_event_count=int(sync_result["imported_event_count"]),
132
+ total_event_count=int(sync_result["total_event_count"]),
133
+ user_event_count=int(sync_result["user_event_count"]),
134
+ assistant_event_count=int(sync_result["assistant_event_count"]),
135
+ tool_event_count=int(sync_result["tool_event_count"]),
136
+ system_event_count=int(sync_result["system_event_count"]),
137
+ tool_type_counts=dict(sync_result["tool_type_counts"]),
138
+ )
139
+ saw_change = True
140
+ except Exception as exc:
141
+ _record_status(
142
+ repo_root=repo_root,
143
+ host_app=host_app,
144
+ host_session_key=str(candidate["host_session_key"]),
145
+ last_successful_sync_at=None,
146
+ last_error=str(exc),
147
+ )
148
+ _record_sync_telemetry_best_effort(
149
+ uow_factory=uow_factory,
150
+ repo_id=repo_id,
151
+ host_app=host_app,
152
+ host_session_key=str(candidate["host_session_key"]),
153
+ thread_id=f"{host_app}:{candidate['host_session_key']}",
154
+ episode_id=None,
155
+ transcript_path=str(transcript_path),
156
+ outcome="error",
157
+ error_stage="sync",
158
+ error_message=str(exc),
159
+ duration_ms=int((perf_counter() - sync_started_at) * 1000),
160
+ imported_event_count=0,
161
+ total_event_count=0,
162
+ user_event_count=0,
163
+ assistant_event_count=0,
164
+ tool_event_count=0,
165
+ system_event_count=0,
166
+ tool_type_counts={},
167
+ )
168
+
169
+ if saw_change:
170
+ last_change_at = time.monotonic()
171
+ elif time.monotonic() - last_change_at >= IDLE_EXIT_SECONDS:
172
+ break
173
+
174
+ time.sleep(POLL_INTERVAL_SECONDS)
175
+ def _close_episode(*, repo_id: str, host_app: str, host_session_key: str, uow_factory) -> None:
176
+ """Close one active episode when a newer session replaces it."""
177
+
178
+ canonical_thread_id = f"{host_app}:{host_session_key}"
179
+ with uow_factory() as uow:
180
+ episode = uow.episodes.get_episode_by_thread(repo_id=repo_id, thread_id=canonical_thread_id)
181
+ if episode is None:
182
+ return
183
+ uow.episodes.close_episode(episode_id=episode.id, ended_at=_utc_now())
184
+
185
+
186
+ def _record_missing_source(
187
+ *,
188
+ repo_root: Path,
189
+ host_app: str,
190
+ host_session_key: str,
191
+ search_roots: list[Path],
192
+ last_known_path: Path,
193
+ ) -> None:
194
+ """Persist a clear health record when one previously-known source disappears."""
195
+
196
+ try:
197
+ resolve_host_transcript_source(
198
+ host_app=host_app,
199
+ host_session_key=host_session_key,
200
+ search_roots=search_roots,
201
+ last_known_path=last_known_path,
202
+ )
203
+ except FileNotFoundError as exc:
204
+ _record_status(
205
+ repo_root=repo_root,
206
+ host_app=host_app,
207
+ host_session_key=host_session_key,
208
+ last_successful_sync_at=None,
209
+ last_error=str(exc),
210
+ )
211
+
212
+
213
+ def _record_status(
214
+ *,
215
+ repo_root: Path,
216
+ host_app: str,
217
+ host_session_key: str,
218
+ last_successful_sync_at: str | None,
219
+ last_error: str | None,
220
+ ) -> None:
221
+ """Write one small repo-local health/status file."""
222
+
223
+ runtime_dir = repo_root / ".shellbrain"
224
+ runtime_dir.mkdir(parents=True, exist_ok=True)
225
+ status_path = runtime_dir / "episode_sync_status.json"
226
+ try:
227
+ status = json.loads(status_path.read_text(encoding="utf-8"))
228
+ except (FileNotFoundError, json.JSONDecodeError):
229
+ status = {"repo_root": str(repo_root), "hosts": {}}
230
+
231
+ hosts = status.setdefault("hosts", {})
232
+ host_status = hosts.setdefault(host_app, {})
233
+ host_status["current_session_key"] = host_session_key
234
+ if last_successful_sync_at is not None:
235
+ host_status["last_successful_sync_at"] = last_successful_sync_at
236
+ host_status["last_error"] = last_error
237
+ status_path.write_text(json.dumps(status, indent=2, sort_keys=True), encoding="utf-8")
238
+
239
+
240
+ def _utc_now() -> datetime:
241
+ """Return a timezone-aware current UTC time."""
242
+
243
+ return datetime.now(timezone.utc)
244
+
245
+
246
+ def _record_sync_telemetry_best_effort(
247
+ *,
248
+ uow_factory,
249
+ repo_id: str,
250
+ host_app: str,
251
+ host_session_key: str,
252
+ thread_id: str,
253
+ episode_id: str | None,
254
+ transcript_path: str | None,
255
+ outcome: str,
256
+ error_stage: str | None,
257
+ error_message: str | None,
258
+ duration_ms: int,
259
+ imported_event_count: int,
260
+ total_event_count: int,
261
+ user_event_count: int,
262
+ assistant_event_count: int,
263
+ tool_event_count: int,
264
+ system_event_count: int,
265
+ tool_type_counts: dict[str, int],
266
+ ) -> None:
267
+ """Persist one poller sync-run row without affecting the poller loop."""
268
+
269
+ try:
270
+ run, tool_types = build_episode_sync_records(
271
+ sync_run_id=str(uuid4()),
272
+ source="poller",
273
+ invocation_id=None,
274
+ repo_id=repo_id,
275
+ host_app=host_app,
276
+ host_session_key=host_session_key,
277
+ thread_id=thread_id,
278
+ episode_id=episode_id,
279
+ transcript_path=transcript_path,
280
+ outcome=outcome,
281
+ error_stage=error_stage,
282
+ error_message=error_message,
283
+ duration_ms=duration_ms,
284
+ imported_event_count=imported_event_count,
285
+ total_event_count=total_event_count,
286
+ user_event_count=user_event_count,
287
+ assistant_event_count=assistant_event_count,
288
+ tool_event_count=tool_event_count,
289
+ system_event_count=system_event_count,
290
+ tool_type_counts=tool_type_counts,
291
+ )
292
+ with uow_factory() as uow:
293
+ record_episode_sync_telemetry(uow=uow, run=run, tool_types=tool_types)
294
+ except Exception:
295
+ return
296
+
297
+
298
+ if __name__ == "__main__":
299
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ """Resolve local host transcript files for episodic ingestion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from app.periphery.episodes.claude_code import (
9
+ find_latest_claude_code_session_for_repo,
10
+ resolve_claude_code_transcript_path,
11
+ )
12
+ from app.periphery.episodes.codex import find_latest_codex_session_for_repo, resolve_codex_transcript_path
13
+
14
+ SUPPORTED_HOSTS = ("codex", "claude_code")
15
+
16
+
17
+ def resolve_host_transcript_source(
18
+ *,
19
+ host_app: str,
20
+ host_session_key: str,
21
+ search_roots: Sequence[Path],
22
+ last_known_path: Path | None = None,
23
+ ) -> Path:
24
+ """Resolve the transcript file for one supported host session."""
25
+
26
+ search_roots = [Path(root) for root in search_roots]
27
+ if host_app == "codex":
28
+ return resolve_codex_transcript_path(
29
+ host_session_key=host_session_key,
30
+ search_roots=search_roots,
31
+ last_known_path=last_known_path,
32
+ )
33
+ if host_app == "claude_code":
34
+ return resolve_claude_code_transcript_path(
35
+ host_session_key=host_session_key,
36
+ search_roots=search_roots,
37
+ last_known_path=last_known_path,
38
+ )
39
+ raise ValueError(f"Unsupported host app for episode sync: {host_app}")
40
+
41
+
42
+ def default_search_roots(*, repo_root: Path, host_app: str) -> list[Path]:
43
+ """Return bounded transcript search roots for one supported host."""
44
+
45
+ home = Path.home()
46
+ if host_app == "codex":
47
+ return [home / ".codex" / "sessions"]
48
+ if host_app == "claude_code":
49
+ return [home]
50
+ return [repo_root]
51
+
52
+
53
+ def discover_active_host_session(
54
+ *,
55
+ host_app: str,
56
+ repo_root: Path,
57
+ search_roots: Sequence[Path],
58
+ ) -> dict | None:
59
+ """Discover the latest active session for one host and repo root."""
60
+
61
+ search_roots = [Path(root) for root in search_roots]
62
+ if host_app == "codex":
63
+ return find_latest_codex_session_for_repo(repo_root=repo_root, search_roots=search_roots)
64
+ if host_app == "claude_code":
65
+ return find_latest_claude_code_session_for_repo(repo_root=repo_root, search_roots=search_roots)
66
+ raise ValueError(f"Unsupported host app for episode sync: {host_app}")
@@ -0,0 +1,165 @@
1
+ """Deterministic filtering and summarization for host tool-result events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+
8
+
9
+ _NOISY_TOOLS = {"glob", "read", "grep", "search", "find", "ls", "pwd", "cat"}
10
+ _MUTATION_TOOLS = {"edit", "write", "multiedit", "apply_patch"}
11
+ _VALIDATION_FAMILIES = {
12
+ "pytest",
13
+ "py.test",
14
+ "go test",
15
+ "cargo test",
16
+ "npm test",
17
+ "pnpm test",
18
+ "yarn test",
19
+ "alembic",
20
+ }
21
+ _NOISY_COMMANDS = {"ls", "pwd", "cat", "rg", "grep", "find", "fd", "head", "tail", "sed"}
22
+ _FAILED_PREFIXES = ("failed", "error", "exception")
23
+ _MUTATION_PREFIXES = ("updated", "edited", "patched", "applied", "wrote")
24
+
25
+
26
+ def should_keep_tool_result(
27
+ *,
28
+ tool_name: str | None,
29
+ status: str | None,
30
+ text: str | None,
31
+ summary: str | None = None,
32
+ command: str | None = None,
33
+ is_error: bool | None = None,
34
+ ) -> bool:
35
+ """Return whether one tool result carries durable episodic value."""
36
+
37
+ normalized_tool = (tool_name or "").strip().lower()
38
+ normalized_command = _command_family(command)
39
+ normalized_status = (status or "").strip().lower()
40
+ normalized_summary = (summary or "").strip().lower()
41
+ normalized_text = _compact_text(text).lower()
42
+
43
+ if is_error is True or normalized_status in {"error", "failed", "failure"}:
44
+ return True
45
+ if _extract_exit_code(text) not in {None, 0}:
46
+ return True
47
+ if normalized_tool in _MUTATION_TOOLS:
48
+ return True
49
+ if normalized_command in _VALIDATION_FAMILIES:
50
+ return True
51
+ if normalized_summary.startswith(_FAILED_PREFIXES) or normalized_summary.startswith(_MUTATION_PREFIXES):
52
+ return True
53
+ if normalized_text.startswith(_FAILED_PREFIXES) or normalized_text.startswith(_MUTATION_PREFIXES):
54
+ return True
55
+ if normalized_tool in _NOISY_TOOLS:
56
+ return False
57
+ if normalized_command in _NOISY_COMMANDS:
58
+ return False
59
+ return False
60
+
61
+
62
+ def summarize_tool_result(
63
+ *,
64
+ tool_name: str | None,
65
+ status: str | None,
66
+ text: str | None,
67
+ summary: str | None = None,
68
+ command: str | None = None,
69
+ is_error: bool | None = None,
70
+ ) -> str:
71
+ """Build a compact, stable human-readable tool result summary."""
72
+
73
+ command_family = _command_family(command)
74
+ exit_code = _extract_exit_code(text)
75
+
76
+ if summary:
77
+ return _normalize_summary(summary)
78
+ if command_family in _VALIDATION_FAMILIES:
79
+ if is_error is True or (status or "").lower() in {"error", "failed", "failure"} or exit_code not in {None, 0}:
80
+ return f"{command_family} failed"
81
+ return f"{command_family} passed"
82
+
83
+ compact_text = _compact_text(text)
84
+ if compact_text:
85
+ return _normalize_summary(compact_text)
86
+ normalized_tool = (tool_name or "tool").strip().lower()
87
+ if normalized_tool in _MUTATION_TOOLS:
88
+ return f"{normalized_tool} updated file"
89
+ if is_error is True or (status or "").lower() in {"error", "failed", "failure"}:
90
+ return f"{normalized_tool} failed"
91
+ return f"{normalized_tool} completed"
92
+
93
+
94
+ def _normalize_summary(value: str) -> str:
95
+ """Collapse one tool result into a short stable sentence fragment."""
96
+
97
+ compact = _compact_text(value)
98
+ lowered = compact.lower()
99
+ if ":" in compact:
100
+ prefix = compact.split(":", 1)[0].strip()
101
+ lowered_prefix = prefix.lower()
102
+ if (
103
+ any(token in lowered_prefix for token in ("failed", "error", "exception"))
104
+ or lowered_prefix.startswith(_MUTATION_PREFIXES)
105
+ ):
106
+ return prefix
107
+ if lowered.startswith("process exited with code"):
108
+ return compact.splitlines()[0].strip()
109
+ return compact
110
+
111
+
112
+ def _compact_text(value: str | None) -> str:
113
+ """Drop wrapper noise and keep the first meaningful output line."""
114
+
115
+ if value is None:
116
+ return ""
117
+ for line in value.splitlines():
118
+ stripped = line.strip()
119
+ if not stripped:
120
+ continue
121
+ if stripped.startswith("Chunk ID:"):
122
+ continue
123
+ if stripped.startswith("Wall time:"):
124
+ continue
125
+ if stripped.startswith("Original token count:"):
126
+ continue
127
+ if stripped == "Output:":
128
+ continue
129
+ return stripped
130
+ return ""
131
+
132
+
133
+ def _command_family(command: str | None) -> str:
134
+ """Reduce one shell command into a stable family label when possible."""
135
+
136
+ if not command:
137
+ return ""
138
+ try:
139
+ tokens = shlex.split(command)
140
+ except ValueError:
141
+ tokens = command.split()
142
+ if not tokens:
143
+ return ""
144
+ lowered = [token.lower() for token in tokens]
145
+ head = lowered[0]
146
+ if head in {"python", "python3", "uv"} and len(lowered) >= 3 and lowered[1] == "-m":
147
+ return f"{lowered[1]} {lowered[2]}"
148
+ if head == "go" and len(lowered) >= 2:
149
+ return f"go {lowered[1]}"
150
+ if head == "cargo" and len(lowered) >= 2:
151
+ return f"cargo {lowered[1]}"
152
+ if head in {"npm", "pnpm", "yarn"} and len(lowered) >= 2:
153
+ return f"{head} {lowered[1]}"
154
+ return head
155
+
156
+
157
+ def _extract_exit_code(text: str | None) -> int | None:
158
+ """Extract one exit status from wrapped shell output when present."""
159
+
160
+ if text is None:
161
+ return None
162
+ match = re.search(r"Process exited with code (\d+)", text)
163
+ if match is None:
164
+ return None
165
+ return int(match.group(1))
@@ -0,0 +1 @@
1
+ """Caller-identity adapters for supported host runtimes."""
@@ -0,0 +1,67 @@
1
+ """Install the official Claude SessionStart hook for Shellbrain caller identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ _SESSION_START_MATCHER = "startup|resume|clear|compact"
10
+ _MANAGED_MARKER = "shellbrain-managed:session-start"
11
+
12
+
13
+ def install_claude_hook(*, repo_root: Path) -> Path:
14
+ """Install or update one repo-local Claude settings file with the Shellbrain hook."""
15
+
16
+ repo_root = repo_root.resolve()
17
+ settings_path = repo_root / ".claude" / "settings.local.json"
18
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
19
+ try:
20
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
21
+ except (FileNotFoundError, json.JSONDecodeError):
22
+ settings = {}
23
+ if not isinstance(settings, dict):
24
+ settings = {}
25
+ hooks = settings.get("hooks")
26
+ if not isinstance(hooks, dict):
27
+ hooks = {}
28
+ session_start_entries = hooks.get("SessionStart")
29
+ if not isinstance(session_start_entries, list):
30
+ session_start_entries = []
31
+
32
+ managed_entry = {
33
+ "matcher": _SESSION_START_MATCHER,
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": _managed_command(),
38
+ }
39
+ ],
40
+ }
41
+ replaced = False
42
+ for index, entry in enumerate(session_start_entries):
43
+ if not isinstance(entry, dict):
44
+ continue
45
+ nested_hooks = entry.get("hooks")
46
+ if not isinstance(nested_hooks, list):
47
+ continue
48
+ if any(_MANAGED_MARKER in str(item.get("command", "")) for item in nested_hooks if isinstance(item, dict)):
49
+ session_start_entries[index] = managed_entry
50
+ replaced = True
51
+ break
52
+ if not replaced:
53
+ session_start_entries.append(managed_entry)
54
+ hooks["SessionStart"] = session_start_entries
55
+ settings["hooks"] = hooks
56
+ settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True), encoding="utf-8")
57
+ return settings_path
58
+
59
+
60
+ def _managed_command() -> str:
61
+ """Return the Shellbrain-managed Claude SessionStart hook command."""
62
+
63
+ return (
64
+ "python -m app.periphery.identity.claude_runtime session-start "
65
+ f"# {_MANAGED_MARKER} uses CLAUDE_ENV_FILE to export SHELLBRAIN_HOST_APP=claude_code "
66
+ "and related Shellbrain identity variables"
67
+ )
@@ -0,0 +1,83 @@
1
+ """Claude runtime identity helpers and SessionStart hook entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+
11
+ from app.core.entities.identity import CallerIdentity, IdentityTrustLevel
12
+
13
+
14
+ def resolve_trusted_claude_caller_identity() -> CallerIdentity | None:
15
+ """Resolve one trusted Claude caller from Shellbrain hook environment variables."""
16
+
17
+ if os.getenv("SHELLBRAIN_HOST_APP") != "claude_code":
18
+ return None
19
+ session_key = os.getenv("SHELLBRAIN_HOST_SESSION_KEY")
20
+ transcript_path = os.getenv("SHELLBRAIN_TRANSCRIPT_PATH")
21
+ if not session_key or not transcript_path:
22
+ return None
23
+ agent_key = os.getenv("SHELLBRAIN_AGENT_KEY") or None
24
+ if "/subagents/" in transcript_path and agent_key is None:
25
+ return CallerIdentity(
26
+ host_app="claude_code",
27
+ host_session_key=session_key,
28
+ trust_level=IdentityTrustLevel.UNSUPPORTED,
29
+ )
30
+ return CallerIdentity(
31
+ host_app="claude_code",
32
+ host_session_key=session_key,
33
+ agent_key=agent_key,
34
+ trust_level=IdentityTrustLevel.TRUSTED,
35
+ )
36
+
37
+
38
+ def detect_claude_runtime_without_hook() -> bool:
39
+ """Return whether Claude seems present but trusted hook identity is missing."""
40
+
41
+ if os.getenv("SHELLBRAIN_HOST_APP") == "claude_code":
42
+ return False
43
+ return any(
44
+ os.getenv(name)
45
+ for name in ("CLAUDE_SESSION_ID", "CLAUDE_CODE_REMOTE_SESSION_ID", "CLAUDE_CODE_AGENT_NAME")
46
+ )
47
+
48
+
49
+ def resolve_trusted_claude_transcript_path() -> Path | None:
50
+ """Return the trusted Claude transcript path injected by the Shellbrain hook when present."""
51
+
52
+ transcript_path = os.getenv("SHELLBRAIN_TRANSCRIPT_PATH")
53
+ if not transcript_path:
54
+ return None
55
+ return Path(transcript_path).expanduser().resolve()
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ """Entrypoint for the Claude SessionStart hook helper."""
60
+
61
+ parser = argparse.ArgumentParser(prog="shellbrain-claude-runtime")
62
+ parser.add_argument("command", choices=("session-start",))
63
+ args = parser.parse_args(argv)
64
+ if args.command != "session-start":
65
+ return 2
66
+ payload = json.load(sys.stdin)
67
+ session_id = str(payload.get("session_id") or "")
68
+ transcript_path = str(payload.get("transcript_path") or "")
69
+ if not session_id or not transcript_path:
70
+ return 1
71
+ env_file = os.getenv("CLAUDE_ENV_FILE")
72
+ if not env_file:
73
+ return 0
74
+ with Path(env_file).open("a", encoding="utf-8") as handle:
75
+ handle.write("export SHELLBRAIN_HOST_APP=claude_code\n")
76
+ handle.write(f"export SHELLBRAIN_HOST_SESSION_KEY={session_id}\n")
77
+ handle.write(f"export SHELLBRAIN_TRANSCRIPT_PATH={transcript_path}\n")
78
+ handle.write(f"export SHELLBRAIN_CALLER_ID=claude_code:{session_id}\n")
79
+ return 0
80
+
81
+
82
+ if __name__ == "__main__":
83
+ raise SystemExit(main(sys.argv[1:]))