traceforge-toolkit 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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,187 @@
1
+ """SQLite polling source for reading agent session databases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sqlite3
9
+ from collections.abc import AsyncIterator
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from types import TracebackType
13
+ from typing import Literal
14
+
15
+ from traceforge.sources.base import RawRecord, Source
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SqliteSource(Source):
21
+ """Poll a SQLite database for new rows in a target table.
22
+
23
+ Tracks position via a monotonically increasing column (e.g. rowid, id,
24
+ or timestamp). Each new row becomes a RawRecord whose payload is the
25
+ JSON-serialized row dict.
26
+
27
+ Designed for:
28
+ - Copilot CLI session-store.db (turns, forge_trajectory_events)
29
+ - Any agent that stores conversation data in SQLite
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ path: str | Path,
35
+ name: str,
36
+ table: str = "turns",
37
+ order_column: str = "id",
38
+ columns: list[str] | None = None,
39
+ where: str | None = None,
40
+ interval: float = 2.0,
41
+ start_at: Literal["beginning", "end"] = "end",
42
+ session_filter: str | None = None,
43
+ ) -> None:
44
+ """Initialize SqliteSource.
45
+
46
+ Args:
47
+ path: Path to the SQLite database file.
48
+ name: Source name for record attribution.
49
+ table: Table to poll for new rows.
50
+ order_column: Column used for ordering and cursor tracking (must be monotonic).
51
+ columns: Specific columns to select (None = all).
52
+ where: Additional WHERE clause (without 'WHERE' keyword).
53
+ interval: Seconds between polls.
54
+ start_at: "beginning" reads all existing rows; "end" starts from latest.
55
+ session_filter: If set, adds WHERE session_id = ? filter.
56
+ """
57
+ if interval < 0:
58
+ raise ValueError("interval must be non-negative")
59
+ self.path = Path(path).resolve()
60
+ self.name = name
61
+ self.table = table
62
+ self.order_column = order_column
63
+ self.columns = columns
64
+ self.where = where
65
+ self.interval = interval
66
+ self.start_at = start_at
67
+ self.session_filter = session_filter
68
+ self._cursor: int | str | None = None
69
+ self._sequence = 0
70
+ self._conn: sqlite3.Connection | None = None
71
+ self._iterating = False
72
+
73
+ async def __aenter__(self) -> "SqliteSource":
74
+ if not self.path.exists():
75
+ raise FileNotFoundError(f"SqliteSource database not found: {self.path}")
76
+ self._conn = await asyncio.to_thread(self._connect)
77
+ if self.start_at == "end":
78
+ self._cursor = await asyncio.to_thread(self._get_max_cursor)
79
+ return self
80
+
81
+ async def __aexit__(
82
+ self,
83
+ exc_type: type[BaseException] | None,
84
+ exc: BaseException | None,
85
+ tb: TracebackType | None,
86
+ ) -> None:
87
+ if self._conn is not None:
88
+ self._conn.close()
89
+ self._conn = None
90
+ self._iterating = False
91
+
92
+ async def _iter_records(self) -> AsyncIterator[RawRecord]:
93
+ if self._conn is None:
94
+ raise RuntimeError("SqliteSource must be entered before iteration")
95
+ if self._iterating:
96
+ raise RuntimeError("SqliteSource does not support concurrent iteration")
97
+ self._iterating = True
98
+ try:
99
+ while True:
100
+ rows = await asyncio.to_thread(self._poll_new_rows)
101
+ for row in rows:
102
+ yield self._make_record(row)
103
+ await asyncio.sleep(self.interval)
104
+ finally:
105
+ self._iterating = False
106
+
107
+ def __aiter__(self) -> AsyncIterator[RawRecord]:
108
+ return self._iter_records()
109
+
110
+ def _connect(self) -> sqlite3.Connection:
111
+ conn = sqlite3.connect(str(self.path), timeout=5, check_same_thread=False)
112
+ conn.row_factory = sqlite3.Row
113
+ # WAL mode for concurrent reads without blocking writers
114
+ try:
115
+ conn.execute("PRAGMA journal_mode=WAL")
116
+ except sqlite3.OperationalError:
117
+ pass
118
+ return conn
119
+
120
+ def _get_max_cursor(self) -> int | str | None:
121
+ """Get the current maximum value of the order column."""
122
+ assert self._conn is not None
123
+ query = f"SELECT MAX({self.order_column}) FROM {self.table}" # noqa: S608
124
+ filters, params = self._build_filters()
125
+ if filters:
126
+ query += f" WHERE {' AND '.join(filters)}"
127
+ row = self._conn.execute(query, params).fetchone()
128
+ return row[0] if row else None
129
+
130
+ def _poll_new_rows(self) -> list[dict[str, object]]:
131
+ """Query for rows newer than the current cursor."""
132
+ assert self._conn is not None
133
+
134
+ col_spec = ", ".join(self.columns) if self.columns else "*"
135
+ query = f"SELECT {col_spec} FROM {self.table}" # noqa: S608
136
+
137
+ filters, params = self._build_filters()
138
+ if self._cursor is not None:
139
+ filters.append(f"{self.order_column} > ?")
140
+ params.append(self._cursor)
141
+
142
+ if filters:
143
+ query += f" WHERE {' AND '.join(filters)}"
144
+ query += f" ORDER BY {self.order_column} ASC"
145
+
146
+ try:
147
+ rows = self._conn.execute(query, params).fetchall()
148
+ except sqlite3.OperationalError as exc:
149
+ logger.warning("SqliteSource %s: query failed: %s", self.name, exc)
150
+ return []
151
+
152
+ results: list[dict[str, object]] = []
153
+ for row in rows:
154
+ row_dict = dict(row)
155
+ cursor_val = row_dict.get(self.order_column)
156
+ if cursor_val is not None:
157
+ self._cursor = cursor_val
158
+ # Serialize to JSON for the RawRecord payload
159
+ results.append({k: v for k, v in row_dict.items() if v is not None})
160
+
161
+ if results:
162
+ logger.debug("SqliteSource %s: polled %d new rows", self.name, len(results))
163
+
164
+ return results
165
+
166
+ def _build_filters(self) -> tuple[list[str], list[object]]:
167
+ """Build WHERE clause filters and parameters."""
168
+ filters: list[str] = []
169
+ params: list[object] = []
170
+ if self.session_filter:
171
+ filters.append("session_id = ?")
172
+ params.append(self.session_filter)
173
+ if self.where:
174
+ filters.append(f"({self.where})")
175
+ return filters, params
176
+
177
+ def _make_record(self, row_dict: dict[str, object]) -> RawRecord:
178
+
179
+ record = RawRecord(
180
+ payload=json.dumps(row_dict, default=str),
181
+ source_name=self.name,
182
+ mode="sqlite",
183
+ sequence=self._sequence,
184
+ received_at=datetime.now(timezone.utc),
185
+ )
186
+ self._sequence += 1
187
+ return record
@@ -0,0 +1,198 @@
1
+ """Server-sent events source transport (WHATWG spec compliant)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from types import TracebackType
10
+
11
+ import httpx
12
+
13
+ from traceforge.sources.base import RawRecord, Source
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class SSEEvent:
18
+ """A parsed SSE event per the WHATWG spec."""
19
+
20
+ data: str
21
+ event_type: str = "message"
22
+ last_event_id: str = ""
23
+
24
+
25
+ class SSESource(Source):
26
+ """Read SSE events from an endpoint with spec-compliant parsing and reconnect.
27
+
28
+ Implements the WHATWG Server-Sent Events specification:
29
+ - Parses data, event, id, retry fields
30
+ - Sends Last-Event-ID on reconnect
31
+ - Validates Content-Type: text/event-stream
32
+ - Exponential backoff with server-controlled retry
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ url: str,
38
+ name: str,
39
+ headers: dict[str, str] | None = None,
40
+ reconnect_delay: float = 1.0,
41
+ max_reconnects: int | None = None,
42
+ ) -> None:
43
+ self.url = url
44
+ self.name = name
45
+ self.headers = dict(headers or {})
46
+ self.reconnect_delay = reconnect_delay
47
+ self.max_reconnects = max_reconnects
48
+ self._client: httpx.AsyncClient | None = None
49
+ self._sequence = 0
50
+ self._last_event_id: str = ""
51
+ self._iterating = False
52
+
53
+ async def __aenter__(self) -> "SSESource":
54
+ self._client = httpx.AsyncClient(timeout=None)
55
+ return self
56
+
57
+ async def __aexit__(
58
+ self,
59
+ exc_type: type[BaseException] | None,
60
+ exc: BaseException | None,
61
+ tb: TracebackType | None,
62
+ ) -> None:
63
+ if self._client is not None:
64
+ await self._client.aclose()
65
+ self._client = None
66
+ self._iterating = False
67
+
68
+ async def _iter_records(self) -> AsyncIterator[RawRecord]:
69
+ if self._client is None:
70
+ raise RuntimeError("SSESource must be entered before iteration")
71
+ if self._iterating:
72
+ raise RuntimeError("SSESource does not support concurrent iteration")
73
+ self._iterating = True
74
+ try:
75
+ reconnects = 0
76
+ while True:
77
+ if self.max_reconnects is not None and reconnects > self.max_reconnects:
78
+ raise RuntimeError(f"Exceeded maximum reconnects for SSE source {self.name}")
79
+ try:
80
+ async for sse_event in self._stream_once():
81
+ reconnects = 0
82
+ yield self._make_record(sse_event)
83
+ except asyncio.CancelledError:
84
+ raise
85
+ except Exception:
86
+ reconnects += 1
87
+ if self.max_reconnects is not None and reconnects > self.max_reconnects:
88
+ raise
89
+ await asyncio.sleep(self._backoff_delay(reconnects))
90
+ continue
91
+
92
+ reconnects += 1
93
+ if self.max_reconnects is not None and reconnects > self.max_reconnects:
94
+ return
95
+ await asyncio.sleep(self._backoff_delay(reconnects))
96
+ finally:
97
+ self._iterating = False
98
+
99
+ def __aiter__(self) -> AsyncIterator[RawRecord]:
100
+ return self._iter_records()
101
+
102
+ async def _stream_once(self) -> AsyncIterator[SSEEvent]:
103
+ """Connect and yield parsed SSE events until the stream ends."""
104
+ if self._client is None:
105
+ raise RuntimeError("SSESource must be entered before iteration")
106
+
107
+ request_headers = {"Accept": "text/event-stream", **self.headers}
108
+ if self._last_event_id:
109
+ request_headers["Last-Event-ID"] = self._last_event_id
110
+
111
+ async with self._client.stream("GET", self.url, headers=request_headers) as response:
112
+ response.raise_for_status()
113
+ content_type = response.headers.get("content-type", "")
114
+ if "text/event-stream" not in content_type:
115
+ raise ValueError(
116
+ f"SSE endpoint returned Content-Type '{content_type}', "
117
+ f"expected 'text/event-stream'"
118
+ )
119
+
120
+ data_buf: list[str] = []
121
+ event_type = ""
122
+ last_id: str | None = None
123
+
124
+ async for line in response.aiter_lines():
125
+ if line == "":
126
+ # Dispatch event
127
+ if data_buf:
128
+ data = "\n".join(data_buf)
129
+ # SSE is at-least-once: after a reconnect the server may
130
+ # redeliver an already-seen id (e.g. resumed via Last-Event-ID).
131
+ # Drop such redeliveries; only emit ids newer than the last one
132
+ # we emitted, advancing _last_event_id solely for emitted events.
133
+ if last_id is None or not self._is_duplicate(last_id):
134
+ if last_id is not None:
135
+ self._last_event_id = last_id
136
+ yield SSEEvent(
137
+ data=data,
138
+ event_type=event_type or "message",
139
+ last_event_id=self._last_event_id,
140
+ )
141
+ # Reset per-event state
142
+ data_buf = []
143
+ event_type = ""
144
+ last_id = None
145
+ continue
146
+
147
+ if line.startswith(":"):
148
+ continue
149
+
150
+ field, _, value = line.partition(":")
151
+ if value.startswith(" "):
152
+ value = value[1:]
153
+
154
+ if field == "data":
155
+ data_buf.append(value)
156
+ elif field == "event":
157
+ event_type = value
158
+ elif field == "id":
159
+ # Per spec: empty id resets; id with NUL is ignored
160
+ if "\x00" not in value:
161
+ last_id = value
162
+ elif field == "retry":
163
+ if value.isdigit():
164
+ self.reconnect_delay = max(int(value) / 1000.0, 0.0)
165
+
166
+ def _is_duplicate(self, event_id: str) -> bool:
167
+ """Return True if ``event_id`` was already emitted (at-least-once redelivery).
168
+
169
+ After a reconnect an SSE server may redeliver events the client has already
170
+ seen. When both the last-emitted id and the candidate are integers, treat any
171
+ id not strictly greater than the last-seen id as a duplicate (monotonic ids,
172
+ the common case). For ids that cannot be ordered numerically, drop only an id
173
+ that exactly equals the last-emitted one (the immediately-preceding event).
174
+ """
175
+ last = self._last_event_id
176
+ if not last:
177
+ return False
178
+ if event_id == last:
179
+ return True
180
+ try:
181
+ return int(event_id) <= int(last)
182
+ except ValueError:
183
+ return False
184
+
185
+ def _backoff_delay(self, reconnects: int) -> float:
186
+ exponent = max(reconnects - 1, 0)
187
+ return self.reconnect_delay * min(2**exponent, 30)
188
+
189
+ def _make_record(self, sse_event: SSEEvent) -> RawRecord:
190
+ record = RawRecord(
191
+ payload=sse_event.data,
192
+ source_name=self.name,
193
+ mode="stream",
194
+ sequence=self._sequence,
195
+ received_at=datetime.now(timezone.utc),
196
+ )
197
+ self._sequence += 1
198
+ return record
@@ -0,0 +1,192 @@
1
+ """Opt-in, near-zero-footprint self-metrics for :class:`~traceforge.pipeline.EventPipeline`.
2
+
3
+ This package is traceforge's *self*-observability seam (SPEC §14). It is distinct
4
+ from OTLP *export* (``sinks/otel_exporter.OtelExporterSink``, which ships events /
5
+ spans / usage to an external collector): this measures the pipeline's **own**
6
+ operation — throughput, enrichment latency, per-sink write time, and dropped /
7
+ failed-sink counts.
8
+
9
+ Design contract (why this stays honest to the "must not add per-event cost"
10
+ constraint of #48):
11
+
12
+ - **Opt-in.** A pipeline created without a :class:`PipelineMetrics` instance does
13
+ no timing and makes no metrics allocations on the hot path — the pipeline
14
+ guards every instrumentation site on ``metrics is not None`` and never calls a
15
+ clock when disabled. Attaching an instance is the *only* way to turn it on.
16
+ - **In-process, no dependencies.** No ``opentelemetry-sdk``, no ``prometheus``,
17
+ no background threads, no parallel transport. It is a plain accumulator read
18
+ back off the same object you passed in (surfaced on ``flush()`` / ``close()``).
19
+ - **Bounded.** Every field is a scalar counter or timing sum, plus exactly one
20
+ entry per sink. Nothing grows per event, so a long-lived daemon never
21
+ accumulates unbounded state.
22
+
23
+ Usage::
24
+
25
+ from traceforge import EventPipeline
26
+ from traceforge.telemetry import PipelineMetrics
27
+
28
+ metrics = PipelineMetrics()
29
+ pipeline = EventPipeline(sinks=[...], metrics=metrics)
30
+ ...
31
+ await pipeline.close()
32
+ snap = metrics.snapshot()
33
+ print(snap.events_per_second, snap.mean_enrichment_ms)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import time
39
+ from dataclasses import dataclass
40
+
41
+ __all__ = ["MetricsSnapshot", "PipelineMetrics", "SinkMetrics"]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SinkMetrics:
46
+ """Immutable per-sink write totals within a :class:`MetricsSnapshot`.
47
+
48
+ ``label`` identifies the sink by class name and position in the pipeline's
49
+ sink list (e.g. ``"JsonlSink#1"``), so two sinks of the same class stay
50
+ distinguishable.
51
+ """
52
+
53
+ label: str
54
+ writes: int
55
+ failures: int
56
+ write_seconds: float
57
+
58
+ @property
59
+ def mean_write_ms(self) -> float:
60
+ """Mean wall time this sink spent per write, in milliseconds."""
61
+ return (self.write_seconds / self.writes * 1000.0) if self.writes else 0.0
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MetricsSnapshot:
66
+ """An immutable point-in-time read of a :class:`PipelineMetrics`.
67
+
68
+ Returned by :meth:`PipelineMetrics.snapshot`. Raw counters are captured
69
+ verbatim; rates and means are exposed as derived properties so no divide-by-
70
+ zero can leak to callers.
71
+ """
72
+
73
+ events: int
74
+ dropped_events: int
75
+ sink_failures: int
76
+ enrichment_calls: int
77
+ enrichment_seconds: float
78
+ active_seconds: float
79
+ sinks: tuple[SinkMetrics, ...]
80
+
81
+ @property
82
+ def events_per_second(self) -> float:
83
+ """Throughput over the span from the first to the most recent event.
84
+
85
+ ``0.0`` until at least two events have been recorded far enough apart for
86
+ the monotonic clock to advance (a single event has no measurable span).
87
+ """
88
+ return (self.events / self.active_seconds) if self.active_seconds > 0 else 0.0
89
+
90
+ @property
91
+ def mean_enrichment_ms(self) -> float:
92
+ """Mean wall time spent in the enricher per event, in milliseconds."""
93
+ return (
94
+ (self.enrichment_seconds / self.enrichment_calls * 1000.0)
95
+ if self.enrichment_calls
96
+ else 0.0
97
+ )
98
+
99
+
100
+ class PipelineMetrics:
101
+ """Mutable, in-process accumulator of :class:`EventPipeline` self-metrics.
102
+
103
+ Attach one instance per pipeline via ``EventPipeline(..., metrics=...)`` to
104
+ turn metrics on; leave it unset (the default) and the pipeline stays a true
105
+ no-op on the hot path. The pipeline calls the ``record_*`` methods; consumers
106
+ call :meth:`snapshot` (any time — the counters update live) to read a stable,
107
+ immutable summary.
108
+
109
+ Not thread-safe by design: the pipeline is asyncio single-threaded, so every
110
+ ``record_*`` call runs on the one event loop; there is nothing to lock.
111
+ """
112
+
113
+ __slots__ = (
114
+ "_dropped_events",
115
+ "_enrichment_calls",
116
+ "_enrichment_seconds",
117
+ "_events",
118
+ "_first_perf",
119
+ "_last_perf",
120
+ "_sink_failures",
121
+ "_sink_seconds",
122
+ "_sink_writes",
123
+ )
124
+
125
+ def __init__(self) -> None:
126
+ self._events = 0
127
+ self._dropped_events = 0
128
+ self._enrichment_calls = 0
129
+ self._enrichment_seconds = 0.0
130
+ self._first_perf: float | None = None
131
+ self._last_perf = 0.0
132
+ # Per-sink accumulators, keyed by the pipeline-assigned sink label. Bounded
133
+ # by the number of sinks; never grows per event.
134
+ self._sink_writes: dict[str, int] = {}
135
+ self._sink_failures: dict[str, int] = {}
136
+ self._sink_seconds: dict[str, float] = {}
137
+
138
+ def record_event(self) -> None:
139
+ """Count one event reaching the sink fan-out and stamp its arrival time."""
140
+ now = time.perf_counter()
141
+ if self._first_perf is None:
142
+ self._first_perf = now
143
+ self._last_perf = now
144
+ self._events += 1
145
+
146
+ def record_drop(self) -> None:
147
+ """Count one event dropped by the enricher (``process`` returned ``None``)."""
148
+ self._dropped_events += 1
149
+
150
+ def record_enrichment(self, seconds: float) -> None:
151
+ """Record one enrichment call and the wall time it took."""
152
+ self._enrichment_calls += 1
153
+ self._enrichment_seconds += seconds
154
+
155
+ def record_sink_write(self, label: str, seconds: float, *, failed: bool) -> None:
156
+ """Record one sink write: its wall time and whether it raised.
157
+
158
+ A failed write still counts as a write (the sink was invoked); ``failed``
159
+ additionally bumps that sink's failure tally.
160
+ """
161
+ self._sink_writes[label] = self._sink_writes.get(label, 0) + 1
162
+ self._sink_seconds[label] = self._sink_seconds.get(label, 0.0) + seconds
163
+ if failed:
164
+ self._sink_failures[label] = self._sink_failures.get(label, 0) + 1
165
+
166
+ @property
167
+ def active_seconds(self) -> float:
168
+ """Span from the first to the most recent recorded event, in seconds."""
169
+ if self._first_perf is None:
170
+ return 0.0
171
+ return max(0.0, self._last_perf - self._first_perf)
172
+
173
+ def snapshot(self) -> MetricsSnapshot:
174
+ """Return an immutable summary of the metrics recorded so far."""
175
+ sinks = tuple(
176
+ SinkMetrics(
177
+ label=label,
178
+ writes=writes,
179
+ failures=self._sink_failures.get(label, 0),
180
+ write_seconds=self._sink_seconds.get(label, 0.0),
181
+ )
182
+ for label, writes in self._sink_writes.items()
183
+ )
184
+ return MetricsSnapshot(
185
+ events=self._events,
186
+ dropped_events=self._dropped_events,
187
+ sink_failures=sum(self._sink_failures.values()),
188
+ enrichment_calls=self._enrichment_calls,
189
+ enrichment_seconds=self._enrichment_seconds,
190
+ active_seconds=self.active_seconds,
191
+ sinks=sinks,
192
+ )
@@ -0,0 +1,23 @@
1
+ """Torch-free, CPU-only title generation for activity/step spans.
2
+
3
+ See :mod:`traceforge.title.inference` for the served titler. Title hygiene
4
+ (decode cleanup, parent/child de-dup) lives in :mod:`traceforge.title.hygiene`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .context import distilled_context
10
+ from .hygiene import best_of, clean_title, norm_key, pick_distinct
11
+ from .inference import TitleModel
12
+ from .inferencer import SessionTitleStream, TitleInferencer
13
+
14
+ __all__ = [
15
+ "SessionTitleStream",
16
+ "TitleInferencer",
17
+ "TitleModel",
18
+ "best_of",
19
+ "clean_title",
20
+ "distilled_context",
21
+ "norm_key",
22
+ "pick_distinct",
23
+ ]
@@ -0,0 +1,79 @@
1
+ """Locate the packaged titler weights (the activity/step *span* head).
2
+
3
+ The ONNX weights ship in the separate :mod:`traceforge_title_model` package, which
4
+ is a hard dependency of traceforge, so a normal install always has them. This
5
+ module resolves the span head's directory, in order:
6
+
7
+ 1. the installed :mod:`traceforge_title_model` package (the shipped path);
8
+ 2. an in-tree dev fallback (``src/traceforge/title/data/``) so a source checkout
9
+ with the ONNX dropped in place still serves without the installed package;
10
+ 3. ``None`` -> the caller raises :data:`INSTALL_HINT`.
11
+
12
+ Session naming no longer uses a packaged head (the distilled request head was
13
+ dropped as proven-weak); it is served by :mod:`traceforge.title.naming` instead.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ #: The three files :meth:`traceforge.title.TitleModel.load` reads for one head.
21
+ TRIAD = ("encoder.onnx", "decoder.onnx", "tokenizer.json")
22
+
23
+ _LFS_MAGIC = b"version https://git-lfs.github.com/spec/v1"
24
+ #: Real ONNX heads are tens of MB; a Git LFS pointer is ~133 bytes. This floor
25
+ #: cleanly separates a smudged binary from a pointer (or a truncated artifact).
26
+ _MIN_ONNX_BYTES = 1_000_000
27
+
28
+ _HERE = Path(__file__).resolve().parent
29
+ #: In-tree dev fallback. Empty in a normal install (weights live in the data
30
+ #: package); populated only if a developer drops the ONNX here by hand.
31
+ _DEV_SPAN = _HERE / "data"
32
+
33
+ INSTALL_HINT = (
34
+ "traceforge titler weights are not installed. They ship with the "
35
+ "'traceforge-title-model' package (a dependency of traceforge), so reinstalling "
36
+ "should restore them:\n"
37
+ " pip install --force-reinstall traceforge-title-model\n"
38
+ "or, if PyPI is unavailable, pull the GitHub-release mirror:\n"
39
+ " traceforge download-model --source gh"
40
+ )
41
+
42
+
43
+ def _usable(f: Path) -> bool:
44
+ """A weight file that is present *and* a real binary (not an LFS pointer).
45
+
46
+ Guards the pointer-in-wheel failure mode: if a package was built from a
47
+ checkout that never smudged LFS, the files exist but are ~133-byte pointer
48
+ stubs. Existence alone would then resolve as "installed" and fail only later,
49
+ deep inside onnxruntime. Reject pointers (and implausibly small ONNX) here so
50
+ the caller falls back / surfaces :data:`INSTALL_HINT` instead.
51
+ """
52
+ try:
53
+ if not f.is_file():
54
+ return False
55
+ if f.suffix == ".onnx" and f.stat().st_size < _MIN_ONNX_BYTES:
56
+ return False
57
+ with f.open("rb") as fh:
58
+ return not fh.read(len(_LFS_MAGIC)).startswith(_LFS_MAGIC)
59
+ except OSError:
60
+ return False
61
+
62
+
63
+ def _complete(d: Path | None) -> bool:
64
+ return d is not None and all(_usable(d / f) for f in TRIAD)
65
+
66
+
67
+ def _pkg_dir() -> Path | None:
68
+ """The span head dir from the installed data package, if complete."""
69
+ try:
70
+ import traceforge_title_model as m
71
+ except ImportError:
72
+ return None
73
+ d = m.span_dir()
74
+ return d if _complete(d) else None
75
+
76
+
77
+ def span_dir() -> Path | None:
78
+ """Resolved span (activity/step) head dir, or ``None`` if unavailable."""
79
+ return _pkg_dir() or (_DEV_SPAN if _complete(_DEV_SPAN) else None)