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