ithrl 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 (108) hide show
  1. ithrl/__init__.py +1 -0
  2. ithrl/__main__.py +6 -0
  3. ithrl/adapters/__init__.py +49 -0
  4. ithrl/adapters/_jsonl.py +266 -0
  5. ithrl/adapters/codex/__init__.py +5 -0
  6. ithrl/adapters/codex/context.py +204 -0
  7. ithrl/adapters/codex/decisions.py +224 -0
  8. ithrl/adapters/codex/discovery.py +60 -0
  9. ithrl/adapters/codex/parser.py +191 -0
  10. ithrl/adapters/contracts.py +106 -0
  11. ithrl/adapters/copilot/__init__.py +5 -0
  12. ithrl/adapters/copilot/context.py +182 -0
  13. ithrl/adapters/copilot/decisions.py +294 -0
  14. ithrl/adapters/copilot/discovery.py +28 -0
  15. ithrl/adapters/copilot/parser.py +283 -0
  16. ithrl/adapters/discovery.py +220 -0
  17. ithrl/adapters/errors.py +52 -0
  18. ithrl/adapters/registry.py +433 -0
  19. ithrl/behavior.py +138 -0
  20. ithrl/calibration.py +434 -0
  21. ithrl/case/__init__.py +33 -0
  22. ithrl/case/contracts.py +88 -0
  23. ithrl/case/evidence.py +169 -0
  24. ithrl/case/filesystem.py +340 -0
  25. ithrl/case/source.py +208 -0
  26. ithrl/case/source_context.py +119 -0
  27. ithrl/case/source_storage.py +234 -0
  28. ithrl/case/storage.py +377 -0
  29. ithrl/cli/__init__.py +968 -0
  30. ithrl/cli/extract.py +478 -0
  31. ithrl/cli/native.py +484 -0
  32. ithrl/cli/output.py +786 -0
  33. ithrl/cli/paths.py +24 -0
  34. ithrl/cli/sampled.py +485 -0
  35. ithrl/cli/sampled_output.py +472 -0
  36. ithrl/cli/sampled_reduction.py +1036 -0
  37. ithrl/cli/sampled_session.py +342 -0
  38. ithrl/cli/shrink.py +523 -0
  39. ithrl/context.py +465 -0
  40. ithrl/decisions.py +271 -0
  41. ithrl/examples/synthetic-trace.json +46 -0
  42. ithrl/execution/__init__.py +68 -0
  43. ithrl/execution/budget.py +146 -0
  44. ithrl/execution/contracts.py +426 -0
  45. ithrl/execution/preflight.py +346 -0
  46. ithrl/experiment.py +453 -0
  47. ithrl/extraction.py +206 -0
  48. ithrl/measurement.py +315 -0
  49. ithrl/observations.py +1148 -0
  50. ithrl/path_safety.py +100 -0
  51. ithrl/reduce/__init__.py +53 -0
  52. ithrl/reduce/contracts.py +508 -0
  53. ithrl/reduce/engine.py +901 -0
  54. ithrl/reduce/search.py +178 -0
  55. ithrl/results/__init__.py +49 -0
  56. ithrl/results/models.py +294 -0
  57. ithrl/results/storage.py +485 -0
  58. ithrl/results/summary.py +420 -0
  59. ithrl/results/validation.py +611 -0
  60. ithrl/run_state.py +1135 -0
  61. ithrl/runner/__init__.py +44 -0
  62. ithrl/runner/codex/__init__.py +17 -0
  63. ithrl/runner/codex/capability.py +708 -0
  64. ithrl/runner/codex/configuration.py +529 -0
  65. ithrl/runner/codex/isolation.py +396 -0
  66. ithrl/runner/codex/materialize.py +358 -0
  67. ithrl/runner/codex/policy.py +67 -0
  68. ithrl/runner/codex/protocol.py +649 -0
  69. ithrl/runner/codex/protocol_contracts.py +171 -0
  70. ithrl/runner/codex/runner.py +533 -0
  71. ithrl/runner/codex/runtime_configuration.py +264 -0
  72. ithrl/runner/codex/schema.py +527 -0
  73. ithrl/runner/codex/transcript.py +130 -0
  74. ithrl/runner/codex/worker.py +175 -0
  75. ithrl/runner/contracts.py +419 -0
  76. ithrl/runner/copilot/__init__.py +18 -0
  77. ithrl/runner/copilot/capability.py +825 -0
  78. ithrl/runner/copilot/configuration.py +201 -0
  79. ithrl/runner/copilot/isolation.py +374 -0
  80. ithrl/runner/copilot/materialize.py +484 -0
  81. ithrl/runner/copilot/policy.py +110 -0
  82. ithrl/runner/copilot/protocol/__init__.py +18 -0
  83. ithrl/runner/copilot/protocol/contracts.py +74 -0
  84. ithrl/runner/copilot/protocol/decoding.py +93 -0
  85. ithrl/runner/copilot/protocol/parser.py +418 -0
  86. ithrl/runner/copilot/protocol/state.py +130 -0
  87. ithrl/runner/copilot/runner.py +425 -0
  88. ithrl/runner/copilot/worker.py +351 -0
  89. ithrl/runner/evidence.py +345 -0
  90. ithrl/runner/fake.py +107 -0
  91. ithrl/runner/registry.py +90 -0
  92. ithrl/runner/runtime/__init__.py +50 -0
  93. ithrl/runner/runtime/contracts.py +157 -0
  94. ithrl/runner/runtime/output.py +184 -0
  95. ithrl/runner/runtime/process.py +954 -0
  96. ithrl/runner/runtime/protection.py +562 -0
  97. ithrl/runner/runtime/state.py +282 -0
  98. ithrl/runner/validation.py +376 -0
  99. ithrl/sampling.py +412 -0
  100. ithrl/statistics.py +276 -0
  101. ithrl/synthetic.py +511 -0
  102. ithrl/timestamps.py +26 -0
  103. ithrl/workflow.py +570 -0
  104. ithrl-0.1.0.dist-info/METADATA +118 -0
  105. ithrl-0.1.0.dist-info/RECORD +108 -0
  106. ithrl-0.1.0.dist-info/WHEEL +4 -0
  107. ithrl-0.1.0.dist-info/entry_points.txt +2 -0
  108. ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,283 @@
