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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""SQLite output sink — stores enriched events in a queryable database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from traceforge.sinks.base import StorageSink
|
|
12
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_CREATE_TABLE = """\
|
|
17
|
+
CREATE TABLE IF NOT EXISTS enriched_events (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
session_id TEXT NOT NULL,
|
|
20
|
+
kind TEXT NOT NULL,
|
|
21
|
+
timestamp TEXT NOT NULL,
|
|
22
|
+
tool_name TEXT,
|
|
23
|
+
risk_level TEXT,
|
|
24
|
+
risk_score INTEGER,
|
|
25
|
+
action TEXT,
|
|
26
|
+
payload_json TEXT,
|
|
27
|
+
metadata_json TEXT,
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_enriched_session ON enriched_events(session_id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_enriched_timestamp ON enriched_events(timestamp);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_enriched_risk ON enriched_events(risk_level);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_enriched_action ON enriched_events(action);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS segment_titles (
|
|
37
|
+
segment_id TEXT NOT NULL,
|
|
38
|
+
kind TEXT NOT NULL,
|
|
39
|
+
session_id TEXT NOT NULL,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
version INTEGER NOT NULL,
|
|
42
|
+
parent_id TEXT,
|
|
43
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
44
|
+
PRIMARY KEY (segment_id, kind)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_segment_titles_session ON segment_titles(session_id);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS context_gaps (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
session_id TEXT NOT NULL,
|
|
52
|
+
timestamp TEXT NOT NULL,
|
|
53
|
+
dropped_count INTEGER NOT NULL,
|
|
54
|
+
first_dropped_sequence INTEGER,
|
|
55
|
+
last_dropped_sequence INTEGER,
|
|
56
|
+
gap_ordinal INTEGER NOT NULL,
|
|
57
|
+
reason TEXT NOT NULL,
|
|
58
|
+
source_event_key TEXT NOT NULL,
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_context_gaps_session ON context_gaps(session_id);
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SqliteOutputSink(StorageSink):
|
|
67
|
+
"""Persists enriched events to a SQLite database.
|
|
68
|
+
|
|
69
|
+
Provides a queryable local audit trail with indexed columns for
|
|
70
|
+
session_id, timestamp, risk_level, and action.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_VALID_JOURNAL_MODES = frozenset({"wal", "delete", "truncate", "persist", "memory", "off"})
|
|
74
|
+
|
|
75
|
+
def __init__(self, path: str | Path, journal_mode: str = "wal") -> None:
|
|
76
|
+
self._path = Path(path).expanduser()
|
|
77
|
+
if journal_mode.lower() not in self._VALID_JOURNAL_MODES:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Invalid journal_mode '{journal_mode}'. "
|
|
80
|
+
f"Must be one of: {', '.join(sorted(self._VALID_JOURNAL_MODES))}"
|
|
81
|
+
)
|
|
82
|
+
self._journal_mode = journal_mode.lower()
|
|
83
|
+
self._conn: sqlite3.Connection | None = None
|
|
84
|
+
|
|
85
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
86
|
+
tool_name = None
|
|
87
|
+
risk_level = None
|
|
88
|
+
risk_score = None
|
|
89
|
+
action = None
|
|
90
|
+
|
|
91
|
+
if event.payload:
|
|
92
|
+
tool_name = event.payload.get("tool_name")
|
|
93
|
+
|
|
94
|
+
if event.metadata:
|
|
95
|
+
gov = event.metadata.governance
|
|
96
|
+
if gov is not None:
|
|
97
|
+
if gov.risk_assessment is not None:
|
|
98
|
+
risk_level = gov.risk_assessment.level
|
|
99
|
+
risk_score = gov.risk_assessment.score
|
|
100
|
+
if gov.recommendation is not None:
|
|
101
|
+
action = gov.recommendation.recommended_action.value
|
|
102
|
+
|
|
103
|
+
payload_json = json.dumps(event.payload, default=str) if event.payload else None
|
|
104
|
+
metadata_json = (
|
|
105
|
+
json.dumps(event.metadata.model_dump(exclude_none=True), default=str)
|
|
106
|
+
if event.metadata
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
params = (
|
|
111
|
+
event.id,
|
|
112
|
+
event.session_id,
|
|
113
|
+
event.kind,
|
|
114
|
+
event.timestamp.isoformat() if event.timestamp else None,
|
|
115
|
+
tool_name,
|
|
116
|
+
risk_level,
|
|
117
|
+
risk_score,
|
|
118
|
+
action,
|
|
119
|
+
payload_json,
|
|
120
|
+
metadata_json,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await asyncio.to_thread(self._write_event, params)
|
|
124
|
+
|
|
125
|
+
def _write_event(self, params: tuple) -> None:
|
|
126
|
+
"""Synchronous write — called via asyncio.to_thread."""
|
|
127
|
+
conn = self._get_conn()
|
|
128
|
+
try:
|
|
129
|
+
conn.execute(
|
|
130
|
+
"""INSERT OR IGNORE INTO enriched_events
|
|
131
|
+
(id, session_id, kind, timestamp, tool_name, risk_level, risk_score, action, payload_json, metadata_json)
|
|
132
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
133
|
+
params,
|
|
134
|
+
)
|
|
135
|
+
conn.commit()
|
|
136
|
+
except sqlite3.Error as exc:
|
|
137
|
+
logger.error("SqliteOutputSink: write failed: %s", exc)
|
|
138
|
+
|
|
139
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
async def on_enriched_event(self, enriched) -> None:
|
|
146
|
+
"""Persist a governance envelope. Live events keep byte-identical output
|
|
147
|
+
(delegated to :meth:`on_event`); context-gap markers land in the
|
|
148
|
+
``context_gaps`` table."""
|
|
149
|
+
from traceforge.governance.envelope import ContextGapEvent
|
|
150
|
+
|
|
151
|
+
gap = enriched.event
|
|
152
|
+
if not isinstance(gap, ContextGapEvent):
|
|
153
|
+
await super().on_enriched_event(enriched)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
params = (
|
|
157
|
+
gap.id,
|
|
158
|
+
gap.session_id,
|
|
159
|
+
gap.timestamp.isoformat() if gap.timestamp else None,
|
|
160
|
+
gap.dropped_count,
|
|
161
|
+
gap.first_dropped_sequence,
|
|
162
|
+
gap.last_dropped_sequence,
|
|
163
|
+
gap.gap_ordinal,
|
|
164
|
+
gap.reason,
|
|
165
|
+
gap.source_event_key,
|
|
166
|
+
)
|
|
167
|
+
await asyncio.to_thread(self._write_gap, params)
|
|
168
|
+
|
|
169
|
+
def _write_gap(self, params: tuple) -> None:
|
|
170
|
+
"""Synchronous gap write — called via asyncio.to_thread."""
|
|
171
|
+
conn = self._get_conn()
|
|
172
|
+
try:
|
|
173
|
+
conn.execute(
|
|
174
|
+
"""INSERT OR IGNORE INTO context_gaps
|
|
175
|
+
(id, session_id, timestamp, dropped_count, first_dropped_sequence,
|
|
176
|
+
last_dropped_sequence, gap_ordinal, reason, source_event_key)
|
|
177
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
178
|
+
params,
|
|
179
|
+
)
|
|
180
|
+
conn.commit()
|
|
181
|
+
except sqlite3.Error as exc:
|
|
182
|
+
logger.error("SqliteOutputSink: gap write failed: %s", exc)
|
|
183
|
+
|
|
184
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
185
|
+
params = (
|
|
186
|
+
update.segment_id,
|
|
187
|
+
update.kind,
|
|
188
|
+
update.session_id,
|
|
189
|
+
update.title,
|
|
190
|
+
update.version,
|
|
191
|
+
update.parent_id,
|
|
192
|
+
)
|
|
193
|
+
await asyncio.to_thread(self._write_title, params)
|
|
194
|
+
|
|
195
|
+
def _write_title(self, params: tuple) -> None:
|
|
196
|
+
"""Synchronous upsert keeping the highest version — called via to_thread."""
|
|
197
|
+
conn = self._get_conn()
|
|
198
|
+
try:
|
|
199
|
+
conn.execute(
|
|
200
|
+
"""INSERT INTO segment_titles
|
|
201
|
+
(segment_id, kind, session_id, title, version, parent_id)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
203
|
+
ON CONFLICT(segment_id, kind) DO UPDATE SET
|
|
204
|
+
title=excluded.title,
|
|
205
|
+
session_id=excluded.session_id,
|
|
206
|
+
version=excluded.version,
|
|
207
|
+
parent_id=excluded.parent_id,
|
|
208
|
+
updated_at=datetime('now')
|
|
209
|
+
WHERE excluded.version >= segment_titles.version""",
|
|
210
|
+
params,
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
except sqlite3.Error as exc:
|
|
214
|
+
logger.error("SqliteOutputSink: title write failed: %s", exc)
|
|
215
|
+
|
|
216
|
+
async def flush(self) -> None:
|
|
217
|
+
if self._conn:
|
|
218
|
+
self._conn.commit()
|
|
219
|
+
|
|
220
|
+
async def close(self) -> None:
|
|
221
|
+
if self._conn:
|
|
222
|
+
self._conn.close()
|
|
223
|
+
self._conn = None
|
|
224
|
+
|
|
225
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
226
|
+
if self._conn is None:
|
|
227
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
self._conn = sqlite3.connect(str(self._path), timeout=5, check_same_thread=False)
|
|
229
|
+
try:
|
|
230
|
+
self._conn.execute(f"PRAGMA journal_mode={self._journal_mode}")
|
|
231
|
+
except sqlite3.OperationalError:
|
|
232
|
+
pass
|
|
233
|
+
self._conn.executescript(_CREATE_TABLE)
|
|
234
|
+
return self._conn
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Webhook sink — POST governance results to an HTTP endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from urllib.error import URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from traceforge.sinks.base import StorageSink
|
|
12
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebhookSink(StorageSink):
|
|
18
|
+
"""POSTs enriched events as JSON to a configured URL.
|
|
19
|
+
|
|
20
|
+
Only emits events matching the filter (by governance action).
|
|
21
|
+
Uses stdlib urllib to avoid adding dependencies.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
url: str,
|
|
27
|
+
filter_actions: list[str] | None = None,
|
|
28
|
+
timeout: float = 10.0,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
headers: dict[str, str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if not url.startswith(("http://", "https://")):
|
|
33
|
+
raise ValueError(f"Webhook URL must use http:// or https:// scheme, got: {url}")
|
|
34
|
+
self._url = url
|
|
35
|
+
self._filter = set(filter_actions or ["deny", "escalate"])
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
self._max_retries = max_retries
|
|
38
|
+
self._headers = headers or {}
|
|
39
|
+
|
|
40
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
41
|
+
action = self._extract_action(event)
|
|
42
|
+
if action is None or action not in self._filter:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
payload = {
|
|
46
|
+
"id": event.id,
|
|
47
|
+
"kind": event.kind,
|
|
48
|
+
"session_id": event.session_id,
|
|
49
|
+
"timestamp": event.timestamp.isoformat() if event.timestamp else None,
|
|
50
|
+
"payload": event.payload,
|
|
51
|
+
"governance": self._extract_governance(event),
|
|
52
|
+
}
|
|
53
|
+
await self._post(payload)
|
|
54
|
+
|
|
55
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
56
|
+
await self._post(
|
|
57
|
+
{
|
|
58
|
+
"record": "title_update",
|
|
59
|
+
"session_id": update.session_id,
|
|
60
|
+
"segment_id": update.segment_id,
|
|
61
|
+
"kind": update.kind,
|
|
62
|
+
"title": update.title,
|
|
63
|
+
"version": update.version,
|
|
64
|
+
"parent_id": update.parent_id,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def _post(self, payload: dict) -> None:
|
|
69
|
+
body = json.dumps(payload, default=str).encode("utf-8")
|
|
70
|
+
headers = {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
**self._headers,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for attempt in range(1, self._max_retries + 1):
|
|
76
|
+
try:
|
|
77
|
+
req = Request(self._url, data=body, headers=headers, method="POST")
|
|
78
|
+
resp = await asyncio.to_thread(self._do_request, req)
|
|
79
|
+
if resp < 300:
|
|
80
|
+
return
|
|
81
|
+
logger.warning(
|
|
82
|
+
"WebhookSink: %s returned status %d (attempt %d/%d)",
|
|
83
|
+
self._url,
|
|
84
|
+
resp,
|
|
85
|
+
attempt,
|
|
86
|
+
self._max_retries,
|
|
87
|
+
)
|
|
88
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"WebhookSink: POST to %s failed (attempt %d/%d): %s",
|
|
91
|
+
self._url,
|
|
92
|
+
attempt,
|
|
93
|
+
self._max_retries,
|
|
94
|
+
exc,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
logger.error("WebhookSink: all %d attempts to %s failed", self._max_retries, self._url)
|
|
98
|
+
|
|
99
|
+
def _do_request(self, req: Request) -> int:
|
|
100
|
+
"""Synchronous HTTP request — returns status code. Ensures response body is consumed."""
|
|
101
|
+
with urlopen(req, timeout=self._timeout) as resp:
|
|
102
|
+
resp.read() # consume body to release connection
|
|
103
|
+
return resp.status
|
|
104
|
+
|
|
105
|
+
def _extract_action(self, event: SessionEvent) -> str | None:
|
|
106
|
+
gov = event.metadata.governance if event.metadata else None
|
|
107
|
+
if gov is None:
|
|
108
|
+
return None
|
|
109
|
+
rec = gov.recommendation
|
|
110
|
+
if rec is None:
|
|
111
|
+
return None
|
|
112
|
+
return rec.recommended_action.value
|
|
113
|
+
|
|
114
|
+
def _extract_governance(self, event: SessionEvent) -> dict | None:
|
|
115
|
+
gov = event.metadata.governance if event.metadata else None
|
|
116
|
+
if gov is None:
|
|
117
|
+
return None
|
|
118
|
+
result: dict = {}
|
|
119
|
+
if gov.risk_assessment is not None:
|
|
120
|
+
result["risk_assessment"] = {
|
|
121
|
+
"score": gov.risk_assessment.score,
|
|
122
|
+
"level": gov.risk_assessment.level,
|
|
123
|
+
"confidence": gov.risk_assessment.confidence,
|
|
124
|
+
}
|
|
125
|
+
if gov.recommendation is not None:
|
|
126
|
+
result["recommendation"] = {
|
|
127
|
+
"action": gov.recommendation.recommended_action.value,
|
|
128
|
+
"reason_code": gov.recommendation.reason_code,
|
|
129
|
+
}
|
|
130
|
+
return result or None
|
|
131
|
+
|
|
132
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
136
|
+
pass
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from traceforge.sources.base import RawRecord, Source
|
|
2
|
+
from traceforge.sources.file_poll import FilePollSource
|
|
3
|
+
from traceforge.sources.file_watch import FileWatchSource
|
|
4
|
+
from traceforge.sources.http_poll import HttpPollSource
|
|
5
|
+
from traceforge.sources.replay import ReplaySource
|
|
6
|
+
from traceforge.sources.sqlite import SqliteSource
|
|
7
|
+
from traceforge.sources.sse import SSESource
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FilePollSource",
|
|
11
|
+
"FileWatchSource",
|
|
12
|
+
"HttpPollSource",
|
|
13
|
+
"RawRecord",
|
|
14
|
+
"ReplaySource",
|
|
15
|
+
"SqliteSource",
|
|
16
|
+
"SSESource",
|
|
17
|
+
"Source",
|
|
18
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Framework auto-detection — discovers installed AI coding agents by checking well-known paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _expand(path: str) -> Path:
|
|
14
|
+
return Path(os.path.expanduser(path))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _vscode_global_storage() -> Path | None:
|
|
18
|
+
"""Locate VS Code's globalStorage directory (OS-specific)."""
|
|
19
|
+
system = platform.system()
|
|
20
|
+
if system == "Darwin":
|
|
21
|
+
base = _expand("~/Library/Application Support/Code/User/globalStorage")
|
|
22
|
+
elif system == "Windows":
|
|
23
|
+
appdata = os.environ.get("APPDATA", "")
|
|
24
|
+
base = Path(appdata) / "Code" / "User" / "globalStorage" if appdata else None
|
|
25
|
+
if base is None:
|
|
26
|
+
return None
|
|
27
|
+
else: # Linux
|
|
28
|
+
base = _expand("~/.config/Code/User/globalStorage")
|
|
29
|
+
return base if base.is_dir() else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _goose_data_dir() -> Path:
|
|
33
|
+
"""Goose uses XDG data dir conventions via the `etcetera` crate."""
|
|
34
|
+
system = platform.system()
|
|
35
|
+
if system == "Darwin":
|
|
36
|
+
return _expand("~/Library/Application Support/Block/goose")
|
|
37
|
+
elif system == "Windows":
|
|
38
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
39
|
+
return Path(local) / "Block" / "goose" if local else _expand("~/.local/share/goose")
|
|
40
|
+
else:
|
|
41
|
+
return _expand("~/.local/share/goose")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _amazonq_data_dir() -> Path:
|
|
45
|
+
"""Amazon Q Developer CLI data directory."""
|
|
46
|
+
system = platform.system()
|
|
47
|
+
if system == "Darwin":
|
|
48
|
+
return _expand("~/Library/Application Support/amazon-q")
|
|
49
|
+
elif system == "Windows":
|
|
50
|
+
local = os.environ.get("LOCALAPPDATA", "")
|
|
51
|
+
return Path(local) / "amazon-q" if local else _expand("~/.local/share/amazon-q")
|
|
52
|
+
else:
|
|
53
|
+
return _expand("~/.local/share/amazon-q")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─── Detection registry ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DetectedFramework:
|
|
60
|
+
"""A detected framework with its source path and adapter config."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, name: str, path: Path, adapter: str, ingestion_mode: str) -> None:
|
|
63
|
+
self.name = name
|
|
64
|
+
self.path = path
|
|
65
|
+
self.adapter = adapter
|
|
66
|
+
self.ingestion_mode = ingestion_mode
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"DetectedFramework({self.name!r}, path={self.path})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_frameworks(frameworks: list[str] | None = None) -> list[DetectedFramework]:
|
|
73
|
+
"""Scan well-known paths for installed AI coding agent frameworks.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
frameworks: If provided, only detect these specific frameworks.
|
|
77
|
+
If None/empty, detect all known frameworks.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of detected frameworks with their paths and adapter configs.
|
|
81
|
+
"""
|
|
82
|
+
all_detectors = {
|
|
83
|
+
"claude": _detect_claude,
|
|
84
|
+
"codex": _detect_codex,
|
|
85
|
+
"continue": _detect_continue,
|
|
86
|
+
"cline": _detect_cline,
|
|
87
|
+
"goose": _detect_goose,
|
|
88
|
+
"amazonq": _detect_amazonq,
|
|
89
|
+
"aider": _detect_aider,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
target = frameworks if frameworks else list(all_detectors.keys())
|
|
93
|
+
detected: list[DetectedFramework] = []
|
|
94
|
+
|
|
95
|
+
for name in target:
|
|
96
|
+
detector = all_detectors.get(name)
|
|
97
|
+
if detector is None:
|
|
98
|
+
logger.debug("Unknown framework for detection: %s", name)
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
result = detector()
|
|
102
|
+
if result is not None:
|
|
103
|
+
detected.append(result)
|
|
104
|
+
logger.info("Detected %s at %s", result.name, result.path)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
logger.debug("Detection failed for %s: %s", name, exc)
|
|
107
|
+
|
|
108
|
+
return detected
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─── Individual detectors ────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _detect_claude() -> DetectedFramework | None:
|
|
115
|
+
path = _expand("~/.claude/projects")
|
|
116
|
+
if path.is_dir():
|
|
117
|
+
return DetectedFramework("claude", path, "claude", "file_watch")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _detect_codex() -> DetectedFramework | None:
|
|
122
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
123
|
+
if codex_home:
|
|
124
|
+
path = Path(codex_home) / "sessions"
|
|
125
|
+
else:
|
|
126
|
+
path = _expand("~/.codex/sessions")
|
|
127
|
+
if path.is_dir():
|
|
128
|
+
return DetectedFramework("codex", path, "codex", "file_watch")
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _detect_continue() -> DetectedFramework | None:
|
|
133
|
+
custom = os.environ.get("CONTINUE_GLOBAL_DIR")
|
|
134
|
+
if custom:
|
|
135
|
+
path = Path(custom) / "sessions"
|
|
136
|
+
else:
|
|
137
|
+
path = _expand("~/.continue/sessions")
|
|
138
|
+
if path.is_dir():
|
|
139
|
+
return DetectedFramework("continue", path, "continue", "file_watch")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _detect_cline() -> DetectedFramework | None:
|
|
144
|
+
gs = _vscode_global_storage()
|
|
145
|
+
if gs is None:
|
|
146
|
+
return None
|
|
147
|
+
path = gs / "saoudrizwan.claude-dev" / "tasks"
|
|
148
|
+
if path.is_dir():
|
|
149
|
+
return DetectedFramework("cline", path, "cline", "file_watch")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _detect_goose() -> DetectedFramework | None:
|
|
154
|
+
path = _goose_data_dir() / "sessions"
|
|
155
|
+
if path.is_dir():
|
|
156
|
+
return DetectedFramework("goose", path, "goose", "poll")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _detect_amazonq() -> DetectedFramework | None:
|
|
161
|
+
path = _amazonq_data_dir() / "data.sqlite3"
|
|
162
|
+
if path.is_file():
|
|
163
|
+
return DetectedFramework("amazonq", path, "amazonq", "sqlite")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _detect_aider() -> DetectedFramework | None:
|
|
168
|
+
# Aider writes .aider.chat.history.md in the project root
|
|
169
|
+
# We check CWD since aider is project-local
|
|
170
|
+
path = Path.cwd() / ".aider.chat.history.md"
|
|
171
|
+
if path.is_file():
|
|
172
|
+
return DetectedFramework("aider", path, "aider_markdown", "file_watch")
|
|
173
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Base abstractions for configurable ingestion sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from traceforge.types import IngestionMode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class RawRecord:
|
|
16
|
+
"""A complete raw record emitted by a source transport."""
|
|
17
|
+
|
|
18
|
+
payload: str
|
|
19
|
+
source_name: str
|
|
20
|
+
mode: IngestionMode
|
|
21
|
+
sequence: int | None = None
|
|
22
|
+
received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Source(ABC):
|
|
26
|
+
"""Async source that yields complete logical records."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def __aenter__(self) -> "Source":
|
|
32
|
+
"""Allocate any resources required by the source."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def __aexit__(
|
|
36
|
+
self,
|
|
37
|
+
exc_type: type[BaseException] | None,
|
|
38
|
+
exc: BaseException | None,
|
|
39
|
+
tb: TracebackType | None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Release any resources held by the source."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
45
|
+
"""Yield complete logical records from the source."""
|