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,477 @@
1
+ """Live activity/step **titling** over a boundary-stamped event stream.
2
+
3
+ The boundary classifier (:mod:`traceforge.boundary`) has already divided the
4
+ session into a two-level activity/step structure by stamping the opening label
5
+ on the event that *opens* each segment (``metadata.boundary``). This module is
6
+ the next stage: it turns those segments into human-readable titles.
7
+
8
+ A faithful title needs the segment's **whole** content, and a segment is only
9
+ complete once its closing boundary fires (i.e. the next segment opens). Rather
10
+ than hold the segment's events back until then -- which would stall live
11
+ emission for the length of an activity -- this stream is a *streaming
12
+ enrichment*: it assigns each segment a stable id the instant it opens (the
13
+ opening event's id), stamps that ``activity_id``/``step_id`` on every event, and
14
+ releases the event **immediately**. When the activity later closes, it is
15
+ distilled (:func:`traceforge.title.context.distilled_context`) and titled with
16
+ the torch-free :class:`traceforge.title.TitleModel`, and the titles are published
17
+ as append-only :class:`traceforge.types.TitleUpdate` records keyed to those ids
18
+ -- never by mutating the already-emitted events. Titling at the activity
19
+ granularity lets the activity title see all its steps while each step title
20
+ still sees its own full content, and keeps step titles distinct from their
21
+ parent activity and siblings via :func:`traceforge.title.hygiene.pick_distinct`.
22
+
23
+ The model is loaded lazily on first close and runs CPU-only with a capped thread
24
+ count, so an inactive session costs nothing and an active one runs the heavy
25
+ model once per *segment*, never per event.
26
+
27
+ Activity/step titles default to that packaged model alone, but are also
28
+ configurable (mirroring session naming): when ``title.activity_titling.strategy``
29
+ is ``api`` *and* a provider key is present, each closed activity's packaged
30
+ titles are additionally queued for an **off-hot-path** LiteLLM refinement
31
+ (:meth:`TitleInferencer.refine_activity`, backed by
32
+ :mod:`traceforge.title.naming`). The pipeline upgrades the activity title and all
33
+ its step titles in one call in a worker thread and emits them as later
34
+ append-only :class:`~traceforge.types.TitleUpdate` records on the *same* segment
35
+ ids. Absent a key, or on any API failure/timeout, the packaged titles stand, so
36
+ the default (``strategy=model``) path is byte-for-byte unchanged and never blocks
37
+ live emission.
38
+
39
+ The same machinery also titles the **session** itself: fed the first substantive
40
+ user message (:meth:`TitleInferencer.request_title`), it emits a ``kind="session"``
41
+ :class:`~traceforge.types.TitleUpdate` keyed by the session id -- the session label,
42
+ live off its opening request. Session naming does **not** use the span model: the
43
+ distilled request head was proven weak at it (~9% coherent on the honest CodePlane
44
+ heldout), so the session title is produced by :mod:`traceforge.title.naming` -- a
45
+ deterministic, zero-cost heuristic over the user's own words by default, with an
46
+ opt-in LiteLLM API tier engaged only when a key is configured.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from dataclasses import dataclass, field
52
+
53
+ from traceforge.phase.event_rows import event_to_feature_row
54
+ from traceforge.types import EventKind, SessionEvent, TitleUpdate
55
+
56
+ from .context import distilled_context, narration, payload_text
57
+ from .hygiene import best_of, norm_key, pick_distinct
58
+
59
+ _ACTIVITY = "activity-boundary"
60
+ _STEP = "step-boundary"
61
+
62
+
63
+ class TitleInferencer:
64
+ """Loads the torch-free titler once and applies it to live spans.
65
+
66
+ Construct with an explicit ``model``/``model_dir`` or rely on the packaged
67
+ default. The (heavy, optional-dependency) span model loads lazily on the
68
+ first closed segment, so a pipeline with no titled activities never imports
69
+ it. Session naming is served separately by :mod:`traceforge.title.naming`;
70
+ inject a ``session_titler`` callable to override it (e.g. in tests).
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ model=None,
76
+ model_dir=None,
77
+ session_titler=None,
78
+ session_refiner=None,
79
+ activity_refiner=None,
80
+ ) -> None:
81
+ self._model = model
82
+ self._model_dir = model_dir
83
+ # Explicit overrides for the session titler (tests inject plain
84
+ # callables). ``session_titler`` is the *immediate* heuristic title;
85
+ # ``session_refiner`` is the optional off-hot-path API upgrade. When
86
+ # neither is given both are built lazily from the global config.
87
+ self._session_titler = session_titler
88
+ self._session_refiner_override = session_refiner
89
+ self._titler_built = False
90
+ self._session_heuristic = None
91
+ self._session_refiner = None
92
+ # Optional off-hot-path activity/step-title API refiner. ``None`` (the
93
+ # default) means the packaged ONNX titles are final. Injected directly in
94
+ # tests; otherwise built lazily from ``title.activity_titling`` on first
95
+ # use, so a pipeline that never refines a span pays nothing.
96
+ self._activity_refiner_override = activity_refiner
97
+ self._activity_titler_built = False
98
+ self._activity_refiner = None
99
+
100
+ @property
101
+ def model(self):
102
+ if self._model is None:
103
+ from .inference import TitleModel
104
+
105
+ self._model = TitleModel.load(self._model_dir, threads=1)
106
+ return self._model
107
+
108
+ def _ensure_session_titler(self) -> None:
109
+ if self._titler_built:
110
+ return
111
+ if self._session_titler is not None or self._session_refiner_override is not None:
112
+ heuristic = self._session_titler
113
+ if heuristic is None:
114
+ from .naming import build_session_titler_split
115
+
116
+ heuristic = build_session_titler_split().heuristic
117
+ self._session_heuristic = heuristic
118
+ self._session_refiner = self._session_refiner_override
119
+ else:
120
+ from .naming import build_session_titler_split
121
+
122
+ split = build_session_titler_split()
123
+ self._session_heuristic = split.heuristic
124
+ self._session_refiner = split.api_refiner
125
+ self._titler_built = True
126
+
127
+ def request_title(self, text: str) -> str:
128
+ """Immediate, non-blocking session title (the heuristic floor).
129
+
130
+ Returns the free extractive title over the user's own words the instant
131
+ the opening request arrives; it never touches the network, so live event
132
+ emission is never blocked on a title. When ``strategy=api`` is configured
133
+ the abstractive upgrade is served separately by :meth:`refine_title`, run
134
+ by the pipeline off the hot path. The titler is built lazily from the
135
+ global config on first use, so a pipeline that never titles a session
136
+ pays nothing.
137
+ """
138
+ if not text or not text.strip():
139
+ return ""
140
+ self._ensure_session_titler()
141
+ return self._session_heuristic(text)
142
+
143
+ @property
144
+ def has_session_refiner(self) -> bool:
145
+ """Whether an off-hot-path API session-title refiner is configured."""
146
+ self._ensure_session_titler()
147
+ return self._session_refiner is not None
148
+
149
+ def refine_title(self, text: str) -> str:
150
+ """Abstractive session-title upgrade for the opt-in API tier.
151
+
152
+ Returns the API-refined title, or ``""`` when no refiner is configured or
153
+ the API call fails/times out (the caller then keeps the heuristic). This
154
+ blocks on the network and MUST be run off the hot path (the pipeline runs
155
+ it in a worker thread and emits the result as a later ``TitleUpdate``).
156
+ """
157
+ if not text or not text.strip():
158
+ return ""
159
+ self._ensure_session_titler()
160
+ if self._session_refiner is None:
161
+ return ""
162
+ return self._session_refiner(text)
163
+
164
+ def _ensure_activity_titler(self) -> None:
165
+ if self._activity_titler_built:
166
+ return
167
+ if self._activity_refiner_override is not None:
168
+ self._activity_refiner = self._activity_refiner_override
169
+ else:
170
+ from .naming import build_activity_refiner
171
+
172
+ self._activity_refiner = build_activity_refiner()
173
+ self._activity_titler_built = True
174
+
175
+ @property
176
+ def has_activity_refiner(self) -> bool:
177
+ """Whether an off-hot-path API activity/step-title refiner is configured."""
178
+ self._ensure_activity_titler()
179
+ return self._activity_refiner is not None
180
+
181
+ def refine_activity(self, closed: "_ClosedActivity") -> list["TitleRefinement"]:
182
+ """Abstractive activity/step-title upgrades for the opt-in API tier.
183
+
184
+ Distils the just-closed activity and each of its steps, asks the
185
+ configured API refiner for better titles in **one** call, and returns a
186
+ per-segment :class:`TitleRefinement` for the activity and every step the
187
+ API usefully covered. Returns ``[]`` when no refiner is configured or the
188
+ API returns nothing usable (missing key, provider error, timeout, or an
189
+ unparseable reply), so the packaged-model titles already emitted stand.
190
+
191
+ This blocks on the network and MUST run off the hot path (the pipeline
192
+ runs it in a worker thread and emits each result as a later
193
+ ``TitleUpdate`` on the same segment id). Step titles are kept distinct
194
+ from the (effective) activity title and one another via
195
+ :func:`traceforge.title.hygiene.norm_key`; any API step title that
196
+ collides is dropped so that step keeps its distinct packaged-model title.
197
+ """
198
+ self._ensure_activity_titler()
199
+ if self._activity_refiner is None:
200
+ return []
201
+ from .naming import ActivitySpan
202
+
203
+ step_contexts = [distilled_context(rows) for _sid, rows in closed.steps]
204
+ activity_rows = [r for _sid, rows in closed.steps for r in rows]
205
+ activity_context = distilled_context(activity_rows)
206
+ # Nothing extractable anywhere -> no useful API call (parity with the
207
+ # packaged path, which emits no updates for a signal-less span).
208
+ if activity_context == "(no signal)" and all(c == "(no signal)" for c in step_contexts):
209
+ return []
210
+ titles = self._activity_refiner(ActivitySpan(activity_context, step_contexts))
211
+
212
+ refinements: list["TitleRefinement"] = []
213
+ used: set = set()
214
+ if titles.activity:
215
+ refinements.append(TitleRefinement(closed.activity_id, "activity", titles.activity))
216
+ effective_activity = titles.activity or closed.activity_title
217
+ if effective_activity:
218
+ used.add(norm_key(effective_activity))
219
+ for (step_id, _rows), step_title in zip(closed.steps, titles.steps):
220
+ if not step_title:
221
+ continue
222
+ key = norm_key(step_title)
223
+ if not key or key in used:
224
+ continue # collides with the activity/a sibling -> keep packaged title
225
+ used.add(key)
226
+ refinements.append(TitleRefinement(step_id, "step", step_title, closed.activity_id))
227
+ return refinements
228
+
229
+ def _title(self, rows: list[dict]) -> str:
230
+ ctx = distilled_context(rows)
231
+ if ctx == "(no signal)":
232
+ return ""
233
+ return best_of(self.model.candidates(ctx))
234
+
235
+ def _title_distinct(self, rows: list[dict], used: set) -> str:
236
+ ctx = distilled_context(rows)
237
+ if ctx == "(no signal)":
238
+ return ""
239
+ return pick_distinct(used, self.model.candidates(ctx))
240
+
241
+ def new_stream(self, session_id: str, source: str = "") -> "SessionTitleStream":
242
+ """Open a live per-session titling stream."""
243
+
244
+ return SessionTitleStream(self, session_id, source)
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class TitleRefinement:
249
+ """One off-hot-path title upgrade for a closed activity or one of its steps.
250
+
251
+ Produced by :meth:`TitleInferencer.refine_activity` and turned into a later
252
+ append-only :class:`~traceforge.types.TitleUpdate` (same ``segment_id`` /
253
+ ``kind``) by the pipeline, superseding the packaged-model title.
254
+ """
255
+
256
+ segment_id: str
257
+ kind: str # "activity" | "step"
258
+ title: str
259
+ parent_id: str | None = None
260
+
261
+
262
+ @dataclass
263
+ class _ClosedActivity:
264
+ """A just-closed activity queued for off-hot-path API title refinement.
265
+
266
+ Carries the segment ids and the raw feature rows (already built on the hot
267
+ path) so the worker thread can distil + call the API without touching the
268
+ live stream. ``activity_title`` is the packaged-model activity title (or
269
+ ``None``); it only seeds step-title distinctness when the API leaves the
270
+ activity title unchanged.
271
+ """
272
+
273
+ activity_id: str
274
+ activity_title: str | None
275
+ steps: list[tuple[str, list[dict]]] = field(default_factory=list) # (step_id, rows), in order
276
+
277
+
278
+ class _Step:
279
+ __slots__ = ("step_id", "rows")
280
+
281
+ def __init__(self, step_id: str) -> None:
282
+ self.step_id = step_id
283
+ self.rows: list[dict] = []
284
+
285
+
286
+ class SessionTitleStream:
287
+ """Stamps live segment ids, titles the session, and titles each activity.
288
+
289
+ Feed events (already boundary-stamped) in arrival order via :meth:`push`;
290
+ each call returns ``(event, updates)`` where ``event`` is the same event now
291
+ carrying its ``activity_id``/``step_id`` (ready to emit immediately) and
292
+ ``updates`` is the list of :class:`TitleUpdate` records for the activity that
293
+ this event just closed (empty while the current activity is still open). The
294
+ first substantive user message also yields a ``kind="session"`` update --
295
+ the session label, emitted live off its opening request. Call :meth:`flush`
296
+ at session end to title the final open activity.
297
+ """
298
+
299
+ def __init__(self, inferencer: "TitleInferencer", session_id: str, source: str) -> None:
300
+ self._inf = inferencer
301
+ self._session_id = session_id
302
+ self._source = source
303
+ self._seq = 0
304
+ self._activity_id: str | None = None
305
+ self._steps: list[_Step] = [] # steps of the currently-open activity
306
+ self._session_titled = False # set-once; the session label is its opening intent
307
+ # Text queued for off-hot-path API session-title refinement, set when the
308
+ # heuristic title is emitted and an API refiner is configured. The
309
+ # pipeline pops it via :meth:`take_session_refinement` and refines in a
310
+ # worker thread, so the network never blocks live emission.
311
+ self._pending_refine_text: str | None = None
312
+ # Closed activities queued for off-hot-path API activity/step-title
313
+ # refinement, appended by :meth:`_close_activity` when an activity refiner
314
+ # is configured. The pipeline pops them via
315
+ # :meth:`take_activity_refinements` and refines each in a worker thread,
316
+ # emitting the upgraded titles as later append-only ``TitleUpdate``s.
317
+ self._pending_activity_refinements: list[_ClosedActivity] = []
318
+
319
+ def push(self, event: SessionEvent) -> tuple[SessionEvent, list[TitleUpdate]]:
320
+ """Ingest one event; stamp its segment ids and emit it now, returning
321
+ any TitleUpdates for the activity it just closed (plus, on the first
322
+ substantive user message, the session-level title)."""
323
+
324
+ row = event_to_feature_row(event, self._seq)
325
+ self._seq += 1
326
+ boundary = event.metadata.boundary if event.metadata is not None else None
327
+
328
+ updates: list[TitleUpdate] = []
329
+ opens_activity = not self._steps or boundary == _ACTIVITY
330
+ if self._steps and boundary == _ACTIVITY:
331
+ updates = self._close_activity()
332
+
333
+ if opens_activity:
334
+ self._activity_id = event.id
335
+ self._steps = [_Step(event.id)]
336
+ elif boundary == _STEP:
337
+ self._steps.append(_Step(event.id))
338
+
339
+ step = self._steps[-1]
340
+ step.rows.append(row)
341
+ stamped = self._stamp(event, self._activity_id, step.step_id)
342
+ return stamped, self._maybe_session_title(event, row) + updates
343
+
344
+ def _maybe_session_title(self, event: SessionEvent, row: dict) -> list[TitleUpdate]:
345
+ """Title the session from its first *substantive* user message.
346
+
347
+ Substance (R1) reuses the shipped narration hygiene with no new
348
+ threshold: a message is substantive iff it yields >=1 sentence under
349
+ :func:`traceforge.title.context.narration` -- greetings / acks collapse to
350
+ zero, real requests keep one. The **heuristic** title is emitted live and
351
+ non-blocking the instant that message arrives, keyed by
352
+ ``segment_id == session_id``. When an API refiner is configured the raw
353
+ request text is queued (see :meth:`take_session_refinement`) so the
354
+ pipeline can upgrade the title off the hot path -- the network never
355
+ blocks live emission. The set-once flag means a mid-session
356
+ SESSION_ENDED/PAUSED (which can recur on resume) never re-triggers or
357
+ tears down the session title; a contentless session simply gets none.
358
+ """
359
+ if self._session_titled or event.kind != EventKind.MESSAGE_USER:
360
+ return []
361
+ if not narration([row]):
362
+ return []
363
+ text = payload_text(row)
364
+ title = self._inf.request_title(text)
365
+ if not title:
366
+ return []
367
+ self._session_titled = True
368
+ if self._inf.has_session_refiner:
369
+ self._pending_refine_text = text
370
+ return [
371
+ TitleUpdate(
372
+ session_id=self._session_id,
373
+ segment_id=self._session_id,
374
+ kind="session",
375
+ title=title,
376
+ )
377
+ ]
378
+
379
+ def take_session_refinement(self) -> str | None:
380
+ """Pop the request text queued for off-hot-path API title refinement.
381
+
382
+ Returns the text once (then ``None``), or ``None`` if no refinement is
383
+ pending. The pipeline calls this right after :meth:`push` and, when a
384
+ text is returned, refines the session title in a worker thread and emits
385
+ the result as a later session :class:`TitleUpdate`.
386
+ """
387
+ text = self._pending_refine_text
388
+ self._pending_refine_text = None
389
+ return text
390
+
391
+ def flush(self) -> list[TitleUpdate]:
392
+ """Title the final open activity (if any) and return its updates."""
393
+
394
+ if not self._steps:
395
+ return []
396
+ return self._close_activity()
397
+
398
+ def _close_activity(self) -> list[TitleUpdate]:
399
+ """Title the just-closed activity + its steps as append-only updates.
400
+
401
+ The packaged-model titles are computed and returned immediately (the
402
+ pipeline emits them the instant the activity closes). When an API
403
+ activity refiner is configured *and* this activity produced at least one
404
+ packaged title, the closed activity (ids + raw rows) is also queued for
405
+ off-hot-path API refinement via :meth:`take_activity_refinements`; the
406
+ pipeline upgrades the titles in a worker thread and emits them as later
407
+ append-only updates. Queueing is skipped entirely by default
408
+ (``strategy=model``), so the packaged path is unchanged.
409
+ """
410
+
411
+ steps = self._steps
412
+ activity_id = self._activity_id
413
+ self._steps = []
414
+ self._activity_id = None
415
+
416
+ activity_rows = [r for s in steps for r in s.rows]
417
+ activity_title = self._inf._title(activity_rows) or None
418
+
419
+ updates: list[TitleUpdate] = []
420
+ used: set = set()
421
+ if activity_title:
422
+ used.add(norm_key(activity_title))
423
+ updates.append(
424
+ TitleUpdate(
425
+ session_id=self._session_id,
426
+ segment_id=activity_id,
427
+ kind="activity",
428
+ title=activity_title,
429
+ )
430
+ )
431
+
432
+ for step in steps:
433
+ step_title = self._inf._title_distinct(step.rows, used) or None
434
+ if step_title:
435
+ updates.append(
436
+ TitleUpdate(
437
+ session_id=self._session_id,
438
+ segment_id=step.step_id,
439
+ kind="step",
440
+ title=step_title,
441
+ parent_id=activity_id,
442
+ )
443
+ )
444
+
445
+ # Queue this activity for an off-hot-path API upgrade only when a refiner
446
+ # is configured and there is at least one packaged title to upgrade.
447
+ if updates and self._inf.has_activity_refiner:
448
+ self._pending_activity_refinements.append(
449
+ _ClosedActivity(
450
+ activity_id=activity_id,
451
+ activity_title=activity_title,
452
+ steps=[(s.step_id, s.rows) for s in steps],
453
+ )
454
+ )
455
+ return updates
456
+
457
+ def take_activity_refinements(self) -> list["_ClosedActivity"]:
458
+ """Pop the closed activities queued for off-hot-path API refinement.
459
+
460
+ Returns them once (then empties the queue). The pipeline calls this right
461
+ after :meth:`push` / :meth:`flush` and, for each returned activity,
462
+ refines its titles in a worker thread (:meth:`TitleInferencer.refine_activity`)
463
+ and emits the upgrades as later append-only :class:`TitleUpdate`s.
464
+ """
465
+ out = self._pending_activity_refinements
466
+ self._pending_activity_refinements = []
467
+ return out
468
+
469
+ @staticmethod
470
+ def _stamp(event: SessionEvent, activity_id: str | None, step_id: str | None) -> SessionEvent:
471
+ if event.metadata is None:
472
+ return event
473
+ new_md = event.metadata.model_copy(update={"activity_id": activity_id, "step_id": step_id})
474
+ return event.model_copy(update={"metadata": new_md})
475
+
476
+
477
+ __all__ = ["TitleInferencer", "SessionTitleStream", "TitleRefinement"]