1
+ """Validate the supported GitHub Copilot CLI session format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Never, cast
8
+
9
+ from ithrl.adapters._jsonl import JsonlDocument
10
+ from ithrl.adapters.contracts import NativeSession, SessionFormat, SessionMetadata
11
+ from ithrl.adapters.errors import AdapterError, AdapterErrorCode
12
+ from ithrl.context import JsonObject, JsonValue
13
+ from ithrl.timestamps import validate_rfc3339_timestamp
14
+
15
+ _FORMAT_NAME = "copilot-events-jsonl"
16
+ _PRODUCER = "copilot-agent"
17
+ _SUPPORTED_RECORD_SCHEMA = 1
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class _Record:
22
+ line: int
23
+ record_id: str
24
+ parent_id: str | None
25
+ record_type: str
26
+ data: JsonObject
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class _ToolCall:
31
+ turn_id: str
32
+ started: bool = False
33
+ completed: bool = False
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class _RelationshipState:
38
+ open_turns: dict[str, int] = field(default_factory=dict)
39
+ tools: dict[str, _ToolCall] = field(default_factory=dict)
40
+
41
+
42
+ class _CopilotParser:
43
+ format_name = _FORMAT_NAME
44
+
45
+ def matches(self, document: JsonlDocument) -> bool:
46
+ """Return whether the first record has the Copilot session signature."""
47
+
48
+ if not document.records:
49
+ return False
50
+ first = document.records[0]
51
+ data = first.get("data")
52
+ return (
53
+ first.get("type") == "session.start"
54
+ and isinstance(data, dict)
55
+ and data.get("producer") == _PRODUCER
56
+ and "sessionId" in data
57
+ and "version" in data
58
+ and "copilotVersion" in data
59
+ )
60
+
61
+ def parse(self, document: JsonlDocument) -> NativeSession:
62
+ """Validate and preserve one bounded Copilot session document."""
63
+
64
+ if not self.matches(document):
65
+ raise AdapterError(
66
+ AdapterErrorCode.UNSUPPORTED_FORMAT,
67
+ "session source is not a GitHub Copilot CLI event log",
68
+ )
69
+
70
+ records = _validate_envelopes(document.records)
71
+ metadata = _parse_metadata(records)
72
+ _validate_turns_and_tools(records)
73
+ return NativeSession(
74
+ metadata=metadata,
75
+ source=document.source,
76
+ native_bytes=document.raw,
77
+ records=document.records,
78
+ )
79
+
80
+
81
+ PARSER = _CopilotParser()
82
+
83
+
84
+ def _validate_envelopes(records: tuple[JsonObject, ...]) -> tuple[_Record, ...]:
85
+ validated: list[_Record] = []
86
+ record_ids: set[str] = set()
87
+ for line, record in enumerate(records, start=1):
88
+ record_id = _required_string(record.get("id"), line, "session record ID is missing")
89
+ if record_id in record_ids:
90
+ _invalid("session record IDs must be unique", line)
91
+ record_ids.add(record_id)
92
+
93
+ parent = record.get("parentId")
94
+ if parent is not None and (not isinstance(parent, str) or not parent):
95
+ _invalid("session record parent ID must be a non-empty string or null", line)
96
+
97
+ timestamp = _required_string(
98
+ record.get("timestamp"), line, "session record timestamp is missing"
99
+ )
100
+ try:
101
+ validate_rfc3339_timestamp(timestamp, field="session record timestamp")
102
+ except ValueError:
103
+ raise AdapterError(
104
+ AdapterErrorCode.INVALID_SESSION,
105
+ "session record timestamp is not valid RFC 3339",
106
+ line=line,
107
+ ) from None
108
+ record_type = _required_string(record.get("type"), line, "session record type is missing")
109
+ data = record.get("data")
110
+ if not isinstance(data, dict):
111
+ _invalid("session record data must be an object", line)
112
+
113
+ validated.append(
114
+ _Record(
115
+ line=line,
116
+ record_id=record_id,
117
+ parent_id=parent,
118
+ record_type=record_type,
119
+ data=cast(JsonObject, data),
120
+ )
121
+ )
122
+ return tuple(validated)
123
+
124
+
125
+ def _parse_metadata(records: tuple[_Record, ...]) -> SessionMetadata:
126
+ starts = [record for record in records if record.record_type == "session.start"]
127
+ if len(starts) != 1 or starts[0].line != 1:
128
+ _invalid("session must begin with exactly one session.start record", 1)
129
+ start = starts[0]
130
+
131
+ session_id = _required_string(start.data.get("sessionId"), start.line, "session ID is missing")
132
+ record_schema = start.data.get("version")
133
+ if type(record_schema) is not int or record_schema != _SUPPORTED_RECORD_SCHEMA:
134
+ raise AdapterError(
135
+ AdapterErrorCode.UNSUPPORTED_VERSION,
136
+ "GitHub Copilot CLI record schema is not supported",
137
+ line=start.line,
138
+ )
139
+ producer_version = _required_string(
140
+ start.data.get("copilotVersion"), start.line, "producer version is missing"
141
+ )
142
+ captured_at = _required_string(
143
+ start.data.get("startTime"), start.line, "session start time is missing"
144
+ )
145
+ try:
146
+ validate_rfc3339_timestamp(captured_at, field="session start time")
147
+ except ValueError:
148
+ raise AdapterError(
149
+ AdapterErrorCode.INVALID_SESSION,
150
+ "session start time is not valid RFC 3339",
151
+ line=start.line,
152
+ ) from None
153
+
154
+ return SessionMetadata(
155
+ format=SessionFormat(
156
+ harness="copilot",
157
+ name=_FORMAT_NAME,
158
+ producer_version=producer_version,
159
+ record_schema_version=record_schema,
160
+ ),
161
+ session_id=session_id,
162
+ captured_at=captured_at,
163
+ )
164
+
165
+
166
+ def _validate_turns_and_tools(records: tuple[_Record, ...]) -> None:
167
+ state = _RelationshipState()
168
+ for record in records:
169
+ if validator := _RELATIONSHIP_VALIDATORS.get(record.record_type):
170
+ validator(record, state)
171
+
172
+
173
+ def _start_turn(record: _Record, state: _RelationshipState) -> None:
174
+ turn_id = _turn_id(record)
175
+ state.open_turns[turn_id] = state.open_turns.get(turn_id, 0) + 1
176
+
177
+
178
+ def _end_turn(record: _Record, state: _RelationshipState) -> None:
179
+ turn_id = _turn_id(record)
180
+ depth = state.open_turns.get(turn_id, 0)
181
+ if depth == 0:
182
+ _invalid("assistant turn ends before it starts", record.line)
183
+ if depth == 1:
184
+ del state.open_turns[turn_id]
185
+ else:
186
+ state.open_turns[turn_id] = depth - 1
187
+
188
+
189
+ def _add_tool_requests(
190
+ record: _Record,
191
+ state: _RelationshipState,
192
+ ) -> None:
193
+ requests = record.data.get("toolRequests")
194
+ if requests is None:
195
+ return
196
+ if not isinstance(requests, list):
197
+ _invalid("assistant tool requests must be an array", record.line)
198
+ if not requests:
199
+ return
200
+
201
+ turn_id = _turn_id(record)
202
+ _require_open_turn(turn_id, record.line, state.open_turns)
203
+ for request in requests:
204
+ if not isinstance(request, dict):
205
+ _invalid("assistant tool request must be an object", record.line)
206
+ tool_call_id = _required_string(
207
+ request.get("toolCallId"), record.line, "assistant tool call ID is missing"
208
+ )
209
+ if tool_call_id in state.tools:
210
+ _invalid("assistant tool call IDs must be unique", record.line)
211
+ state.tools[tool_call_id] = _ToolCall(turn_id=turn_id)
212
+
213
+
214
+ def _start_tool(
215
+ record: _Record,
216
+ state: _RelationshipState,
217
+ ) -> None:
218
+ tool_call_id = _required_string(
219
+ record.data.get("toolCallId"), record.line, "tool execution call ID is missing"
220
+ )
221
+ turn_id = _turn_id(record)
222
+ tool = state.tools.get(tool_call_id)
223
+ if tool is None:
224
+ _invalid("tool execution starts before its assistant request", record.line)
225
+ _require_open_turn(turn_id, record.line, state.open_turns)
226
+ if tool.turn_id != turn_id:
227
+ _invalid("tool execution and assistant request use different turns", record.line)
228
+ if tool.started:
229
+ _invalid("tool execution starts more than once", record.line)
230
+ tool.started = True
231
+
232
+
233
+ def _complete_tool(
234
+ record: _Record,
235
+ state: _RelationshipState,
236
+ ) -> None:
237
+ tool_call_id = _required_string(
238
+ record.data.get("toolCallId"), record.line, "tool completion call ID is missing"
239
+ )
240
+ turn_id = _turn_id(record)
241
+ tool = state.tools.get(tool_call_id)
242
+ if tool is None or not tool.started:
243
+ _invalid("tool execution completes before it starts", record.line)
244
+ _require_open_turn(turn_id, record.line, state.open_turns)
245
+ if tool.turn_id != turn_id:
246
+ _invalid("tool completion and assistant request use different turns", record.line)
247
+ if tool.completed:
248
+ _invalid("tool execution completes more than once", record.line)
249
+ tool.completed = True
250
+
251
+
252
+ def _require_open_turn(
253
+ turn_id: str,
254
+ line: int,
255
+ open_turns: dict[str, int],
256
+ ) -> None:
257
+ if not open_turns.get(turn_id, 0):
258
+ _invalid("tool relationship is outside its assistant turn", line)
259
+
260
+
261
+ def _turn_id(record: _Record) -> str:
262
+ return _required_string(record.data.get("turnId"), record.line, "turn ID is missing")
263
+
264
+
265
+ def _required_string(value: JsonValue | None, line: int, message: str) -> str:
266
+ if not isinstance(value, str) or not value:
267
+ _invalid(message, line)
268
+ return value
269
+
270
+
271
+ def _invalid(message: str, line: int) -> Never:
272
+ raise AdapterError(AdapterErrorCode.INVALID_SESSION, message, line=line)
273
+
274
+
275
+ type _RelationshipValidator = Callable[[_Record, _RelationshipState], None]
276
+
277
+ _RELATIONSHIP_VALIDATORS: dict[str, _RelationshipValidator] = {
278
+ "assistant.turn_start": _start_turn,
279
+ "assistant.message": _add_tool_requests,
280
+ "tool.execution_start": _start_tool,
281
+ "tool.execution_complete": _complete_tool,
282
+ "assistant.turn_end": _end_turn,
283
+ }
@@ -0,0 +1,220 @@
1
+ """Bounded contracts and filesystem traversal for native session discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from collections.abc import Callable, Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Annotated, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
13
+
14
+ from ithrl.adapters.contracts import SourceFacts
15
+ from ithrl.adapters.errors import AdapterError, AdapterErrorCode
16
+ from ithrl.behavior import BehaviorType
17
+ from ithrl.decisions import DecisionLimitation
18
+ from ithrl.path_safety import UnsafePathError, validate_directory_chain
19
+ from ithrl.timestamps import validate_rfc3339_timestamp
20
+
21
+ type HarnessName = Literal["codex", "copilot"]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class DiscoveryLimits:
26
+ """Resource limits for one read-only default-store scan."""
27
+
28
+ max_entries: int = 50_000
29
+ max_candidates: int = 4_096
30
+ max_parse_bytes: int = 512 * 1024 * 1024
31
+ max_sessions: int = 10
32
+
33
+ def __post_init__(self) -> None:
34
+ if (
35
+ min(
36
+ self.max_entries,
37
+ self.max_candidates,
38
+ self.max_parse_bytes,
39
+ self.max_sessions,
40
+ )
41
+ < 1
42
+ ):
43
+ raise ValueError("discovery limits must be positive")
44
+ if self.max_candidates > self.max_entries:
45
+ raise ValueError("max_candidates must not exceed max_entries")
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class SessionSource:
50
+ """One candidate native session file found under a known store."""
51
+
52
+ path: Path
53
+ modified_at_ns: int
54
+ size_bytes: int
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class SourceScan:
59
+ """A bounded set of matching source files from one native store."""
60
+
61
+ sources: tuple[SessionSource, ...]
62
+ truncated: bool
63
+
64
+
65
+ class SessionSummary(BaseModel):
66
+ """Native session facts that can be shown without captured content."""
67
+
68
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
69
+
70
+ schema_version: Literal[1] = 1
71
+ harness: HarnessName
72
+ session_id: Annotated[str, Field(min_length=1, max_length=1024)]
73
+ producer_version: Annotated[str, Field(min_length=1, max_length=128)]
74
+ captured_at: Annotated[str, Field(min_length=1, max_length=64)]
75
+ decision_count: Annotated[int, Field(ge=1, le=4096)]
76
+ target_types: Annotated[tuple[BehaviorType, ...], Field(min_length=1, max_length=2)]
77
+ limitations: tuple[DecisionLimitation, ...] = ()
78
+
79
+ @field_validator("captured_at")
80
+ @classmethod
81
+ def validate_captured_at(cls, value: str) -> str:
82
+ return validate_rfc3339_timestamp(value, field="captured_at")
83
+
84
+ @model_validator(mode="after")
85
+ def validate_unique_values(self) -> SessionSummary:
86
+ if len(set(self.target_types)) != len(self.target_types):
87
+ raise ValueError("target_types must not contain duplicates")
88
+ if len(set(self.limitations)) != len(self.limitations):
89
+ raise ValueError("limitations must not contain duplicates")
90
+ return self
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class DiscoveredSession:
95
+ """A display-safe summary paired with its internal source path."""
96
+
97
+ source: SessionSource
98
+ summary: SessionSummary
99
+ source_facts: SourceFacts
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class DiscoveryResult:
104
+ """Bounded supported sessions and scan counts that expose no session content."""
105
+
106
+ sessions: tuple[DiscoveredSession, ...]
107
+ candidates_seen: int
108
+ rejected_candidates: int
109
+ rejection_codes: tuple[AdapterErrorCode, ...]
110
+ truncated: bool
111
+
112
+
113
+ def scan_store(
114
+ root: Path,
115
+ *,
116
+ max_depth: int,
117
+ accepts: Callable[[Path], bool],
118
+ limits: DiscoveryLimits,
119
+ ) -> SourceScan:
120
+ """Return matching regular files without following directory links."""
121
+
122
+ if max_depth < 0:
123
+ raise ValueError("max_depth must not be negative")
124
+ if not _store_is_available(root):
125
+ return SourceScan(sources=(), truncated=False)
126
+
127
+ candidates: list[SessionSource] = []
128
+ matching_entries = 0
129
+ for path, entry_metadata in _walk_store(
130
+ root,
131
+ max_depth=max_depth,
132
+ max_entries=limits.max_entries,
133
+ ):
134
+ if not accepts(path.relative_to(root)):
135
+ continue
136
+ matching_entries += 1
137
+ if stat.S_ISLNK(entry_metadata.st_mode) or not stat.S_ISREG(entry_metadata.st_mode):
138
+ raise AdapterError(
139
+ AdapterErrorCode.UNSAFE_SOURCE,
140
+ "native session candidate must be a regular file, not a link",
141
+ )
142
+ candidates.append(
143
+ SessionSource(
144
+ path=path,
145
+ modified_at_ns=entry_metadata.st_mtime_ns,
146
+ size_bytes=entry_metadata.st_size,
147
+ )
148
+ )
149
+ if len(candidates) == limits.max_candidates * 2:
150
+ candidates.sort(key=_source_sort_key)
151
+ del candidates[limits.max_candidates :]
152
+ candidates.sort(key=_source_sort_key)
153
+ del candidates[limits.max_candidates :]
154
+ return SourceScan(
155
+ sources=tuple(candidates),
156
+ truncated=matching_entries > len(candidates),
157
+ )
158
+
159
+
160
+ def _store_is_available(root: Path) -> bool:
161
+ try:
162
+ metadata = root.lstat()
163
+ except FileNotFoundError:
164
+ return False
165
+ except OSError:
166
+ raise AdapterError(
167
+ AdapterErrorCode.SOURCE_UNAVAILABLE,
168
+ "native session store is unavailable",
169
+ ) from None
170
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
171
+ raise AdapterError(
172
+ AdapterErrorCode.UNSAFE_SOURCE,
173
+ "native session store must be a directory, not a link",
174
+ )
175
+ try:
176
+ validate_directory_chain(root)
177
+ except UnsafePathError:
178
+ raise AdapterError(
179
+ AdapterErrorCode.UNSAFE_SOURCE,
180
+ "native session store has an unsafe controlling directory",
181
+ ) from None
182
+ return True
183
+
184
+
185
+ def _walk_store(
186
+ root: Path,
187
+ *,
188
+ max_depth: int,
189
+ max_entries: int,
190
+ ) -> Iterator[tuple[Path, os.stat_result]]:
191
+ pending = [(root, 0)]
192
+ entries_seen = 0
193
+ while pending:
194
+ directory, depth = pending.pop()
195
+ try:
196
+ with os.scandir(directory) as entries:
197
+ for entry in entries:
198
+ entries_seen += 1
199
+ if entries_seen > max_entries:
200
+ raise AdapterError(
201
+ AdapterErrorCode.DISCOVERY_LIMIT_EXCEEDED,
202
+ "native session store exceeds the discovery entry limit",
203
+ )
204
+ entry_metadata = entry.stat(follow_symlinks=False)
205
+ if stat.S_ISDIR(entry_metadata.st_mode):
206
+ if depth < max_depth and not entry.is_symlink():
207
+ pending.append((Path(entry.path), depth + 1))
208
+ continue
209
+ yield Path(entry.path), entry_metadata
210
+ except AdapterError:
211
+ raise
212
+ except OSError:
213
+ raise AdapterError(
214
+ AdapterErrorCode.SOURCE_UNAVAILABLE,
215
+ "native session store cannot be inspected",
216
+ ) from None
217
+
218
+
219
+ def _source_sort_key(source: SessionSource) -> tuple[int, str]:
220
+ return -source.modified_at_ns, source.path.as_posix()
@@ -0,0 +1,52 @@
1
+ """Stable native-session errors that do not expose captured content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class AdapterErrorCode(StrEnum):
9
+ """Machine-readable reasons native session evidence cannot be used."""
10
+
11
+ SOURCE_UNAVAILABLE = "source-unavailable"
12
+ UNSAFE_SOURCE = "unsafe-source"
13
+ SOURCE_TOO_LARGE = "source-too-large"
14
+ LINE_TOO_LARGE = "line-too-large"
15
+ TOO_MANY_RECORDS = "too-many-records"
16
+ INVALID_UTF8 = "invalid-utf8"
17
+ INVALID_JSONL = "invalid-jsonl"
18
+ DUPLICATE_KEY = "duplicate-key"
19
+ JSON_LIMIT_EXCEEDED = "json-limit-exceeded"
20
+ SOURCE_CHANGED = "source-changed"
21
+ UNSUPPORTED_FORMAT = "unsupported-format"
22
+ AMBIGUOUS_FORMAT = "ambiguous-format"
23
+ UNSUPPORTED_VERSION = "unsupported-version"
24
+ INVALID_SESSION = "invalid-session"
25
+ CONTEXT_TOO_LARGE = "context-too-large"
26
+ DECISION_NOT_FOUND = "decision-not-found"
27
+ TARGET_NOT_FOUND = "target-not-found"
28
+ TOO_MANY_DECISIONS = "too-many-decisions"
29
+ DISCOVERY_LIMIT_EXCEEDED = "discovery-limit-exceeded"
30
+ SESSION_NOT_FOUND = "session-not-found"
31
+ AMBIGUOUS_SESSION = "ambiguous-session"
32
+ NO_SESSIONS = "no-sessions"
33
+
34
+
35
+ class AdapterError(ValueError):
36
+ """Native session evidence cannot be read, validated, or selected safely."""
37
+
38
+ __slots__ = ("bytes_read", "code", "line")
39
+
40
+ def __init__(
41
+ self,
42
+ code: AdapterErrorCode,
43
+ message: str,
44
+ *,
45
+ line: int | None = None,
46
+ bytes_read: int = 0,
47
+ ) -> None:
48
+ self.code = code
49
+ self.line = line
50
+ self.bytes_read = bytes_read
51
+ location = f" at line {line}" if line is not None else ""
52
+ super().__init__(f"{message}{location}")