codex-flow 2.1.13__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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Read-only adapter from FlowPilot telemetry to MCP tool/resource values.
|
|
2
|
+
|
|
3
|
+
This module deliberately uses the structured telemetry query helpers instead of
|
|
4
|
+
the telemetry CLI, hook collector, or app-server client. It is safe to import
|
|
5
|
+
from a long-running process: importing it does not start any subprocesses or
|
|
6
|
+
write telemetry state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TOOL_NAME = "flowpilot_get_telemetry"
|
|
20
|
+
RESOURCE_URI = "ui://flowpilot/telemetry-pip.html"
|
|
21
|
+
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
|
|
22
|
+
WIDGET_FILENAME = "widget.html"
|
|
23
|
+
_MAX_WIDGET_BYTES = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ``telemetry_core`` lives under scripts/ in the checkout. Keep this import
|
|
27
|
+
# local to the repository layout so the service can be started from any cwd.
|
|
28
|
+
_MODULE_DIR = Path(__file__).resolve().parent
|
|
29
|
+
_REPOSITORY_ROOT = _MODULE_DIR.parents[1]
|
|
30
|
+
_SCRIPTS_DIR = _REPOSITORY_ROOT / "scripts"
|
|
31
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
32
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
33
|
+
|
|
34
|
+
_TELEMETRY_IMPORT_ERROR: Optional[BaseException] = None
|
|
35
|
+
try:
|
|
36
|
+
from telemetry_core import ( # type: ignore
|
|
37
|
+
fmt_duration_ms,
|
|
38
|
+
fmt_local_timestamp,
|
|
39
|
+
fmt_tokens,
|
|
40
|
+
list_runs,
|
|
41
|
+
load_run,
|
|
42
|
+
resolve_run_target,
|
|
43
|
+
run_context,
|
|
44
|
+
run_key,
|
|
45
|
+
)
|
|
46
|
+
except Exception as exc: # pragma: no cover - only used by broken installs
|
|
47
|
+
# The MCP process should remain a harmless, healthy local service even if
|
|
48
|
+
# telemetry is unavailable in an incomplete checkout or installation.
|
|
49
|
+
_TELEMETRY_IMPORT_ERROR = exc
|
|
50
|
+
fmt_duration_ms = None # type: ignore
|
|
51
|
+
fmt_local_timestamp = None # type: ignore
|
|
52
|
+
fmt_tokens = None # type: ignore
|
|
53
|
+
list_runs = None # type: ignore
|
|
54
|
+
load_run = None # type: ignore
|
|
55
|
+
resolve_run_target = None # type: ignore
|
|
56
|
+
run_context = None # type: ignore
|
|
57
|
+
run_key = None # type: ignore
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WidgetResourceError(IOError):
|
|
61
|
+
"""Raised when the one allowed MCP Apps resource cannot be read."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_number(value: Any) -> bool:
|
|
65
|
+
return (
|
|
66
|
+
isinstance(value, (int, float))
|
|
67
|
+
and not isinstance(value, bool)
|
|
68
|
+
and math.isfinite(float(value))
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _number(value: Any) -> Optional[float]:
|
|
73
|
+
return float(value) if _is_number(value) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _integer(value: Any) -> Optional[int]:
|
|
77
|
+
if not _is_number(value):
|
|
78
|
+
return None
|
|
79
|
+
return int(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _text(value: Any, limit: int = 128) -> Optional[str]:
|
|
83
|
+
if value is None:
|
|
84
|
+
return None
|
|
85
|
+
value = " ".join(str(value).split())
|
|
86
|
+
if not value:
|
|
87
|
+
return None
|
|
88
|
+
return value[:limit]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _empty_quota() -> Dict[str, Any]:
|
|
92
|
+
return {"used": [], "remaining": [], "windows": []}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unavailable_telemetry() -> Dict[str, Any]:
|
|
96
|
+
"""Return the stable shape used when telemetry data is not available."""
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"run_id": None,
|
|
100
|
+
"session": None,
|
|
101
|
+
"project": None,
|
|
102
|
+
"branch": None,
|
|
103
|
+
"status": "unavailable",
|
|
104
|
+
"started_at": None,
|
|
105
|
+
"finished_at": None,
|
|
106
|
+
"duration_seconds": None,
|
|
107
|
+
"duration_text": None,
|
|
108
|
+
"participants": [],
|
|
109
|
+
"parent": {"model": None, "reasoning_effort": None, "total_tokens": None},
|
|
110
|
+
"parent_model": None,
|
|
111
|
+
"parent_reasoning_effort": None,
|
|
112
|
+
"total_tokens": None,
|
|
113
|
+
"quota": _empty_quota(),
|
|
114
|
+
"skills_used": [],
|
|
115
|
+
"tools_used": [],
|
|
116
|
+
"trajectory": [],
|
|
117
|
+
"summary": {},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _safe_context(run: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
122
|
+
"""Get display context without falling back to a thread preview.
|
|
123
|
+
|
|
124
|
+
``run_context`` is the canonical project/branch formatter, but its session
|
|
125
|
+
fallback includes ``thread.preview``. A preview can contain user text, so
|
|
126
|
+
remove it before calling the shared helper.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
if run_context is None:
|
|
130
|
+
return None, None, None
|
|
131
|
+
safe_run = dict(run)
|
|
132
|
+
thread = run.get("thread")
|
|
133
|
+
if isinstance(thread, dict):
|
|
134
|
+
safe_thread = dict(thread)
|
|
135
|
+
safe_thread.pop("preview", None)
|
|
136
|
+
safe_run["thread"] = safe_thread
|
|
137
|
+
try:
|
|
138
|
+
return run_context(safe_run)
|
|
139
|
+
except Exception:
|
|
140
|
+
return None, None, None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_identifier(run: Dict[str, Any]) -> Optional[str]:
|
|
144
|
+
explicit = _text(run.get("run_id") or run.get("id"))
|
|
145
|
+
if explicit:
|
|
146
|
+
return explicit
|
|
147
|
+
session_id = _text(run.get("session_id"))
|
|
148
|
+
turn_id = _text(run.get("turn_id"))
|
|
149
|
+
if session_id and turn_id:
|
|
150
|
+
# This mirrors telemetry_core.run_key while avoiding a local path or
|
|
151
|
+
# any other storage detail in the MCP response.
|
|
152
|
+
if run_key is not None:
|
|
153
|
+
try:
|
|
154
|
+
return _text(run_key(run))
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
return "%s--%s" % (session_id.replace("/", "_"), turn_id.replace("/", "_"))
|
|
158
|
+
return session_id or turn_id
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _status(run: Dict[str, Any]) -> str:
|
|
162
|
+
value = _text(run.get("status"), limit=32)
|
|
163
|
+
if value:
|
|
164
|
+
return value
|
|
165
|
+
return "completed" if _integer(run.get("finished_at_ms")) is not None else "running"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _usage(participant: Any) -> Optional[Dict[str, Any]]:
|
|
169
|
+
if not isinstance(participant, dict):
|
|
170
|
+
return None
|
|
171
|
+
value = participant.get("usage_delta")
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
return value
|
|
174
|
+
value = participant.get("usage")
|
|
175
|
+
return value if isinstance(value, dict) else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _participant(
|
|
179
|
+
participant: Any,
|
|
180
|
+
role: str,
|
|
181
|
+
participant_status: str,
|
|
182
|
+
) -> Dict[str, Any]:
|
|
183
|
+
if not isinstance(participant, dict):
|
|
184
|
+
participant = {}
|
|
185
|
+
usage = _usage(participant)
|
|
186
|
+
return {
|
|
187
|
+
"role": role,
|
|
188
|
+
"agent_id": _text(participant.get("agent_id")),
|
|
189
|
+
"agent_type": _text(participant.get("agent_type")),
|
|
190
|
+
"model": _text(participant.get("model")),
|
|
191
|
+
"reasoning_effort": _text(
|
|
192
|
+
participant.get("reasoning_effort") or participant.get("reasoningEffort")
|
|
193
|
+
),
|
|
194
|
+
"status": _text(participant.get("status"), limit=32) or participant_status,
|
|
195
|
+
"conclusion": _text(participant.get("conclusion"), limit=1000),
|
|
196
|
+
"total_tokens": _integer(usage.get("total_tokens")) if usage else None,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _total_tokens(participants: List[Dict[str, Any]]) -> Optional[int]:
|
|
201
|
+
"""Sum tokens only when every participant has an exact total.
|
|
202
|
+
|
|
203
|
+
A partial sum would look authoritative in a dashboard while hiding a
|
|
204
|
+
missing worker measurement, so the adapter follows telemetry's
|
|
205
|
+
all-participants aggregation semantics and returns null instead.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
values = [item.get("total_tokens") for item in participants]
|
|
209
|
+
if not values or any(not isinstance(value, int) for value in values):
|
|
210
|
+
return None
|
|
211
|
+
return sum(values)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _quota(run: Dict[str, Any]) -> Dict[str, Any]:
|
|
215
|
+
# quota_after is the current snapshot for a completed run. An in-flight
|
|
216
|
+
# run has only quota_before; exposing that snapshot is more useful than
|
|
217
|
+
# inventing a current value, and the source is kept explicit in each item.
|
|
218
|
+
source = run.get("quota_after")
|
|
219
|
+
source_name = "after"
|
|
220
|
+
if not isinstance(source, list) or not source:
|
|
221
|
+
source = run.get("quota_before")
|
|
222
|
+
source_name = "before"
|
|
223
|
+
if not isinstance(source, list):
|
|
224
|
+
source = []
|
|
225
|
+
|
|
226
|
+
used: List[Dict[str, Any]] = []
|
|
227
|
+
remaining: List[Dict[str, Any]] = []
|
|
228
|
+
windows: List[Dict[str, Any]] = []
|
|
229
|
+
for raw in source:
|
|
230
|
+
if not isinstance(raw, dict):
|
|
231
|
+
continue
|
|
232
|
+
used_percent = _number(raw.get("used_percent"))
|
|
233
|
+
remaining_percent = (
|
|
234
|
+
max(0.0, 100.0 - used_percent) if used_percent is not None else None
|
|
235
|
+
)
|
|
236
|
+
duration = _integer(raw.get("window_duration_mins"))
|
|
237
|
+
resets_at = raw.get("resets_at") if _is_number(raw.get("resets_at")) else None
|
|
238
|
+
slot = _text(raw.get("slot"), limit=32)
|
|
239
|
+
common = {
|
|
240
|
+
"slot": slot,
|
|
241
|
+
"window_duration_mins": duration,
|
|
242
|
+
"resets_at": resets_at,
|
|
243
|
+
"source": source_name,
|
|
244
|
+
}
|
|
245
|
+
used.append(dict(common, percent=used_percent))
|
|
246
|
+
remaining.append(dict(common, percent=remaining_percent))
|
|
247
|
+
windows.append(
|
|
248
|
+
dict(
|
|
249
|
+
common,
|
|
250
|
+
used_percent=used_percent,
|
|
251
|
+
remaining_percent=remaining_percent,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return {"used": used, "remaining": remaining, "windows": windows}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def telemetry_for_target(target: str = "last") -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
258
|
+
"""Read one run through telemetry_core without invoking collection code."""
|
|
259
|
+
|
|
260
|
+
if resolve_run_target is None:
|
|
261
|
+
return None, "FlowPilot telemetry is unavailable."
|
|
262
|
+
try:
|
|
263
|
+
run, identifier = resolve_run_target(target or "last")
|
|
264
|
+
except Exception:
|
|
265
|
+
return None, "FlowPilot telemetry is unavailable."
|
|
266
|
+
if not isinstance(run, dict):
|
|
267
|
+
return None, _text(identifier) or "No FlowPilot telemetry run recorded yet."
|
|
268
|
+
return run, _text(identifier)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_telemetry(run: Dict[str, Any], identifier: Optional[str] = None) -> Dict[str, Any]:
|
|
272
|
+
"""Convert one internal run record into the public, transcript-free shape."""
|
|
273
|
+
|
|
274
|
+
status = _status(run)
|
|
275
|
+
session, project, branch = _safe_context(run)
|
|
276
|
+
if not session:
|
|
277
|
+
session = _text(run.get("session_id"))
|
|
278
|
+
|
|
279
|
+
started_ms = _integer(run.get("started_at_ms"))
|
|
280
|
+
finished_ms = _integer(run.get("finished_at_ms"))
|
|
281
|
+
duration_ms: Optional[int] = None
|
|
282
|
+
if started_ms is not None and finished_ms is not None and finished_ms >= started_ms:
|
|
283
|
+
duration_ms = finished_ms - started_ms
|
|
284
|
+
|
|
285
|
+
parent = run.get("parent") if isinstance(run.get("parent"), dict) else {}
|
|
286
|
+
parent_item = _participant(parent, "parent", status)
|
|
287
|
+
participants = [parent_item]
|
|
288
|
+
workers = run.get("workers")
|
|
289
|
+
if isinstance(workers, dict):
|
|
290
|
+
for worker in workers.values():
|
|
291
|
+
if isinstance(worker, dict):
|
|
292
|
+
participants.append(
|
|
293
|
+
_participant(worker, "worker", status)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
parent_data = {
|
|
297
|
+
"model": parent_item.get("model"),
|
|
298
|
+
"reasoning_effort": parent_item.get("reasoning_effort"),
|
|
299
|
+
"total_tokens": parent_item.get("total_tokens"),
|
|
300
|
+
}
|
|
301
|
+
result: Dict[str, Any] = {
|
|
302
|
+
"run_id": _run_identifier(run) or identifier,
|
|
303
|
+
"session": session,
|
|
304
|
+
"project": project,
|
|
305
|
+
"branch": branch,
|
|
306
|
+
"status": status,
|
|
307
|
+
"started_at": (
|
|
308
|
+
fmt_local_timestamp(started_ms, milliseconds=True)
|
|
309
|
+
if fmt_local_timestamp is not None
|
|
310
|
+
else None
|
|
311
|
+
),
|
|
312
|
+
"finished_at": (
|
|
313
|
+
fmt_local_timestamp(finished_ms, milliseconds=True)
|
|
314
|
+
if fmt_local_timestamp is not None
|
|
315
|
+
else None
|
|
316
|
+
),
|
|
317
|
+
"duration_seconds": (
|
|
318
|
+
duration_ms / 1000.0 if duration_ms is not None else None
|
|
319
|
+
),
|
|
320
|
+
"duration_text": (
|
|
321
|
+
fmt_duration_ms(duration_ms)
|
|
322
|
+
if duration_ms is not None and fmt_duration_ms is not None
|
|
323
|
+
else None
|
|
324
|
+
),
|
|
325
|
+
"participants": participants,
|
|
326
|
+
"parent": parent_data,
|
|
327
|
+
"parent_model": parent_data["model"],
|
|
328
|
+
"parent_reasoning_effort": parent_data["reasoning_effort"],
|
|
329
|
+
"total_tokens": _total_tokens(participants),
|
|
330
|
+
"quota": _quota(run),
|
|
331
|
+
"skills_used": run.get("skills_used") or [],
|
|
332
|
+
"tools_used": run.get("tools_used") or [],
|
|
333
|
+
"trajectory": run.get("trajectory") or [],
|
|
334
|
+
"summary": run.get("summary_info") or {},
|
|
335
|
+
}
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_telemetry(arguments: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], str, bool]:
|
|
340
|
+
"""Return ``(structured_content, text, is_error)`` for tools/call."""
|
|
341
|
+
|
|
342
|
+
args = arguments if isinstance(arguments, dict) else {}
|
|
343
|
+
target = args.get("target")
|
|
344
|
+
if target is None:
|
|
345
|
+
target = args.get("run_id")
|
|
346
|
+
if isinstance(target, dict):
|
|
347
|
+
target = target.get("run_id") or target.get("target")
|
|
348
|
+
if target is None or target == "":
|
|
349
|
+
target = "last"
|
|
350
|
+
if not isinstance(target, str):
|
|
351
|
+
return unavailable_telemetry(), "Telemetry target must be a run id or 'last'.", True
|
|
352
|
+
|
|
353
|
+
run, message = telemetry_for_target(target)
|
|
354
|
+
if run is None:
|
|
355
|
+
text = message or "No FlowPilot telemetry run recorded yet."
|
|
356
|
+
return unavailable_telemetry(), text, False
|
|
357
|
+
|
|
358
|
+
structured = build_telemetry(run, message)
|
|
359
|
+
status = structured.get("status") or "unavailable"
|
|
360
|
+
project = structured.get("project") or "unknown project"
|
|
361
|
+
tokens = structured.get("total_tokens")
|
|
362
|
+
token_text = str(tokens) if isinstance(tokens, int) else "token usage unavailable"
|
|
363
|
+
text = "FlowPilot run %s: %s · %s · %s tokens." % (
|
|
364
|
+
structured.get("run_id") or "unknown",
|
|
365
|
+
status,
|
|
366
|
+
project,
|
|
367
|
+
token_text,
|
|
368
|
+
)
|
|
369
|
+
return structured, text, False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def tool_result(arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
373
|
+
"""Build an MCP tool result with both human-readable and structured data."""
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
structured, text, is_error = get_telemetry(arguments)
|
|
377
|
+
except Exception:
|
|
378
|
+
structured = unavailable_telemetry()
|
|
379
|
+
text = "FlowPilot telemetry is temporarily unavailable."
|
|
380
|
+
is_error = True
|
|
381
|
+
result: Dict[str, Any] = {
|
|
382
|
+
"content": [{"type": "text", "text": text}],
|
|
383
|
+
"structuredContent": structured,
|
|
384
|
+
}
|
|
385
|
+
if is_error:
|
|
386
|
+
result["isError"] = True
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def read_widget() -> str:
|
|
391
|
+
"""Read exactly the fixed widget resource and reject symlink escapes."""
|
|
392
|
+
|
|
393
|
+
module_dir = os.path.realpath(str(_MODULE_DIR))
|
|
394
|
+
widget_path = os.path.join(module_dir, WIDGET_FILENAME)
|
|
395
|
+
if os.path.realpath(widget_path) != widget_path:
|
|
396
|
+
raise WidgetResourceError("The telemetry widget resource is invalid.")
|
|
397
|
+
try:
|
|
398
|
+
with open(widget_path, "rb") as stream:
|
|
399
|
+
data = stream.read(_MAX_WIDGET_BYTES + 1)
|
|
400
|
+
except OSError as exc:
|
|
401
|
+
raise WidgetResourceError("The telemetry widget resource is unavailable.") from exc
|
|
402
|
+
if len(data) > _MAX_WIDGET_BYTES:
|
|
403
|
+
raise WidgetResourceError("The telemetry widget resource is too large.")
|
|
404
|
+
try:
|
|
405
|
+
return data.decode("utf-8")
|
|
406
|
+
except UnicodeDecodeError as exc:
|
|
407
|
+
raise WidgetResourceError("The telemetry widget resource is not valid UTF-8.") from exc
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def resource_descriptor() -> Dict[str, Any]:
|
|
411
|
+
return {
|
|
412
|
+
"uri": RESOURCE_URI,
|
|
413
|
+
"name": "flowpilot-telemetry-pip",
|
|
414
|
+
"title": "FlowPilot telemetry",
|
|
415
|
+
"description": "Read-only FlowPilot run telemetry dashboard.",
|
|
416
|
+
"mimeType": RESOURCE_MIME_TYPE,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def tool_descriptor() -> Dict[str, Any]:
|
|
421
|
+
return {
|
|
422
|
+
"name": TOOL_NAME,
|
|
423
|
+
"title": "FlowPilot telemetry",
|
|
424
|
+
"description": (
|
|
425
|
+
"Read the latest FlowPilot telemetry run, or a run by run_id. "
|
|
426
|
+
"This tool is read-only and never starts an app-server."
|
|
427
|
+
),
|
|
428
|
+
"inputSchema": {
|
|
429
|
+
"type": "object",
|
|
430
|
+
"properties": {
|
|
431
|
+
"target": {
|
|
432
|
+
"type": "string",
|
|
433
|
+
"description": "Optional run id; omit or use 'last' for the latest run.",
|
|
434
|
+
},
|
|
435
|
+
"run_id": {
|
|
436
|
+
"type": "string",
|
|
437
|
+
"description": "Optional alias for target when selecting a run.",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
"additionalProperties": False,
|
|
441
|
+
},
|
|
442
|
+
"_meta": {"ui": {"resourceUri": RESOURCE_URI}},
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
__all__ = [
|
|
447
|
+
"RESOURCE_MIME_TYPE",
|
|
448
|
+
"RESOURCE_URI",
|
|
449
|
+
"TOOL_NAME",
|
|
450
|
+
"WidgetResourceError",
|
|
451
|
+
"build_telemetry",
|
|
452
|
+
"get_telemetry",
|
|
453
|
+
"read_widget",
|
|
454
|
+
"resource_descriptor",
|
|
455
|
+
"tool_descriptor",
|
|
456
|
+
"tool_result",
|
|
457
|
+
"unavailable_telemetry",
|
|
458
|
+
]
|