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.
- app/__init__.py +1 -0
- app/__main__.py +7 -0
- app/boot/__init__.py +1 -0
- app/boot/admin_db.py +88 -0
- app/boot/config.py +14 -0
- app/boot/create_policy.py +52 -0
- app/boot/db.py +70 -0
- app/boot/embeddings.py +55 -0
- app/boot/home.py +45 -0
- app/boot/migrations.py +61 -0
- app/boot/read_policy.py +179 -0
- app/boot/repos.py +15 -0
- app/boot/retrieval.py +3 -0
- app/boot/thresholds.py +19 -0
- app/boot/update_policy.py +34 -0
- app/boot/use_cases.py +22 -0
- app/config/__init__.py +1 -0
- app/config/defaults/create_policy.yaml +7 -0
- app/config/defaults/read_policy.yaml +25 -0
- app/config/defaults/runtime.yaml +10 -0
- app/config/defaults/thresholds.yaml +3 -0
- app/config/defaults/update_policy.yaml +5 -0
- app/config/loader.py +58 -0
- app/core/__init__.py +1 -0
- app/core/contracts/__init__.py +1 -0
- app/core/contracts/errors.py +29 -0
- app/core/contracts/requests.py +211 -0
- app/core/contracts/responses.py +15 -0
- app/core/entities/__init__.py +1 -0
- app/core/entities/associations.py +58 -0
- app/core/entities/episodes.py +66 -0
- app/core/entities/evidence.py +29 -0
- app/core/entities/facts.py +30 -0
- app/core/entities/guidance.py +47 -0
- app/core/entities/identity.py +48 -0
- app/core/entities/memory.py +34 -0
- app/core/entities/runtime_context.py +19 -0
- app/core/entities/session_state.py +31 -0
- app/core/entities/telemetry.py +152 -0
- app/core/entities/utility.py +14 -0
- app/core/interfaces/__init__.py +1 -0
- app/core/interfaces/clock.py +12 -0
- app/core/interfaces/config.py +28 -0
- app/core/interfaces/embeddings.py +12 -0
- app/core/interfaces/idgen.py +11 -0
- app/core/interfaces/repos.py +279 -0
- app/core/interfaces/retrieval.py +20 -0
- app/core/interfaces/session_state_store.py +33 -0
- app/core/interfaces/unit_of_work.py +50 -0
- app/core/policies/__init__.py +1 -0
- app/core/policies/_shared/__init__.py +1 -0
- app/core/policies/_shared/executor.py +132 -0
- app/core/policies/_shared/side_effects.py +9 -0
- app/core/policies/create_policy/__init__.py +1 -0
- app/core/policies/create_policy/pipeline.py +96 -0
- app/core/policies/read_policy/__init__.py +1 -0
- app/core/policies/read_policy/bm25.py +114 -0
- app/core/policies/read_policy/context_pack_builder.py +140 -0
- app/core/policies/read_policy/expansion.py +132 -0
- app/core/policies/read_policy/fusion_rrf.py +34 -0
- app/core/policies/read_policy/lexical_query.py +101 -0
- app/core/policies/read_policy/pipeline.py +93 -0
- app/core/policies/read_policy/scenario_lift.py +11 -0
- app/core/policies/read_policy/scoring.py +61 -0
- app/core/policies/read_policy/seed_retrieval.py +54 -0
- app/core/policies/read_policy/utility_prior.py +11 -0
- app/core/policies/update_policy/__init__.py +1 -0
- app/core/policies/update_policy/pipeline.py +80 -0
- app/core/use_cases/__init__.py +1 -0
- app/core/use_cases/build_guidance.py +85 -0
- app/core/use_cases/create_memory.py +26 -0
- app/core/use_cases/manage_session_state.py +159 -0
- app/core/use_cases/read_memory.py +21 -0
- app/core/use_cases/record_episode_sync_telemetry.py +19 -0
- app/core/use_cases/record_operation_telemetry.py +32 -0
- app/core/use_cases/sync_episode.py +162 -0
- app/core/use_cases/update_memory.py +40 -0
- app/migrations/__init__.py +1 -0
- app/migrations/env.py +65 -0
- app/migrations/versions/20260226_0001_initial_schema.py +232 -0
- app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
- app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
- app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
- app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
- app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
- app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
- app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
- app/migrations/versions/__init__.py +1 -0
- app/periphery/__init__.py +1 -0
- app/periphery/admin/__init__.py +1 -0
- app/periphery/admin/backup.py +360 -0
- app/periphery/admin/destructive_guard.py +32 -0
- app/periphery/admin/doctor.py +192 -0
- app/periphery/admin/init.py +996 -0
- app/periphery/admin/instance_guard.py +211 -0
- app/periphery/admin/machine_state.py +354 -0
- app/periphery/admin/privileges.py +42 -0
- app/periphery/admin/repo_state.py +266 -0
- app/periphery/admin/restore.py +30 -0
- app/periphery/cli/__init__.py +1 -0
- app/periphery/cli/handlers.py +830 -0
- app/periphery/cli/hydration.py +119 -0
- app/periphery/cli/main.py +710 -0
- app/periphery/cli/presenter_json.py +10 -0
- app/periphery/cli/schema_validation.py +201 -0
- app/periphery/db/__init__.py +1 -0
- app/periphery/db/engine.py +10 -0
- app/periphery/db/models/__init__.py +1 -0
- app/periphery/db/models/associations.py +55 -0
- app/periphery/db/models/episodes.py +55 -0
- app/periphery/db/models/evidence.py +19 -0
- app/periphery/db/models/experiences.py +33 -0
- app/periphery/db/models/instance_metadata.py +17 -0
- app/periphery/db/models/memories.py +39 -0
- app/periphery/db/models/metadata.py +6 -0
- app/periphery/db/models/registry.py +18 -0
- app/periphery/db/models/telemetry.py +174 -0
- app/periphery/db/models/utility.py +19 -0
- app/periphery/db/models/views.py +154 -0
- app/periphery/db/repos/__init__.py +1 -0
- app/periphery/db/repos/relational/__init__.py +1 -0
- app/periphery/db/repos/relational/associations_repo.py +117 -0
- app/periphery/db/repos/relational/episodes_repo.py +188 -0
- app/periphery/db/repos/relational/evidence_repo.py +82 -0
- app/periphery/db/repos/relational/experiences_repo.py +41 -0
- app/periphery/db/repos/relational/memories_repo.py +99 -0
- app/periphery/db/repos/relational/read_policy_repo.py +202 -0
- app/periphery/db/repos/relational/telemetry_repo.py +161 -0
- app/periphery/db/repos/relational/utility_repo.py +30 -0
- app/periphery/db/repos/semantic/__init__.py +1 -0
- app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
- app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
- app/periphery/db/session.py +10 -0
- app/periphery/db/uow.py +75 -0
- app/periphery/embeddings/__init__.py +1 -0
- app/periphery/embeddings/local_provider.py +35 -0
- app/periphery/embeddings/query_vector_search.py +18 -0
- app/periphery/episodes/__init__.py +1 -0
- app/periphery/episodes/claude_code.py +387 -0
- app/periphery/episodes/codex.py +423 -0
- app/periphery/episodes/launcher.py +66 -0
- app/periphery/episodes/normalization.py +31 -0
- app/periphery/episodes/poller.py +299 -0
- app/periphery/episodes/source_discovery.py +66 -0
- app/periphery/episodes/tool_filter.py +165 -0
- app/periphery/identity/__init__.py +1 -0
- app/periphery/identity/claude_hook_install.py +67 -0
- app/periphery/identity/claude_runtime.py +83 -0
- app/periphery/identity/codex_runtime.py +32 -0
- app/periphery/identity/compatibility.py +38 -0
- app/periphery/identity/resolver.py +163 -0
- app/periphery/session_state/__init__.py +1 -0
- app/periphery/session_state/file_store.py +100 -0
- app/periphery/telemetry/__init__.py +33 -0
- app/periphery/telemetry/operation_summary.py +299 -0
- app/periphery/telemetry/session_selection.py +156 -0
- app/periphery/telemetry/sync_summary.py +65 -0
- app/periphery/validation/__init__.py +1 -0
- app/periphery/validation/integrity_validation.py +253 -0
- app/periphery/validation/semantic_validation.py +94 -0
- shellbrain-0.1.0.dist-info/METADATA +130 -0
- shellbrain-0.1.0.dist-info/RECORD +165 -0
- shellbrain-0.1.0.dist-info/WHEEL +5 -0
- shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
- 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:]))
|