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,26 @@
1
+ """This module defines the create-shellbrain use-case orchestration entry point."""
2
+
3
+ from uuid import uuid4
4
+
5
+ from app.core.contracts.requests import MemoryCreateRequest
6
+ from app.core.contracts.responses import OperationResult
7
+ from app.core.interfaces.embeddings import IEmbeddingProvider
8
+ from app.core.interfaces.unit_of_work import IUnitOfWork
9
+ from app.core.policies.create_policy.pipeline import apply_create_plan, build_create_plan
10
+
11
+
12
+ def execute_create_memory(
13
+ request: MemoryCreateRequest,
14
+ uow: IUnitOfWork,
15
+ *,
16
+ embedding_provider: IEmbeddingProvider,
17
+ embedding_model: str,
18
+ ) -> OperationResult:
19
+ """This function orchestrates create flow for an already-validated request."""
20
+
21
+ memory_id = str(uuid4())
22
+ payload = request.model_dump(mode="python")
23
+ payload["memory_id"] = memory_id
24
+ plan = build_create_plan(payload, embedding_model=embedding_model)
25
+ apply_create_plan(plan, uow, embedding_provider=embedding_provider)
26
+ return OperationResult(status="ok", data={"memory_id": memory_id, "planned_side_effects": plan})
@@ -0,0 +1,159 @@
1
+ """Core working-session lifecycle rules for per-caller session state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from app.core.entities.identity import CallerIdentity, IdentityTrustLevel
9
+ from app.core.entities.session_state import SessionState
10
+ from app.core.interfaces.clock import IClock
11
+ from app.core.interfaces.session_state_store import ISessionStateStore
12
+
13
+
14
+ IDLE_EXPIRY = timedelta(hours=24)
15
+ GC_EXPIRY = timedelta(days=7)
16
+
17
+
18
+ class SessionStateManager:
19
+ """Application service for loading, touching, and mutating working session state."""
20
+
21
+ def __init__(self, *, store: ISessionStateStore, clock: IClock | None = None) -> None:
22
+ """Capture the persistence store and optional clock dependency."""
23
+
24
+ self._store = store
25
+ self._clock = clock
26
+
27
+ def load_active_state(self, *, repo_root, caller_identity: CallerIdentity | None) -> SessionState | None:
28
+ """Load and touch the active caller state when identity is trusted."""
29
+
30
+ if caller_identity is None or caller_identity.trust_level != IdentityTrustLevel.TRUSTED:
31
+ return None
32
+ now_iso = self._now_iso()
33
+ state = self._store.load(repo_root=repo_root, caller_id=caller_identity.canonical_id or "")
34
+ if state is None:
35
+ state = SessionState(
36
+ caller_id=caller_identity.canonical_id or "",
37
+ host_app=caller_identity.host_app,
38
+ host_session_key=caller_identity.host_session_key,
39
+ agent_key=caller_identity.agent_key,
40
+ session_started_at=now_iso,
41
+ last_seen_at=now_iso,
42
+ )
43
+ else:
44
+ state = self.reset_if_idle(state, now_iso=now_iso)
45
+ state.last_seen_at = now_iso
46
+ self._store.save(repo_root=repo_root, state=state)
47
+ return state
48
+
49
+ def record_events(
50
+ self,
51
+ *,
52
+ repo_root,
53
+ caller_identity: CallerIdentity | None,
54
+ episode_id: str,
55
+ event_ids: list[str],
56
+ ) -> SessionState | None:
57
+ """Persist the latest events context for one trusted caller."""
58
+
59
+ state = self.load_active_state(repo_root=repo_root, caller_identity=caller_identity)
60
+ if state is None:
61
+ return None
62
+ now_iso = self._now_iso()
63
+ state.last_events_episode_id = episode_id
64
+ state.last_events_event_ids = list(event_ids)
65
+ state.last_events_at = now_iso
66
+ state.last_seen_at = now_iso
67
+ self._store.save(repo_root=repo_root, state=state)
68
+ return state
69
+
70
+ def record_problem(
71
+ self,
72
+ *,
73
+ repo_root,
74
+ caller_identity: CallerIdentity | None,
75
+ problem_id: str,
76
+ ) -> SessionState | None:
77
+ """Persist the active problem for one trusted caller."""
78
+
79
+ state = self.load_active_state(repo_root=repo_root, caller_identity=caller_identity)
80
+ if state is None:
81
+ return None
82
+ state.current_problem_id = problem_id
83
+ state.last_seen_at = self._now_iso()
84
+ self._store.save(repo_root=repo_root, state=state)
85
+ return state
86
+
87
+ def record_guidance(
88
+ self,
89
+ *,
90
+ repo_root,
91
+ caller_identity: CallerIdentity | None,
92
+ problem_id: str,
93
+ ) -> SessionState | None:
94
+ """Persist the latest emitted guidance timestamp for one trusted caller."""
95
+
96
+ state = self.load_active_state(repo_root=repo_root, caller_identity=caller_identity)
97
+ if state is None:
98
+ return None
99
+ now_iso = self._now_iso()
100
+ state.last_guidance_problem_id = problem_id
101
+ state.last_guidance_at = now_iso
102
+ state.last_seen_at = now_iso
103
+ self._store.save(repo_root=repo_root, state=state)
104
+ return state
105
+
106
+ def clear_for_caller(self, *, repo_root, caller_id: str) -> None:
107
+ """Delete one caller state."""
108
+
109
+ self._store.delete(repo_root=repo_root, caller_id=caller_id)
110
+
111
+ def garbage_collect(self, *, repo_root) -> list[str]:
112
+ """Delete states older than the configured GC threshold."""
113
+
114
+ cutoff_iso = (self._now() - GC_EXPIRY).isoformat()
115
+ return self._store.gc(repo_root=repo_root, older_than_iso=cutoff_iso)
116
+
117
+ @staticmethod
118
+ def reset_if_idle(state: SessionState, *, now_iso: str) -> SessionState:
119
+ """Reset working-session fields when the caller has been idle too long."""
120
+
121
+ try:
122
+ last_seen = _parse_iso(state.last_seen_at)
123
+ except ValueError:
124
+ last_seen = datetime.min.replace(tzinfo=timezone.utc)
125
+ now = _parse_iso(now_iso)
126
+ if now - last_seen < IDLE_EXPIRY:
127
+ return state
128
+ return replace(
129
+ state,
130
+ session_started_at=now_iso,
131
+ last_seen_at=now_iso,
132
+ current_problem_id=None,
133
+ last_events_episode_id=None,
134
+ last_events_event_ids=[],
135
+ last_events_at=None,
136
+ last_guidance_at=None,
137
+ last_guidance_problem_id=None,
138
+ )
139
+
140
+ def _now(self) -> datetime:
141
+ """Return the current UTC time using the injected clock when present."""
142
+
143
+ if self._clock is not None:
144
+ return self._clock.now()
145
+ return datetime.now(timezone.utc)
146
+
147
+ def _now_iso(self) -> str:
148
+ """Return the current UTC time in ISO-8601 form."""
149
+
150
+ return self._now().isoformat()
151
+
152
+
153
+ def _parse_iso(value: str) -> datetime:
154
+ """Parse one ISO timestamp into a timezone-aware datetime."""
155
+
156
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
157
+ if parsed.tzinfo is None:
158
+ return parsed.replace(tzinfo=timezone.utc)
159
+ return parsed.astimezone(timezone.utc)
@@ -0,0 +1,21 @@
1
+ """This module defines the read-shellbrain use-case orchestration entry point."""
2
+
3
+ from app.core.contracts.requests import MemoryReadRequest
4
+ from app.core.contracts.responses import OperationResult
5
+ from app.core.interfaces.unit_of_work import IUnitOfWork
6
+ from app.core.policies.read_policy.pipeline import build_context_pack
7
+
8
+
9
+ def execute_read_memory(request: MemoryReadRequest, uow: IUnitOfWork) -> OperationResult:
10
+ """This function orchestrates read flow with retrieval and context-pack policy hooks."""
11
+
12
+ payload = request.model_dump(mode="python")
13
+ context_pack = build_context_pack(
14
+ payload,
15
+ keyword_retrieval=uow.keyword_retrieval,
16
+ memories=uow.memories,
17
+ semantic_retrieval=uow.semantic_retrieval,
18
+ read_policy=uow.read_policy,
19
+ vector_search=uow.vector_search,
20
+ )
21
+ return OperationResult(status="ok", data={"pack": context_pack})
@@ -0,0 +1,19 @@
1
+ """Thin orchestration for sync-run telemetry writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from app.core.entities.telemetry import EpisodeSyncRunRecord, EpisodeSyncToolTypeRecord
8
+ from app.core.interfaces.unit_of_work import IUnitOfWork
9
+
10
+
11
+ def record_episode_sync_telemetry(
12
+ *,
13
+ uow: IUnitOfWork,
14
+ run: EpisodeSyncRunRecord,
15
+ tool_types: Sequence[EpisodeSyncToolTypeRecord] = (),
16
+ ) -> None:
17
+ """Persist one sync-run row and its per-tool aggregates."""
18
+
19
+ uow.telemetry.insert_episode_sync_run(run, tuple(tool_types))
@@ -0,0 +1,32 @@
1
+ """Thin orchestration for operation-level telemetry writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from app.core.entities.telemetry import (
8
+ OperationInvocationRecord,
9
+ ReadResultItemRecord,
10
+ ReadSummaryRecord,
11
+ WriteEffectItemRecord,
12
+ WriteSummaryRecord,
13
+ )
14
+ from app.core.interfaces.unit_of_work import IUnitOfWork
15
+
16
+
17
+ def record_operation_telemetry(
18
+ *,
19
+ uow: IUnitOfWork,
20
+ invocation: OperationInvocationRecord,
21
+ read_summary: ReadSummaryRecord | None = None,
22
+ read_items: Sequence[ReadResultItemRecord] = (),
23
+ write_summary: WriteSummaryRecord | None = None,
24
+ write_items: Sequence[WriteEffectItemRecord] = (),
25
+ ) -> None:
26
+ """Persist one invocation row and any attached read or write summaries."""
27
+
28
+ uow.telemetry.insert_operation_invocation(invocation)
29
+ if read_summary is not None:
30
+ uow.telemetry.insert_read_summary(read_summary, tuple(read_items))
31
+ if write_summary is not None:
32
+ uow.telemetry.insert_write_summary(write_summary, tuple(write_items))
@@ -0,0 +1,162 @@
1
+ """Synchronize normalized host transcript events into the episodic provenance tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from datetime import datetime, timezone
7
+ import json
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from app.core.entities.episodes import Episode, EpisodeEvent, EpisodeEventSource, EpisodeStatus
12
+ from app.core.interfaces.unit_of_work import IUnitOfWork
13
+
14
+
15
+ def sync_episode(
16
+ *,
17
+ repo_id: str,
18
+ host_app: str,
19
+ host_session_key: str,
20
+ thread_id: str,
21
+ transcript_path: str,
22
+ normalized_events: Sequence[dict[str, Any]],
23
+ uow: IUnitOfWork,
24
+ ) -> dict[str, Any]:
25
+ """Import one already-normalized host transcript into episodes and episode events."""
26
+
27
+ counts = _count_normalized_events(normalized_events)
28
+ episode = uow.episodes.get_episode_by_thread(repo_id=repo_id, thread_id=thread_id)
29
+ imported_count = 0
30
+
31
+ if episode is None:
32
+ started_at = _earliest_event_timestamp(normalized_events) or datetime.now(timezone.utc)
33
+ episode = Episode(
34
+ id=str(uuid4()),
35
+ repo_id=repo_id,
36
+ host_app=host_app,
37
+ thread_id=thread_id,
38
+ status=EpisodeStatus.ACTIVE,
39
+ started_at=started_at,
40
+ created_at=datetime.now(timezone.utc),
41
+ )
42
+ uow.episodes.create_episode(episode)
43
+
44
+ existing_keys = set(uow.episodes.list_event_keys(episode_id=episode.id))
45
+ next_seq = uow.episodes.next_event_seq(episode_id=episode.id)
46
+ for normalized_event in normalized_events:
47
+ host_event_key = str(normalized_event["host_event_key"])
48
+ if host_event_key in existing_keys:
49
+ continue
50
+ created_at = _parse_timestamp(str(normalized_event["occurred_at"]))
51
+ source = EpisodeEventSource(str(normalized_event["source"]))
52
+ uow.episodes.append_event(
53
+ EpisodeEvent(
54
+ id=str(uuid4()),
55
+ episode_id=episode.id,
56
+ seq=next_seq,
57
+ host_event_key=host_event_key,
58
+ source=source,
59
+ content=json.dumps(normalized_event),
60
+ created_at=created_at,
61
+ )
62
+ )
63
+ existing_keys.add(host_event_key)
64
+ next_seq += 1
65
+ imported_count += 1
66
+
67
+ return {
68
+ "episode_id": episode.id,
69
+ "thread_id": thread_id,
70
+ "imported_event_count": imported_count,
71
+ "transcript_path": transcript_path,
72
+ "total_event_count": counts["total_event_count"],
73
+ "user_event_count": counts["user_event_count"],
74
+ "assistant_event_count": counts["assistant_event_count"],
75
+ "tool_event_count": counts["tool_event_count"],
76
+ "system_event_count": counts["system_event_count"],
77
+ "tool_type_counts": counts["tool_type_counts"],
78
+ }
79
+
80
+
81
+ def sync_episode_from_host(
82
+ *,
83
+ repo_id: str,
84
+ host_app: str,
85
+ host_session_key: str,
86
+ uow: IUnitOfWork,
87
+ search_roots,
88
+ last_known_path=None,
89
+ ) -> dict[str, Any]:
90
+ """Backward-compatible host sync wrapper used by existing callers."""
91
+
92
+ from app.periphery.episodes.normalization import normalize_host_transcript
93
+ from app.periphery.episodes.source_discovery import resolve_host_transcript_source
94
+
95
+ transcript_path = resolve_host_transcript_source(
96
+ host_app=host_app,
97
+ host_session_key=host_session_key,
98
+ search_roots=search_roots,
99
+ last_known_path=last_known_path,
100
+ )
101
+ normalized_events = normalize_host_transcript(
102
+ host_app=host_app,
103
+ host_session_key=host_session_key,
104
+ transcript_path=transcript_path,
105
+ )
106
+ return sync_episode(
107
+ repo_id=repo_id,
108
+ host_app=host_app,
109
+ host_session_key=host_session_key,
110
+ thread_id=f"{host_app}:{host_session_key}",
111
+ transcript_path=str(transcript_path),
112
+ normalized_events=normalized_events,
113
+ uow=uow,
114
+ )
115
+
116
+
117
+ def _parse_timestamp(value: str) -> datetime:
118
+ """Parse one host timestamp into a timezone-aware datetime."""
119
+
120
+ if not value:
121
+ return datetime.now(timezone.utc)
122
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
123
+
124
+
125
+ def _earliest_event_timestamp(events: Sequence[dict[str, Any]]) -> datetime | None:
126
+ """Return the earliest normalized event timestamp if any exist."""
127
+
128
+ timestamps = [
129
+ _parse_timestamp(str(event["occurred_at"]))
130
+ for event in events
131
+ if event.get("occurred_at")
132
+ ]
133
+ if not timestamps:
134
+ return None
135
+ return min(timestamps)
136
+
137
+
138
+ def _count_normalized_events(events: Sequence[dict[str, Any]]) -> dict[str, Any]:
139
+ """Compute telemetry-friendly source and tool-type counts from normalized events."""
140
+
141
+ tool_type_counts: dict[str, int] = {}
142
+ counts = {
143
+ "total_event_count": len(events),
144
+ "user_event_count": 0,
145
+ "assistant_event_count": 0,
146
+ "tool_event_count": 0,
147
+ "system_event_count": 0,
148
+ "tool_type_counts": tool_type_counts,
149
+ }
150
+ for event in events:
151
+ source = str(event.get("source") or "")
152
+ if source == "user":
153
+ counts["user_event_count"] += 1
154
+ elif source == "assistant":
155
+ counts["assistant_event_count"] += 1
156
+ elif source == "tool":
157
+ counts["tool_event_count"] += 1
158
+ tool_name = str(event.get("tool_name") or "unknown_tool")
159
+ tool_type_counts[tool_name] = tool_type_counts.get(tool_name, 0) + 1
160
+ elif source == "system":
161
+ counts["system_event_count"] += 1
162
+ return counts
@@ -0,0 +1,40 @@
1
+ """This module defines the update-shellbrain use-case orchestration entry point."""
2
+
3
+ from app.core.contracts.requests import MemoryBatchUpdateRequest, MemoryUpdateRequest
4
+ from app.core.contracts.responses import OperationResult
5
+ from app.core.interfaces.unit_of_work import IUnitOfWork
6
+ from app.core.policies.update_policy.pipeline import apply_update_plan, build_update_plan
7
+
8
+
9
+ def execute_update_memory(request: MemoryUpdateRequest | MemoryBatchUpdateRequest, uow: IUnitOfWork) -> OperationResult:
10
+ """This function orchestrates update flow for an already-validated request."""
11
+
12
+ if isinstance(request, MemoryBatchUpdateRequest):
13
+ plan: list[dict[str, object]] = []
14
+ updated_memory_ids: list[str] = []
15
+ for item in request.updates:
16
+ plan.extend(
17
+ build_update_plan(
18
+ {
19
+ "repo_id": request.repo_id,
20
+ "memory_id": item.memory_id,
21
+ "update": item.update.model_dump(mode="python"),
22
+ }
23
+ )
24
+ )
25
+ updated_memory_ids.append(item.memory_id)
26
+ apply_update_plan(plan, uow)
27
+ problem_id = request.updates[0].update.problem_id
28
+ return OperationResult(
29
+ status="ok",
30
+ data={
31
+ "problem_id": problem_id,
32
+ "updated_memory_ids": updated_memory_ids,
33
+ "applied_count": len(request.updates),
34
+ "planned_side_effects": plan,
35
+ },
36
+ )
37
+
38
+ plan = build_update_plan(request.model_dump(mode="python"))
39
+ apply_update_plan(plan, uow)
40
+ return OperationResult(status="ok", data={"memory_id": request.memory_id, "planned_side_effects": plan})
@@ -0,0 +1 @@
1
+ """This package contains Alembic migration configuration and revisions."""
app/migrations/env.py ADDED
@@ -0,0 +1,65 @@
1
+ """This module defines the Alembic environment for online and offline PostgreSQL migrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from logging.config import fileConfig
7
+
8
+ from alembic import context
9
+ from sqlalchemy import engine_from_config, pool
10
+
11
+ from app.periphery.db.models.registry import target_metadata
12
+
13
+
14
+ config = context.config
15
+
16
+ if config.config_file_name is not None:
17
+ fileConfig(config.config_file_name)
18
+
19
+ configured_url = config.get_main_option("sqlalchemy.url")
20
+ if not configured_url:
21
+ admin_dsn = os.getenv("SHELLBRAIN_DB_ADMIN_DSN")
22
+ app_dsn = os.getenv("SHELLBRAIN_DB_DSN")
23
+ if admin_dsn:
24
+ config.set_main_option("sqlalchemy.url", admin_dsn)
25
+ elif app_dsn:
26
+ config.set_main_option("sqlalchemy.url", app_dsn)
27
+
28
+
29
+ def run_migrations_offline() -> None:
30
+ """This function runs migrations in offline mode using URL-only context."""
31
+
32
+ url = config.get_main_option("sqlalchemy.url")
33
+ context.configure(
34
+ url=url,
35
+ target_metadata=target_metadata,
36
+ literal_binds=True,
37
+ compare_type=True,
38
+ dialect_opts={"paramstyle": "named"},
39
+ )
40
+
41
+ with context.begin_transaction():
42
+ context.run_migrations()
43
+
44
+
45
+ def run_migrations_online() -> None:
46
+ """This function runs migrations in online mode using an engine connection."""
47
+
48
+ connectable = engine_from_config(
49
+ config.get_section(config.config_ini_section, {}),
50
+ prefix="sqlalchemy.",
51
+ poolclass=pool.NullPool,
52
+ future=True,
53
+ )
54
+
55
+ with connectable.connect() as connection:
56
+ context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
57
+
58
+ with context.begin_transaction():
59
+ context.run_migrations()
60
+
61
+
62
+ if context.is_offline_mode():
63
+ run_migrations_offline()
64
+ else:
65
+ run_migrations_online()