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,423 @@
1
+ """Codex-host transcript discovery and normalization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ from typing import Any
11
+
12
+ from app.periphery.episodes.tool_filter import should_keep_tool_result, summarize_tool_result
13
+
14
+
15
+ def resolve_codex_transcript_path(
16
+ *,
17
+ host_session_key: str,
18
+ search_roots: Sequence[Path],
19
+ last_known_path: Path | None = None,
20
+ ) -> Path:
21
+ """Resolve one Codex rollout transcript path from a thread id."""
22
+
23
+ if last_known_path is not None and last_known_path.exists():
24
+ return last_known_path
25
+ candidates: list[Path] = []
26
+ for root in search_roots:
27
+ if not root.exists():
28
+ continue
29
+ candidates.extend(sorted(root.rglob(f"*{host_session_key}*.jsonl")))
30
+ if not candidates:
31
+ raise FileNotFoundError(
32
+ f"Codex transcript source for session '{host_session_key}' could not be found."
33
+ )
34
+ return max(candidates, key=lambda path: path.stat().st_mtime)
35
+
36
+
37
+ def find_latest_codex_session_for_repo(*, repo_root: Path, search_roots: Sequence[Path]) -> dict[str, Any] | None:
38
+ """Return the most recently updated Codex session for one repo root."""
39
+
40
+ candidates = list_codex_sessions_for_repo(repo_root=repo_root, search_roots=search_roots)
41
+ if not candidates:
42
+ return None
43
+ return max(candidates, key=lambda candidate: candidate["updated_at"])
44
+
45
+
46
+ def list_codex_sessions_for_repo(*, repo_root: Path, search_roots: Sequence[Path]) -> list[dict[str, Any]]:
47
+ """Return all repo-matching Codex sessions under the bounded search roots."""
48
+
49
+ candidates: list[dict[str, Any]] = []
50
+ resolved_repo_root = repo_root.resolve()
51
+ for root in search_roots:
52
+ if not root.exists():
53
+ continue
54
+ for transcript_path in root.rglob("*.jsonl"):
55
+ metadata = _read_session_meta(transcript_path)
56
+ cwd = metadata.get("cwd")
57
+ session_id = metadata.get("id")
58
+ if not isinstance(cwd, str) or not isinstance(session_id, str):
59
+ continue
60
+ try:
61
+ if Path(cwd).resolve() != resolved_repo_root:
62
+ continue
63
+ except FileNotFoundError:
64
+ continue
65
+ candidates.append(
66
+ {
67
+ "host_app": "codex",
68
+ "host_session_key": session_id,
69
+ "transcript_path": transcript_path,
70
+ "updated_at": transcript_path.stat().st_mtime,
71
+ }
72
+ )
73
+ return candidates
74
+
75
+
76
+ def normalize_codex_transcript(*, host_session_key: str, transcript_path: Path) -> list[dict[str, Any]]:
77
+ """Normalize one Codex transcript into shared compact event dictionaries."""
78
+
79
+ events: list[dict[str, Any]] = []
80
+ function_calls: dict[str, dict[str, Any]] = {}
81
+ with transcript_path.open(encoding="utf-8") as handle:
82
+ for line_number, raw_line in enumerate(handle, start=1):
83
+ raw_line = raw_line.rstrip("\n")
84
+ if not raw_line:
85
+ continue
86
+ payload = json.loads(raw_line)
87
+ line_type = payload.get("type")
88
+
89
+ if line_type == "message":
90
+ event = _normalize_simple_message(payload, host_session_key=host_session_key)
91
+ if event is not None:
92
+ events.append(event)
93
+ continue
94
+
95
+ if line_type == "tool_result":
96
+ event = _normalize_simple_tool_result(payload, host_session_key=host_session_key)
97
+ if event is not None:
98
+ events.append(event)
99
+ continue
100
+
101
+ if line_type == "event_msg":
102
+ event = _normalize_event_msg(
103
+ payload,
104
+ host_session_key=host_session_key,
105
+ raw_line=raw_line,
106
+ line_number=line_number,
107
+ )
108
+ if event is not None:
109
+ events.append(event)
110
+ continue
111
+
112
+ if line_type != "response_item":
113
+ continue
114
+
115
+ item = payload.get("payload", {})
116
+ item_type = item.get("type")
117
+ if item_type == "message":
118
+ event = _normalize_response_message(
119
+ payload,
120
+ host_session_key=host_session_key,
121
+ raw_line=raw_line,
122
+ line_number=line_number,
123
+ )
124
+ if event is not None:
125
+ events.append(event)
126
+ continue
127
+ if item_type == "function_call":
128
+ call_id = item.get("call_id")
129
+ if isinstance(call_id, str):
130
+ function_calls[call_id] = item
131
+ continue
132
+ if item_type == "function_call_output":
133
+ event = _normalize_function_call_output(
134
+ payload,
135
+ host_session_key=host_session_key,
136
+ raw_line=raw_line,
137
+ line_number=line_number,
138
+ function_calls=function_calls,
139
+ )
140
+ if event is not None:
141
+ events.append(event)
142
+ continue
143
+
144
+ return events
145
+
146
+
147
+ def _normalize_simple_message(payload: dict[str, Any], *, host_session_key: str) -> dict[str, Any] | None:
148
+ """Normalize the synthetic message shape used by tests."""
149
+
150
+ role = payload.get("role")
151
+ text = payload.get("text")
152
+ if role not in {"user", "assistant"} or not isinstance(text, str) or not text.strip():
153
+ return None
154
+ return _build_event(
155
+ host_session_key=host_session_key,
156
+ host_event_key=str(payload.get("event_id") or _hash_event(payload)),
157
+ source=str(role),
158
+ occurred_at=str(payload.get("timestamp") or ""),
159
+ content_kind="message",
160
+ content_text=text.strip(),
161
+ )
162
+
163
+
164
+ def _normalize_simple_tool_result(payload: dict[str, Any], *, host_session_key: str) -> dict[str, Any] | None:
165
+ """Normalize the synthetic tool-result shape used by tests."""
166
+
167
+ summary = payload.get("summary") if isinstance(payload.get("summary"), str) else None
168
+ text = payload.get("text") if isinstance(payload.get("text"), str) else None
169
+ tool_name = payload.get("tool_name") if isinstance(payload.get("tool_name"), str) else None
170
+ status = payload.get("status") if isinstance(payload.get("status"), str) else None
171
+ if not should_keep_tool_result(
172
+ tool_name=tool_name,
173
+ status=status,
174
+ text=text,
175
+ summary=summary,
176
+ ):
177
+ return None
178
+ return _build_event(
179
+ host_session_key=host_session_key,
180
+ host_event_key=str(payload.get("event_id") or _hash_event(payload)),
181
+ source="tool",
182
+ occurred_at=str(payload.get("timestamp") or ""),
183
+ content_kind="tool_result",
184
+ content_text=summarize_tool_result(
185
+ tool_name=tool_name,
186
+ status=status,
187
+ text=text,
188
+ summary=summary,
189
+ ),
190
+ extra_fields={
191
+ "tool_name": _normalized_tool_name(tool_name=tool_name, command=None),
192
+ "status": _normalized_tool_status(status=status, text=text, summary=summary),
193
+ "is_error": _normalized_tool_status(status=status, text=text, summary=summary) == "error",
194
+ },
195
+ )
196
+
197
+
198
+ def _normalize_event_msg(
199
+ payload: dict[str, Any],
200
+ *,
201
+ host_session_key: str,
202
+ raw_line: str,
203
+ line_number: int,
204
+ ) -> dict[str, Any] | None:
205
+ """Normalize user-visible Codex event message records."""
206
+
207
+ item = payload.get("payload", {})
208
+ item_type = item.get("type")
209
+ if item_type == "user_message":
210
+ text = item.get("message")
211
+ if isinstance(text, str) and text.strip():
212
+ return _build_event(
213
+ host_session_key=host_session_key,
214
+ host_event_key=_fallback_key(payload, raw_line, line_number),
215
+ source="user",
216
+ occurred_at=str(payload.get("timestamp") or ""),
217
+ content_kind="message",
218
+ content_text=text.strip(),
219
+ )
220
+ if item_type == "agent_message":
221
+ text = item.get("message")
222
+ if isinstance(text, str) and text.strip():
223
+ return _build_event(
224
+ host_session_key=host_session_key,
225
+ host_event_key=_fallback_key(payload, raw_line, line_number),
226
+ source="assistant",
227
+ occurred_at=str(payload.get("timestamp") or ""),
228
+ content_kind="message",
229
+ content_text=text.strip(),
230
+ )
231
+ return None
232
+
233
+
234
+ def _normalize_response_message(
235
+ payload: dict[str, Any],
236
+ *,
237
+ host_session_key: str,
238
+ raw_line: str,
239
+ line_number: int,
240
+ ) -> dict[str, Any] | None:
241
+ """Normalize canonical assistant response items from Codex."""
242
+
243
+ item = payload.get("payload", {})
244
+ role = item.get("role")
245
+ if role != "assistant":
246
+ return None
247
+ text = _extract_codex_message_text(item.get("content"))
248
+ if not text:
249
+ return None
250
+ return _build_event(
251
+ host_session_key=host_session_key,
252
+ host_event_key=_fallback_key(payload, raw_line, line_number),
253
+ source="assistant",
254
+ occurred_at=str(payload.get("timestamp") or ""),
255
+ content_kind="message",
256
+ content_text=text,
257
+ )
258
+
259
+
260
+ def _normalize_function_call_output(
261
+ payload: dict[str, Any],
262
+ *,
263
+ host_session_key: str,
264
+ raw_line: str,
265
+ line_number: int,
266
+ function_calls: dict[str, dict[str, Any]],
267
+ ) -> dict[str, Any] | None:
268
+ """Normalize one Codex tool result when it carries durable value."""
269
+
270
+ item = payload.get("payload", {})
271
+ call_id = item.get("call_id")
272
+ call = function_calls.get(str(call_id)) if isinstance(call_id, str) else None
273
+ tool_name = call.get("name") if isinstance(call, dict) else None
274
+ command = _extract_codex_command(call)
275
+ text = item.get("output") if isinstance(item.get("output"), str) else None
276
+ if not should_keep_tool_result(
277
+ tool_name=tool_name if isinstance(tool_name, str) else None,
278
+ status=None,
279
+ text=text,
280
+ command=command,
281
+ ):
282
+ return None
283
+ return _build_event(
284
+ host_session_key=host_session_key,
285
+ host_event_key=str(call_id or _fallback_key(payload, raw_line, line_number)),
286
+ source="tool",
287
+ occurred_at=str(payload.get("timestamp") or ""),
288
+ content_kind="tool_result",
289
+ content_text=summarize_tool_result(
290
+ tool_name=tool_name if isinstance(tool_name, str) else None,
291
+ status=None,
292
+ text=text,
293
+ command=command,
294
+ ),
295
+ extra_fields={
296
+ "tool_name": _normalized_tool_name(
297
+ tool_name=tool_name if isinstance(tool_name, str) else None,
298
+ command=command,
299
+ ),
300
+ "status": _normalized_tool_status(status=None, text=text, summary=None),
301
+ "is_error": _normalized_tool_status(status=None, text=text, summary=None) == "error",
302
+ },
303
+ )
304
+
305
+
306
+ def _extract_codex_message_text(content: Any) -> str:
307
+ """Extract visible text from a Codex response-item content list."""
308
+
309
+ if not isinstance(content, Iterable) or isinstance(content, (str, bytes)):
310
+ return ""
311
+ parts: list[str] = []
312
+ for item in content:
313
+ if not isinstance(item, dict):
314
+ continue
315
+ item_type = item.get("type")
316
+ if item_type not in {"input_text", "output_text", "text"}:
317
+ continue
318
+ text = item.get("text")
319
+ if isinstance(text, str) and text.strip():
320
+ parts.append(text.strip())
321
+ return "\n".join(parts).strip()
322
+
323
+
324
+ def _extract_codex_command(call: dict[str, Any] | None) -> str | None:
325
+ """Extract one shell command from a Codex function call when present."""
326
+
327
+ if not isinstance(call, dict):
328
+ return None
329
+ arguments = call.get("arguments")
330
+ if not isinstance(arguments, str):
331
+ return None
332
+ try:
333
+ parsed = json.loads(arguments)
334
+ except json.JSONDecodeError:
335
+ return None
336
+ command = parsed.get("cmd")
337
+ return command if isinstance(command, str) else None
338
+
339
+
340
+ def _read_session_meta(transcript_path: Path) -> dict[str, Any]:
341
+ """Read the leading session_meta payload from one Codex rollout file when present."""
342
+
343
+ try:
344
+ with transcript_path.open(encoding="utf-8") as handle:
345
+ first_line = handle.readline()
346
+ except FileNotFoundError:
347
+ return {}
348
+ if not first_line:
349
+ return {}
350
+ payload = json.loads(first_line)
351
+ if payload.get("type") != "session_meta":
352
+ return {}
353
+ metadata = payload.get("payload", {})
354
+ return metadata if isinstance(metadata, dict) else {}
355
+
356
+
357
+ def _build_event(
358
+ *,
359
+ host_session_key: str,
360
+ host_event_key: str,
361
+ source: str,
362
+ occurred_at: str,
363
+ content_kind: str,
364
+ content_text: str,
365
+ extra_fields: dict[str, Any] | None = None,
366
+ ) -> dict[str, Any]:
367
+ """Construct one shared normalized event payload."""
368
+
369
+ event = {
370
+ "host_app": "codex",
371
+ "host_session_key": host_session_key,
372
+ "host_event_key": host_event_key,
373
+ "source": source,
374
+ "occurred_at": occurred_at,
375
+ "content_kind": content_kind,
376
+ "content_text": content_text,
377
+ "raw_ref": f"codex://threads/{host_session_key}#event={host_event_key}",
378
+ }
379
+ if extra_fields:
380
+ event.update(extra_fields)
381
+ return event
382
+
383
+
384
+ def _normalized_tool_name(*, tool_name: str | None, command: str | None) -> str:
385
+ """Normalize Codex tool identifiers into stable analytics-friendly names."""
386
+
387
+ if isinstance(tool_name, str) and tool_name.strip():
388
+ return tool_name.strip()
389
+ if command:
390
+ return "exec_command"
391
+ return "unknown_tool"
392
+
393
+
394
+ def _normalized_tool_status(*, status: str | None, text: str | None, summary: str | None) -> str:
395
+ """Derive one compact ok/error status for a normalized tool event."""
396
+
397
+ if isinstance(status, str) and status.strip():
398
+ normalized = status.strip().lower()
399
+ return "error" if normalized in {"error", "failed", "failure"} else "ok"
400
+ combined = " ".join(part for part in (summary, text) if isinstance(part, str)).lower()
401
+ if any(token in combined for token in ("failed", "error", "exception")):
402
+ return "error"
403
+ match = re.search(r"process exited with code (\d+)", text or "", re.IGNORECASE)
404
+ if match is not None and int(match.group(1)) != 0:
405
+ return "error"
406
+ return "ok"
407
+
408
+
409
+ def _fallback_key(payload: dict[str, Any], raw_line: str, line_number: int) -> str:
410
+ """Build a stable upstream event key when the host does not expose one directly."""
411
+
412
+ explicit = payload.get("event_id")
413
+ if isinstance(explicit, str) and explicit:
414
+ return explicit
415
+ digest = hashlib.sha1(raw_line.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
416
+ return f"codex-line-{line_number}-{digest}"
417
+
418
+
419
+ def _hash_event(payload: dict[str, Any]) -> str:
420
+ """Hash a small synthetic event deterministically."""
421
+
422
+ encoded = json.dumps(payload, sort_keys=True)
423
+ return hashlib.sha1(encoded.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
@@ -0,0 +1,66 @@
1
+ """Best-effort startup for the repo-local episodic sync poller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+
11
+
12
+ _PID_FILE = "episode_sync.pid"
13
+
14
+
15
+ def ensure_episode_sync_started(*, repo_id: str, repo_root: Path) -> bool:
16
+ """Start one detached poller process for the repo when needed."""
17
+
18
+ runtime_dir = repo_root / ".shellbrain"
19
+ runtime_dir.mkdir(parents=True, exist_ok=True)
20
+ pid_path = runtime_dir / _PID_FILE
21
+
22
+ existing_pid = _read_pid(pid_path)
23
+ if existing_pid is not None and _is_running(existing_pid):
24
+ return False
25
+
26
+ command = [
27
+ sys.executable,
28
+ "-m",
29
+ "app.periphery.episodes.poller",
30
+ "--repo-id",
31
+ repo_id,
32
+ "--repo-root",
33
+ str(repo_root),
34
+ ]
35
+ process = subprocess.Popen(
36
+ command,
37
+ cwd=repo_root,
38
+ stdout=subprocess.DEVNULL,
39
+ stderr=subprocess.DEVNULL,
40
+ start_new_session=True,
41
+ )
42
+ pid_path.write_text(json.dumps({"pid": process.pid}), encoding="utf-8")
43
+ return True
44
+
45
+
46
+ def _read_pid(pid_path: Path) -> int | None:
47
+ """Read one stored pid from disk when available."""
48
+
49
+ if not pid_path.exists():
50
+ return None
51
+ try:
52
+ payload = json.loads(pid_path.read_text(encoding="utf-8"))
53
+ except json.JSONDecodeError:
54
+ return None
55
+ pid = payload.get("pid")
56
+ return int(pid) if isinstance(pid, int) else None
57
+
58
+
59
+ def _is_running(pid: int) -> bool:
60
+ """Return whether one process id is still alive."""
61
+
62
+ try:
63
+ os.kill(pid, 0)
64
+ except OSError:
65
+ return False
66
+ return True
@@ -0,0 +1,31 @@
1
+ """Normalize host transcript files into compact episode events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from app.periphery.episodes.claude_code import normalize_claude_code_transcript
9
+ from app.periphery.episodes.codex import normalize_codex_transcript
10
+
11
+
12
+ def normalize_host_transcript(
13
+ *,
14
+ host_app: str,
15
+ host_session_key: str,
16
+ transcript_path: Path,
17
+ ) -> list[dict[str, Any]]:
18
+ """Normalize one host transcript file into common episode-event dictionaries."""
19
+
20
+ transcript_path = Path(transcript_path)
21
+ if host_app == "codex":
22
+ return normalize_codex_transcript(
23
+ host_session_key=host_session_key,
24
+ transcript_path=transcript_path,
25
+ )
26
+ if host_app == "claude_code":
27
+ return normalize_claude_code_transcript(
28
+ host_session_key=host_session_key,
29
+ transcript_path=transcript_path,
30
+ )
31
+ raise ValueError(f"Unsupported host app for episode sync: {host_app}")