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,95 @@
1
+ """Base storage sink interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
10
+
11
+ if TYPE_CHECKING:
12
+ from traceforge.governance.envelope import EnrichedEvent
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class StorageSink(ABC):
18
+ # Sink classes that have already been warned about dropping title updates,
19
+ # so the warning fires once per class per process rather than per title.
20
+ _title_drop_warned: ClassVar[set[str]] = set()
21
+ # Sink classes already warned about dropping non-event envelope payloads
22
+ # (gap markers / governance-only records) they do not know how to persist.
23
+ _enriched_drop_warned: ClassVar[set[str]] = set()
24
+
25
+ @abstractmethod
26
+ async def on_event(self, event: SessionEvent) -> None:
27
+ """Handle a session event. Required for all sinks."""
28
+ ...
29
+
30
+ async def on_enriched_event(self, enriched: "EnrichedEvent") -> None:
31
+ """Handle a governance-enriched event envelope.
32
+
33
+ This is the emission entry point used when governance is wired into the
34
+ pipeline: the envelope pairs an event with its ``SessionMeta``. The base
35
+ default keeps every existing sink working unchanged — a live
36
+ ``SessionEvent`` (which already carries its ``metadata.governance`` stamp)
37
+ is forwarded to :meth:`on_event`, so a sink that only implements
38
+ ``on_event`` sees byte-identical output whether or not governance is on.
39
+
40
+ Synthetic envelope payloads that are *not* live session events — a
41
+ :class:`~traceforge.governance.envelope.ContextGapEvent` emitted under
42
+ backpressure, or a governance-only record — cannot be expressed through
43
+ ``on_event``. Rather than silently drop them (they signal audit gaps),
44
+ the base logs a one-time warning naming the sink class. Sinks that want
45
+ to persist gaps override this method (``JsonlSink``/``SqliteOutputSink``
46
+ do).
47
+ """
48
+ event = enriched.event
49
+ if isinstance(event, SessionEvent):
50
+ await self.on_event(event)
51
+ return
52
+ cls = type(self).__name__
53
+ if cls not in StorageSink._enriched_drop_warned:
54
+ StorageSink._enriched_drop_warned.add(cls)
55
+ logger.warning(
56
+ "%s does not handle non-event envelope payloads (e.g. context-gap "
57
+ "markers); they will be dropped. Override on_enriched_event(enriched) "
58
+ "to persist or forward them.",
59
+ cls,
60
+ )
61
+
62
+ async def on_span(self, span: TelemetrySpan) -> None:
63
+ """Handle a telemetry span. Default no-op."""
64
+
65
+ async def on_usage(self, usage: UsageRecord) -> None:
66
+ """Handle a usage record. Default no-op."""
67
+
68
+ async def on_title_update(self, update: TitleUpdate) -> None:
69
+ """Handle an out-of-band title for a closed activity/step segment.
70
+
71
+ Emitted after the segment's events (which already carry its
72
+ ``activity_id``/``step_id``); keyed to them by ``segment_id``. See
73
+ :class:`traceforge.types.TitleUpdate`.
74
+
75
+ Titles are the structurer's primary output, so — unlike the
76
+ ``on_span``/``on_usage`` no-ops — the base default does **not** silently
77
+ drop them: it logs a one-time warning naming the sink class. A custom
78
+ sink keeps working without overriding this, but the author is told that
79
+ titles are being discarded and should override to persist them. All
80
+ in-repo sinks override it.
81
+ """
82
+ cls = type(self).__name__
83
+ if cls not in StorageSink._title_drop_warned:
84
+ StorageSink._title_drop_warned.add(cls)
85
+ logger.warning(
86
+ "%s does not handle title updates; activity/step titles will be "
87
+ "dropped. Override on_title_update(update) to persist or forward them.",
88
+ cls,
89
+ )
90
+
91
+ async def flush(self) -> None:
92
+ """Flush buffered writes. Default no-op."""
93
+
94
+ async def close(self) -> None:
95
+ """Clean up resources. Default no-op."""
@@ -0,0 +1,132 @@
1
+ """Callback-based storage sink for custom event handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import Awaitable, Callable, Iterable
8
+
9
+ from traceforge.sinks.base import StorageSink
10
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
11
+
12
+ #: Accepted shapes for a lightweight event subscriber: a coroutine function or a
13
+ #: plain sync callable (both taking a single :class:`SessionEvent`).
14
+ EventCallback = Callable[[SessionEvent], Awaitable[None] | None]
15
+
16
+ #: Accepted shapes for a per-subscriber kind filter. ``None`` means "all events".
17
+ #: A string is an exact kind, or a ``"prefix.*"`` wildcard matching that dotted
18
+ #: namespace (e.g. ``"tool.*"`` matches ``tool.call``/``tool.result`` but not
19
+ #: ``tool`` or ``toolbar``). An iterable of such strings matches if *any* does. A
20
+ #: callable is used verbatim as a predicate over the event.
21
+ KindFilter = str | Iterable[str] | Callable[[SessionEvent], bool] | None
22
+
23
+
24
+ class CallbackSink(StorageSink):
25
+ """A sink that delegates to user-provided async callback functions."""
26
+
27
+ def __init__(
28
+ self,
29
+ on_event: Callable[[SessionEvent], Awaitable[None]] | None = None,
30
+ on_span: Callable[[TelemetrySpan], Awaitable[None]] | None = None,
31
+ on_usage: Callable[[UsageRecord], Awaitable[None]] | None = None,
32
+ on_title_update: Callable[[TitleUpdate], Awaitable[None]] | None = None,
33
+ ) -> None:
34
+ self._on_event = on_event
35
+ self._on_span = on_span
36
+ self._on_usage = on_usage
37
+ self._on_title_update = on_title_update
38
+
39
+ async def on_event(self, event: SessionEvent) -> None:
40
+ if self._on_event is not None:
41
+ await self._on_event(event)
42
+
43
+ async def on_span(self, span: TelemetrySpan) -> None:
44
+ if self._on_span is not None:
45
+ await self._on_span(span)
46
+
47
+ async def on_usage(self, usage: UsageRecord) -> None:
48
+ if self._on_usage is not None:
49
+ await self._on_usage(usage)
50
+
51
+ async def on_title_update(self, update: TitleUpdate) -> None:
52
+ if self._on_title_update is not None:
53
+ await self._on_title_update(update)
54
+
55
+
56
+ def _kind_predicate(kind: KindFilter) -> Callable[[SessionEvent], bool] | None:
57
+ """Compile a :data:`KindFilter` into a predicate over :class:`SessionEvent`.
58
+
59
+ Returns ``None`` (meaning "no filtering") for ``None`` or an empty iterable,
60
+ so the dispatch wrapper can skip the check entirely in the common case.
61
+ """
62
+ if kind is None:
63
+ return None
64
+ if isinstance(kind, str):
65
+ patterns: tuple[str, ...] = (kind,)
66
+ elif callable(kind):
67
+ return kind
68
+ else:
69
+ patterns = tuple(kind)
70
+ if not patterns:
71
+ return None
72
+
73
+ def predicate(event: SessionEvent) -> bool:
74
+ return any(_pattern_matches(pattern, event.kind) for pattern in patterns)
75
+
76
+ return predicate
77
+
78
+
79
+ def _pattern_matches(pattern: str, kind: str) -> bool:
80
+ """Match one kind string against an exact or ``"prefix.*"`` wildcard pattern."""
81
+ if pattern.endswith(".*"):
82
+ # ``"tool.*"`` -> prefix ``"tool."`` -> matches ``tool.call`` but not
83
+ # bare ``tool`` or an unrelated ``toolbar``.
84
+ return kind.startswith(pattern[:-1])
85
+ return kind == pattern
86
+
87
+
88
+ def as_async_event_callback(
89
+ callback: EventCallback,
90
+ *,
91
+ kind: KindFilter = None,
92
+ to_thread: bool = False,
93
+ ) -> Callable[[SessionEvent], Awaitable[None]]:
94
+ """Adapt a sync-or-async event callback into the ``async on_event`` shape.
95
+
96
+ This is the adapter behind :meth:`~traceforge.pipeline.EventPipeline.subscribe`:
97
+ it lets a plain ``Callable[[SessionEvent], None]`` act as a first-class event
98
+ subscriber without the caller writing ``async def`` or a full sink, and it
99
+ applies an optional per-subscriber ``kind`` filter *before* dispatch so a
100
+ filtered-out event never touches the callback.
101
+
102
+ - **async callables** (coroutine functions) are awaited directly.
103
+ - **sync callables** are called inline on the event loop by default — right
104
+ for the lightweight consumers this exists for (append to a list, put on a
105
+ queue). Pass ``to_thread=True`` to instead run a blocking sync callback via
106
+ :func:`asyncio.to_thread`, so it never stalls the loop.
107
+ - A sync callable that unexpectedly returns an awaitable (e.g. an object whose
108
+ ``__call__`` is ``async`` and so is not flagged by
109
+ :func:`asyncio.iscoroutinefunction`) is awaited defensively.
110
+
111
+ Raises ``TypeError`` if ``callback`` is not callable.
112
+ """
113
+ if not callable(callback):
114
+ raise TypeError(f"event callback must be callable, got {type(callback).__name__}")
115
+
116
+ predicate = _kind_predicate(kind)
117
+ is_async = asyncio.iscoroutinefunction(callback)
118
+
119
+ async def on_event(event: SessionEvent) -> None:
120
+ if predicate is not None and not predicate(event):
121
+ return
122
+ if is_async:
123
+ await callback(event)
124
+ return
125
+ if to_thread:
126
+ await asyncio.to_thread(callback, event)
127
+ return
128
+ result = callback(event)
129
+ if inspect.isawaitable(result):
130
+ await result
131
+
132
+ return on_event
@@ -0,0 +1,112 @@
1
+ """Console sink — pretty-prints governance results to terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ from traceforge.sinks.base import StorageSink
9
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # ANSI color codes
14
+ _RESET = "\033[0m"
15
+ _RED = "\033[91m"
16
+ _YELLOW = "\033[93m"
17
+ _GREEN = "\033[92m"
18
+ _DIM = "\033[2m"
19
+ _BOLD = "\033[1m"
20
+
21
+ _ACTION_COLORS = {
22
+ "deny": _RED,
23
+ "escalate": _RED,
24
+ "warn": _YELLOW,
25
+ "allow": _GREEN,
26
+ "monitor": _DIM,
27
+ }
28
+
29
+
30
+ class ConsoleSink(StorageSink):
31
+ """Prints governance-relevant events to the terminal.
32
+
33
+ Only emits events whose governance action matches the configured filter.
34
+ Designed for real-time human feedback during agent sessions.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ filter_actions: list[str] | None = None,
40
+ color: bool = True,
41
+ stream: object | None = None,
42
+ ) -> None:
43
+ self._filter = set(filter_actions or ["warn", "deny", "escalate"])
44
+ self._color = color and hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
45
+ self._stream = stream or sys.stderr
46
+
47
+ async def on_event(self, event: SessionEvent) -> None:
48
+ meta = event.metadata
49
+ if meta is None:
50
+ return
51
+
52
+ classification = meta.classification
53
+ if classification is None:
54
+ return
55
+
56
+ # Extract governance action from metadata
57
+ action = self._extract_action(event)
58
+ if action is None or action not in self._filter:
59
+ return
60
+
61
+ self._print_event(event, action)
62
+
63
+ def _extract_action(self, event: SessionEvent) -> str | None:
64
+ """Extract the recommended action from event metadata."""
65
+ gov = event.metadata.governance if event.metadata else None
66
+ if gov is None:
67
+ return None
68
+ rec = gov.recommendation
69
+ if rec is None:
70
+ return None
71
+ return rec.recommended_action.value
72
+
73
+ def _print_event(self, event: SessionEvent, action: str) -> None:
74
+ color = _ACTION_COLORS.get(action, _DIM) if self._color else ""
75
+ reset = _RESET if self._color else ""
76
+ bold = _BOLD if self._color else ""
77
+ dim = _DIM if self._color else ""
78
+
79
+ tool_name = event.payload.get("tool_name", event.kind) if event.payload else event.kind
80
+ args_preview = ""
81
+ if event.payload:
82
+ args = event.payload.get("arguments") or event.payload.get("command")
83
+ if args:
84
+ args_str = str(args)
85
+ args_preview = f" {dim}{args_str[:80]}{'...' if len(args_str) > 80 else ''}{reset}"
86
+
87
+ risk_score = ""
88
+ if event.metadata and event.metadata.governance:
89
+ gov = event.metadata.governance
90
+ if gov.risk_assessment is not None:
91
+ risk_score = f" [risk:{gov.risk_assessment.score}]"
92
+
93
+ ts = event.timestamp.strftime("%H:%M:%S") if event.timestamp else ""
94
+ line = f"{dim}{ts}{reset} {color}{bold}{action.upper()}{reset} {tool_name}{risk_score}{args_preview}"
95
+
96
+ print(line, file=self._stream)
97
+
98
+ async def on_span(self, span: TelemetrySpan) -> None:
99
+ pass
100
+
101
+ async def on_usage(self, usage: UsageRecord) -> None:
102
+ pass
103
+
104
+ async def on_title_update(self, update: TitleUpdate) -> None:
105
+ """Render a closed segment's title as an indented TOC line."""
106
+ color = (_GREEN if update.kind == "activity" else _DIM) if self._color else ""
107
+ reset = _RESET if self._color else ""
108
+ bold = _BOLD if self._color else ""
109
+ indent = "" if update.kind == "activity" else " "
110
+ label = update.kind.upper()
111
+ line = f"{indent}{color}{bold}{label}{reset} {update.title}"
112
+ print(line, file=self._stream)
@@ -0,0 +1,94 @@
1
+ """Single factory mapping declarative ``SinkConfig`` unions to sink instances.
2
+
3
+ One place builds standard sinks from config so the CLI daemon
4
+ (:func:`traceforge.cli.watch._build_sinks`) and the SDK
5
+ (:meth:`traceforge.sdk.Pipeline.from_config`) stay in parity — the exact
6
+ asymmetry issue #116 closes.
7
+
8
+ Optional-dependency sinks import lazily *per branch* (e.g. ``boto3`` for S3),
9
+ so a config that never mentions S3 never imports boto3.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from traceforge.config.models import SinkConfig
19
+ from traceforge.sinks.base import StorageSink
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def build_sinks(sink_configs: "list[SinkConfig]") -> "list[StorageSink]":
25
+ """Instantiate the standard sinks declared by a ``SinkConfig`` list.
26
+
27
+ Covers the full serializable union (``sqlite``, ``jsonl``, ``console``,
28
+ ``webhook``, ``otel``, ``s3``); each config field maps onto its sink
29
+ constructor faithfully. Callback / custom :class:`StorageSink` subclasses are
30
+ code-only and intentionally have no ``SinkConfig`` variant, so they are not
31
+ built here.
32
+
33
+ Unknown ``type`` discriminators are logged and skipped. This cannot happen
34
+ for a validated :data:`~traceforge.config.models.SinkConfig` (the union is
35
+ exhaustive), but keeping the branch total means a malformed duck-typed config
36
+ degrades gracefully instead of raising.
37
+ """
38
+ sinks: list = []
39
+ for sink_config in sink_configs:
40
+ sink_type = getattr(sink_config, "type", None)
41
+
42
+ if sink_type == "console":
43
+ from traceforge.sinks.console import ConsoleSink
44
+
45
+ sinks.append(ConsoleSink(filter_actions=sink_config.filter, color=sink_config.color))
46
+ elif sink_type == "sqlite":
47
+ from traceforge.sinks.sqlite_output import SqliteOutputSink
48
+
49
+ sinks.append(
50
+ SqliteOutputSink(path=sink_config.path, journal_mode=sink_config.journal_mode)
51
+ )
52
+ elif sink_type == "jsonl":
53
+ from traceforge.sinks.jsonl import JsonlSink
54
+
55
+ sinks.append(
56
+ JsonlSink(path=sink_config.path, rotate_size_mb=sink_config.rotate_size_mb)
57
+ )
58
+ elif sink_type == "webhook":
59
+ from traceforge.sinks.webhook import WebhookSink
60
+
61
+ sinks.append(
62
+ WebhookSink(
63
+ url=sink_config.url,
64
+ filter_actions=sink_config.filter,
65
+ timeout=sink_config.timeout,
66
+ max_retries=sink_config.max_retries,
67
+ headers=sink_config.headers,
68
+ )
69
+ )
70
+ elif sink_type == "otel":
71
+ from traceforge.sinks.otel_exporter import OtelExporterSink
72
+
73
+ sinks.append(
74
+ OtelExporterSink(
75
+ endpoint=sink_config.endpoint,
76
+ service_name=sink_config.service_name,
77
+ headers=sink_config.headers,
78
+ )
79
+ )
80
+ elif sink_type == "s3":
81
+ from traceforge.sinks.s3 import S3Sink
82
+
83
+ sinks.append(
84
+ S3Sink(
85
+ bucket=sink_config.bucket,
86
+ prefix=sink_config.prefix,
87
+ region=sink_config.region,
88
+ endpoint_url=sink_config.endpoint_url,
89
+ )
90
+ )
91
+ else:
92
+ logger.warning("Unknown sink type %r; skipping", sink_type)
93
+
94
+ return sinks
@@ -0,0 +1,125 @@
1
+ """JSONL file sink — append one JSON line per event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from traceforge.sinks.base import StorageSink
10
+ from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class JsonlSink(StorageSink):
16
+ """Appends events as JSON lines to a file.
17
+
18
+ Supports ``{session_id}`` template in path for per-session output files.
19
+ Creates parent directories on first write.
20
+ """
21
+
22
+ def __init__(self, path: str | Path, rotate_size_mb: float | None = None) -> None:
23
+ self._path_template = str(path)
24
+ self._rotate_size_mb = rotate_size_mb
25
+ self._handles: dict[str, object] = {}
26
+
27
+ _SAFE_SESSION_RE = __import__("re").compile(r"[^a-zA-Z0-9_\-.]")
28
+
29
+ def _resolve_path(self, session_id: str) -> Path:
30
+ sanitized = self._SAFE_SESSION_RE.sub("_", session_id)[:128]
31
+ resolved = self._path_template.replace("{session_id}", sanitized)
32
+ path = Path(resolved).expanduser().resolve()
33
+ # Ensure resolved path stays under the template's parent directory
34
+ base = Path(self._path_template.split("{session_id}")[0]).expanduser().resolve()
35
+ if not str(path).startswith(str(base)):
36
+ raise ValueError(f"JsonlSink: resolved path escapes base directory: {path}")
37
+ return path
38
+
39
+ async def on_event(self, event: SessionEvent) -> None:
40
+ path = self._resolve_path(event.session_id)
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+
43
+ line = json.dumps(
44
+ {
45
+ "id": event.id,
46
+ "kind": event.kind,
47
+ "session_id": event.session_id,
48
+ "timestamp": event.timestamp.isoformat(),
49
+ "payload": event.payload,
50
+ "metadata": event.metadata.model_dump(exclude_none=True)
51
+ if event.metadata
52
+ else None,
53
+ },
54
+ default=str,
55
+ )
56
+
57
+ try:
58
+ with open(path, "a", encoding="utf-8") as f:
59
+ f.write(line + "\n")
60
+ except OSError as exc:
61
+ logger.error("JsonlSink: failed to write to %s: %s", path, exc)
62
+
63
+ async def on_span(self, span: TelemetrySpan) -> None:
64
+ pass
65
+
66
+ async def on_usage(self, usage: UsageRecord) -> None:
67
+ pass
68
+
69
+ async def on_enriched_event(self, enriched) -> None:
70
+ """Persist a governance envelope. Live events keep byte-identical output;
71
+ context-gap markers are written as a tagged ``context_gap`` record."""
72
+ from traceforge.governance.envelope import ContextGapEvent
73
+
74
+ gap = enriched.event
75
+ if not isinstance(gap, ContextGapEvent):
76
+ await super().on_enriched_event(enriched)
77
+ return
78
+
79
+ path = self._resolve_path(gap.session_id)
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+
82
+ line = json.dumps(
83
+ {
84
+ "record": "context_gap",
85
+ "id": gap.id,
86
+ "session_id": gap.session_id,
87
+ "timestamp": gap.timestamp.isoformat(),
88
+ "dropped_count": gap.dropped_count,
89
+ "first_dropped_sequence": gap.first_dropped_sequence,
90
+ "last_dropped_sequence": gap.last_dropped_sequence,
91
+ "gap_ordinal": gap.gap_ordinal,
92
+ "reason": gap.reason,
93
+ "source_event_key": gap.source_event_key,
94
+ },
95
+ default=str,
96
+ )
97
+
98
+ try:
99
+ with open(path, "a", encoding="utf-8") as f:
100
+ f.write(line + "\n")
101
+ except OSError as exc:
102
+ logger.error("JsonlSink: failed to write to %s: %s", path, exc)
103
+
104
+ async def on_title_update(self, update: TitleUpdate) -> None:
105
+ path = self._resolve_path(update.session_id)
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+
108
+ line = json.dumps(
109
+ {
110
+ "record": "title_update",
111
+ "session_id": update.session_id,
112
+ "segment_id": update.segment_id,
113
+ "kind": update.kind,
114
+ "title": update.title,
115
+ "version": update.version,
116
+ "parent_id": update.parent_id,
117
+ },
118
+ default=str,
119
+ )
120
+
121
+ try:
122
+ with open(path, "a", encoding="utf-8") as f:
123
+ f.write(line + "\n")
124
+ except OSError as exc:
125
+ logger.error("JsonlSink: failed to write to %s: %s", path, exc)