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,224 @@
1
+ """Enumerate completed decisions from parsed Codex CLI sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import cast
7
+
8
+ from ithrl.adapters.codex.context import opaque_prefix, source_selector
9
+ from ithrl.adapters.contracts import NativeSession
10
+ from ithrl.adapters.errors import AdapterError, AdapterErrorCode
11
+ from ithrl.behavior import BehaviorObservation, FinalResponseTarget
12
+ from ithrl.context import JsonObject, JsonValue, content_digest
13
+ from ithrl.decisions import (
14
+ MAX_DECISIONS,
15
+ DecisionCaseFacts,
16
+ DecisionDraft,
17
+ DecisionLimitation,
18
+ DecisionSelector,
19
+ TargetCandidate,
20
+ )
21
+
22
+ _ADAPTER = "codex-session"
23
+ _ADAPTER_VERSION = "1"
24
+ _HARNESS = "codex"
25
+ _SOURCE = "source/rollout.jsonl"
26
+ _MAX_BEHAVIOR_BYTES = 64 * 1024
27
+ _OUTPUT_TYPES = frozenset({"custom_tool_call_output", "function_call_output"})
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _DecisionRecord:
32
+ index: int
33
+ context_before_line: int
34
+ turn_id: str
35
+ kind: str
36
+ native_id: str
37
+ observation: BehaviorObservation
38
+ targets: tuple[TargetCandidate, ...]
39
+ model: str | None
40
+
41
+
42
+ def enumerate_decisions(session: NativeSession) -> tuple[DecisionDraft, ...]:
43
+ """Return supported decisions whose enclosing Codex task completed."""
44
+
45
+ if session.metadata.format.harness != _HARNESS:
46
+ raise ValueError("native session is not a Codex CLI session")
47
+
48
+ records = session.records
49
+ decisions = _completed_decision_records(records)
50
+ opaque_context = opaque_prefix(records)
51
+ provider = _provider(records)
52
+ return tuple(
53
+ _build_draft(session, records, opaque_context, provider, decision) for decision in decisions
54
+ )
55
+
56
+
57
+ def _completed_decision_records(records: tuple[JsonObject, ...]) -> tuple[_DecisionRecord, ...]:
58
+ turn_models: dict[str, str] = {}
59
+ open_turns: dict[str, list[int]] = {}
60
+ decisions: list[_DecisionRecord] = []
61
+ latest_model_input_line = 0
62
+
63
+ for index, record in enumerate(records):
64
+ record_type = _string(record.get("type"))
65
+ payload = _object(record.get("payload"))
66
+ if record_type == "turn_context":
67
+ _remember_turn_model(payload, turn_models)
68
+ continue
69
+ if record_type == "response_item" and _is_model_input(payload):
70
+ latest_model_input_line = index + 2
71
+ continue
72
+ if record_type != "event_msg":
73
+ continue
74
+ event_type = _string(payload.get("type"))
75
+ turn_id = _string(payload.get("turn_id"))
76
+ if turn_id is None:
77
+ continue
78
+ if event_type == "task_started":
79
+ open_turns.setdefault(turn_id, []).append(index + 2)
80
+ continue
81
+ if event_type != "task_complete" or not (starts := open_turns.get(turn_id)):
82
+ continue
83
+ # Codex task IDs can overlap; a completion closes the latest unmatched start for that ID.
84
+ context_before_line = max(starts.pop(), latest_model_input_line)
85
+ if not starts:
86
+ del open_turns[turn_id]
87
+ decision = _final_response_record(
88
+ record,
89
+ index=index,
90
+ turn_id=turn_id,
91
+ context_before_line=context_before_line,
92
+ model=turn_models.get(turn_id),
93
+ )
94
+ if decision is None:
95
+ continue
96
+ if len(decisions) == MAX_DECISIONS:
97
+ raise AdapterError(
98
+ AdapterErrorCode.TOO_MANY_DECISIONS,
99
+ "session contains too many completed decisions",
100
+ )
101
+ decisions.append(decision)
102
+
103
+ return tuple(decisions)
104
+
105
+
106
+ def _remember_turn_model(payload: JsonObject, models: dict[str, str]) -> None:
107
+ turn_id = _string(payload.get("turn_id"))
108
+ model = _bounded_string(payload.get("model"), 256)
109
+ if turn_id is not None and model is not None:
110
+ models[turn_id] = model
111
+
112
+
113
+ def _final_response_record(
114
+ record: JsonObject,
115
+ *,
116
+ index: int,
117
+ turn_id: str,
118
+ context_before_line: int,
119
+ model: str | None,
120
+ ) -> _DecisionRecord | None:
121
+ payload = _object(record.get("payload"))
122
+ text = _bounded_behavior_text(payload.get("last_agent_message"))
123
+ if text is None:
124
+ return None
125
+
126
+ target = FinalResponseTarget(text_equals=text)
127
+ return _DecisionRecord(
128
+ index=index,
129
+ context_before_line=context_before_line,
130
+ turn_id=turn_id,
131
+ kind="codex-task-complete",
132
+ native_id=turn_id,
133
+ observation=BehaviorObservation(final_response=text),
134
+ targets=(_target_candidate(target),),
135
+ model=model,
136
+ )
137
+
138
+
139
+ def _is_model_input(payload: JsonObject) -> bool:
140
+ item_type = _string(payload.get("type"))
141
+ return (
142
+ item_type == "message" and payload.get("role") in {"user", "developer", "system"}
143
+ ) or item_type in _OUTPUT_TYPES
144
+
145
+
146
+ def _bounded_behavior_text(value: JsonValue | None) -> str | None:
147
+ if not isinstance(value, str) or not value:
148
+ return None
149
+ return value if len(value.encode("utf-8")) <= _MAX_BEHAVIOR_BYTES else None
150
+
151
+
152
+ def _target_candidate(target: FinalResponseTarget) -> TargetCandidate:
153
+ return TargetCandidate(
154
+ ordinal=1,
155
+ target=target,
156
+ evidence_support="supported",
157
+ experimental_support="unvalidated",
158
+ )
159
+
160
+
161
+ def _build_draft(
162
+ session: NativeSession,
163
+ records: tuple[JsonObject, ...],
164
+ opaque_prefix: tuple[bool, ...],
165
+ provider: str | None,
166
+ decision: _DecisionRecord,
167
+ ) -> DecisionDraft:
168
+ record = records[decision.index]
169
+ limitations: list[DecisionLimitation] = ["experimental-support-unvalidated"]
170
+ if opaque_prefix[decision.context_before_line - 1]:
171
+ limitations.append("opaque-context-frozen")
172
+ return DecisionDraft(
173
+ case=DecisionCaseFacts(
174
+ adapter=_ADAPTER,
175
+ adapter_version=_ADAPTER_VERSION,
176
+ provider=provider,
177
+ model_requested=decision.model,
178
+ source_schema=session.metadata.format.name,
179
+ ),
180
+ selector=_selector(session, record, decision),
181
+ observed_at=cast(str, record["timestamp"]),
182
+ observation=decision.observation,
183
+ targets=decision.targets,
184
+ limitations=tuple(limitations),
185
+ )
186
+
187
+
188
+ def _selector(
189
+ session: NativeSession,
190
+ record: JsonObject,
191
+ decision: _DecisionRecord,
192
+ ) -> DecisionSelector:
193
+ return DecisionSelector(
194
+ harness=_HARNESS,
195
+ session_id=session.metadata.session_id,
196
+ turn_id=decision.turn_id,
197
+ kind=decision.kind,
198
+ native_id=decision.native_id,
199
+ source=_SOURCE,
200
+ context_before_line=decision.context_before_line,
201
+ record=source_selector(decision.index, record),
202
+ record_digest=content_digest(record),
203
+ )
204
+
205
+
206
+ def _provider(records: tuple[JsonObject, ...]) -> str | None:
207
+ if not records:
208
+ return None
209
+ first = records[0]
210
+ if first.get("type") != "session_meta":
211
+ return None
212
+ return _bounded_string(_object(first.get("payload")).get("model_provider"), 256)
213
+
214
+
215
+ def _object(value: JsonValue | None) -> JsonObject:
216
+ return value if isinstance(value, dict) else {}
217
+
218
+
219
+ def _string(value: JsonValue | None) -> str | None:
220
+ return value if isinstance(value, str) and value else None
221
+
222
+
223
+ def _bounded_string(value: JsonValue | None, limit: int) -> str | None:
224
+ return value if isinstance(value, str) and 0 < len(value) <= limit else None
@@ -0,0 +1,60 @@
1
+ """Find Codex rollout sessions in an ordinary local store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from ithrl.adapters.discovery import DiscoveryLimits, SourceScan, scan_store
9
+
10
+ _STORE_DEPTH = 3
11
+
12
+
13
+ def discover_sources(
14
+ *,
15
+ root: Path | None = None,
16
+ limits: DiscoveryLimits | None = None,
17
+ session_id: str | None = None,
18
+ ) -> SourceScan:
19
+ """Return recent Codex rollout files from a local session store."""
20
+
21
+ selected_limits = limits or DiscoveryLimits()
22
+ return scan_store(
23
+ root if root is not None else _default_store(),
24
+ max_depth=_STORE_DEPTH,
25
+ accepts=lambda path: (
26
+ _is_rollout_path(path)
27
+ and (session_id is None or path.name.endswith(f"-{session_id}.jsonl"))
28
+ ),
29
+ limits=selected_limits,
30
+ )
31
+
32
+
33
+ def _default_store() -> Path:
34
+ codex_home = os.environ.get("CODEX_HOME")
35
+ if codex_home:
36
+ return Path(codex_home).expanduser() / "sessions"
37
+ return Path.home() / ".codex" / "sessions"
38
+
39
+
40
+ def _is_rollout_path(path: Path) -> bool:
41
+ parts = path.parts
42
+ if len(parts) != 4:
43
+ return False
44
+ year, month, day, filename = parts
45
+ return all(
46
+ (
47
+ _is_ascii_number(year, width=4),
48
+ _is_ascii_number(month, width=2),
49
+ _is_ascii_number(day, width=2),
50
+ _is_rollout_filename(filename),
51
+ )
52
+ )
53
+
54
+
55
+ def _is_ascii_number(value: str, *, width: int) -> bool:
56
+ return len(value) == width and value.isascii() and value.isdigit()
57
+
58
+
59
+ def _is_rollout_filename(value: str) -> bool:
60
+ return value.startswith("rollout-") and value != "rollout-.jsonl" and value.endswith(".jsonl")
@@ -0,0 +1,191 @@
1
+ """Validate Codex rollout JSONL that starts with `session_meta`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Never
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
13
+ from ithrl.timestamps import validate_rfc3339_timestamp
14
+
15
+ _FORMAT_NAME = "codex-rollout-jsonl"
16
+ _CALL_OUTPUT_TYPES = {
17
+ "custom_tool_call": "custom_tool_call_output",
18
+ "function_call": "function_call_output",
19
+ }
20
+ _OUTPUT_TYPES = frozenset({"custom_tool_call_output", "function_call_output"})
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class _Record:
25
+ line: int
26
+ timestamp: datetime
27
+ type: str
28
+ payload: JsonObject
29
+
30
+
31
+ class _CodexSessionParser:
32
+ format_name = _FORMAT_NAME
33
+
34
+ def matches(self, document: JsonlDocument) -> bool:
35
+ """Return whether the document has the observed Codex rollout signature."""
36
+
37
+ if not document.records:
38
+ return False
39
+ first = document.records[0]
40
+ payload = first.get("payload")
41
+ return (
42
+ first.get("type") == "session_meta"
43
+ and isinstance(payload, dict)
44
+ and "cli_version" in payload
45
+ and ("id" in payload or "session_id" in payload)
46
+ )
47
+
48
+ def parse(self, document: JsonlDocument) -> NativeSession:
49
+ """Validate and preserve one supported Codex rollout document."""
50
+
51
+ if not self.matches(document):
52
+ raise AdapterError(
53
+ AdapterErrorCode.UNSUPPORTED_FORMAT,
54
+ "session source is not a Codex rollout",
55
+ )
56
+
57
+ records = _validate_envelopes(document.records)
58
+ metadata = _session_metadata(records)
59
+ _validate_response_items(records)
60
+ return NativeSession(
61
+ metadata=metadata,
62
+ source=document.source,
63
+ native_bytes=document.raw,
64
+ records=document.records,
65
+ )
66
+
67
+
68
+ PARSER = _CodexSessionParser()
69
+
70
+
71
+ def _validate_envelopes(records: tuple[JsonObject, ...]) -> tuple[_Record, ...]:
72
+ validated: list[_Record] = []
73
+ for line, record in enumerate(records, start=1):
74
+ ordinal = record.get("ordinal")
75
+ if ordinal is not None and (type(ordinal) is not int or ordinal < 0):
76
+ _invalid("Codex record ordinal must be a non-negative integer when present", line)
77
+
78
+ timestamp_text = _required_string(record, "timestamp", line, "record timestamp")
79
+ timestamp = _parse_timestamp(timestamp_text, line, "record timestamp")
80
+
81
+ record_type = _required_string(record, "type", line, "record type")
82
+ payload = record.get("payload")
83
+ if not isinstance(payload, dict):
84
+ _invalid("Codex record payload must be an object", line)
85
+
86
+ validated.append(_Record(line=line, timestamp=timestamp, type=record_type, payload=payload))
87
+ return tuple(validated)
88
+
89
+
90
+ def _session_metadata(records: tuple[_Record, ...]) -> SessionMetadata:
91
+ record = records[0]
92
+ if record.type != "session_meta":
93
+ _invalid("Codex session metadata must be the first record")
94
+ source_session_id = _required_string(
95
+ record.payload,
96
+ "id",
97
+ record.line,
98
+ "source session identifier",
99
+ )
100
+ _required_string(record.payload, "session_id", record.line, "session family identifier")
101
+
102
+ captured_at = _required_string(record.payload, "timestamp", record.line, "capture timestamp")
103
+ _parse_timestamp(captured_at, record.line, "capture timestamp")
104
+
105
+ producer_version = _required_string(
106
+ record.payload,
107
+ "cli_version",
108
+ record.line,
109
+ "producer version",
110
+ )
111
+
112
+ return SessionMetadata(
113
+ format=SessionFormat(
114
+ harness="codex",
115
+ name=_FORMAT_NAME,
116
+ producer_version=producer_version,
117
+ ),
118
+ session_id=source_session_id,
119
+ captured_at=captured_at,
120
+ )
121
+
122
+
123
+ def _validate_response_items(records: tuple[_Record, ...]) -> None:
124
+ item_ids: set[str] = set()
125
+ calls: dict[str, str] = {}
126
+ outputs: set[str] = set()
127
+
128
+ for record in records:
129
+ if record.type != "response_item":
130
+ continue
131
+ item_type = _required_string(record.payload, "type", record.line, "response item type")
132
+ item_id = _required_string(record.payload, "id", record.line, "response item identifier")
133
+ if item_id in item_ids:
134
+ _invalid("Codex response item identifiers must be unique", record.line)
135
+ item_ids.add(item_id)
136
+
137
+ _validate_tool_relationship(record, item_type, calls, outputs)
138
+
139
+
140
+ def _validate_tool_relationship(
141
+ record: _Record,
142
+ item_type: str,
143
+ calls: dict[str, str],
144
+ outputs: set[str],
145
+ ) -> None:
146
+ expected_output = _CALL_OUTPUT_TYPES.get(item_type)
147
+ if expected_output is not None:
148
+ call_id = _required_string(record.payload, "call_id", record.line, "tool call identifier")
149
+ if call_id in calls:
150
+ _invalid("Codex tool call identifiers must be unique", record.line)
151
+ calls[call_id] = expected_output
152
+ return
153
+ if item_type not in _OUTPUT_TYPES:
154
+ return
155
+
156
+ call_id = _required_string(record.payload, "call_id", record.line, "tool call identifier")
157
+ if call_id not in calls:
158
+ _invalid("Codex tool output must follow its call", record.line)
159
+ if item_type != calls[call_id]:
160
+ _invalid("Codex tool output type must match its call type", record.line)
161
+ if call_id in outputs:
162
+ _invalid("Codex tool calls must have at most one output", record.line)
163
+ outputs.add(call_id)
164
+
165
+
166
+ def _required_string(
167
+ value: JsonObject,
168
+ field: str,
169
+ line: int,
170
+ description: str,
171
+ ) -> str:
172
+ item = value.get(field)
173
+ if not isinstance(item, str) or not item:
174
+ _invalid(f"Codex {description} must be a non-empty string", line)
175
+ return item
176
+
177
+
178
+ def _parse_timestamp(value: str, line: int, description: str) -> datetime:
179
+ try:
180
+ validate_rfc3339_timestamp(value, field=description)
181
+ return datetime.fromisoformat(f"{value[:-1]}+00:00" if value.endswith("Z") else value)
182
+ except ValueError:
183
+ raise AdapterError(
184
+ AdapterErrorCode.INVALID_SESSION,
185
+ f"Codex {description} must be a valid RFC 3339 timestamp",
186
+ line=line,
187
+ ) from None
188
+
189
+
190
+ def _invalid(message: str, line: int | None = None) -> Never:
191
+ raise AdapterError(AdapterErrorCode.INVALID_SESSION, message, line=line)
@@ -0,0 +1,106 @@
1
+ """Harness-independent contracts for parsed native session evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ from dataclasses import dataclass
8
+ from typing import Annotated, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from ithrl.context import JsonObject
13
+ from ithrl.timestamps import validate_rfc3339_timestamp
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ParsingLimits:
18
+ """Resource limits applied before native session evidence is accepted."""
19
+
20
+ max_bytes: int = 64 * 1024 * 1024
21
+ max_line_bytes: int = 8 * 1024 * 1024
22
+ max_records: int = 250_000
23
+ max_json_items: int = 1_000_000
24
+
25
+ def __post_init__(self) -> None:
26
+ if min(self.max_bytes, self.max_line_bytes, self.max_records, self.max_json_items) < 1:
27
+ raise ValueError("parsing limits must be positive")
28
+ if self.max_line_bytes > self.max_bytes:
29
+ raise ValueError("max_line_bytes must not exceed max_bytes")
30
+
31
+
32
+ class _ImmutableContract(BaseModel):
33
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
34
+
35
+
36
+ class SessionFormat(_ImmutableContract):
37
+ """Identity and producer metadata for one native saved-session format."""
38
+
39
+ schema_version: Literal[1] = 1
40
+ harness: Annotated[str, Field(min_length=1, max_length=64)]
41
+ name: Annotated[str, Field(min_length=1, max_length=128)]
42
+ producer_version: Annotated[str, Field(min_length=1, max_length=128)]
43
+ record_schema_version: int | str | None = None
44
+
45
+
46
+ class SessionMetadata(_ImmutableContract):
47
+ """Shared facts needed to identify parsed native evidence."""
48
+
49
+ schema_version: Literal[1] = 1
50
+ format: SessionFormat
51
+ session_id: Annotated[str, Field(min_length=1, max_length=1024)]
52
+ captured_at: Annotated[str, Field(min_length=1, max_length=64)]
53
+
54
+ @field_validator("captured_at")
55
+ @classmethod
56
+ def validate_captured_at(cls, value: str) -> str:
57
+ return validate_rfc3339_timestamp(value, field="captured_at")
58
+
59
+
60
+ class SourceFacts(_ImmutableContract):
61
+ """Content identity of the exact native source bytes that were parsed."""
62
+
63
+ schema_version: Literal[1] = 1
64
+ sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
65
+ size_bytes: Annotated[int, Field(ge=1)]
66
+
67
+
68
+ class NativeSession:
69
+ """Parsed metadata with an unchanged native JSONL snapshot."""
70
+
71
+ __slots__ = ("_native_bytes", "_records", "metadata", "source")
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ metadata: SessionMetadata,
77
+ source: SourceFacts,
78
+ native_bytes: bytes,
79
+ records: tuple[JsonObject, ...],
80
+ ) -> None:
81
+ if not native_bytes or not records:
82
+ raise ValueError("native session evidence must not be empty")
83
+ if source.size_bytes != len(native_bytes):
84
+ raise ValueError("native session byte count does not match source facts")
85
+ if source.sha256 != hashlib.sha256(native_bytes).hexdigest():
86
+ raise ValueError("native session digest does not match source facts")
87
+ self.metadata = metadata
88
+ self.source = source
89
+ self._native_bytes = native_bytes
90
+ self._records = copy.deepcopy(records)
91
+
92
+ @property
93
+ def native_bytes(self) -> bytes:
94
+ """Return the exact bytes read from the native session file."""
95
+
96
+ return self._native_bytes
97
+
98
+ @property
99
+ def records(self) -> tuple[JsonObject, ...]:
100
+ """Return defensive copies of native records in source order."""
101
+
102
+ return copy.deepcopy(self._records)
103
+
104
+ @property
105
+ def record_count(self) -> int:
106
+ return len(self._records)
@@ -0,0 +1,5 @@
1
+ """Parse GitHub Copilot CLI session event logs."""
2
+
3
+ from ithrl.adapters.copilot.parser import PARSER
4
+
5
+ __all__ = ["PARSER"]