tokenjam 0.2.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.
- tokenjam/__init__.py +1 -0
- tokenjam/api/__init__.py +0 -0
- tokenjam/api/app.py +104 -0
- tokenjam/api/deps.py +18 -0
- tokenjam/api/middleware.py +28 -0
- tokenjam/api/routes/__init__.py +0 -0
- tokenjam/api/routes/agents.py +33 -0
- tokenjam/api/routes/alerts.py +77 -0
- tokenjam/api/routes/budget.py +96 -0
- tokenjam/api/routes/cost.py +43 -0
- tokenjam/api/routes/drift.py +63 -0
- tokenjam/api/routes/logs.py +511 -0
- tokenjam/api/routes/metrics.py +81 -0
- tokenjam/api/routes/otlp.py +63 -0
- tokenjam/api/routes/spans.py +202 -0
- tokenjam/api/routes/status.py +84 -0
- tokenjam/api/routes/tools.py +22 -0
- tokenjam/api/routes/traces.py +92 -0
- tokenjam/cli/__init__.py +0 -0
- tokenjam/cli/cmd_alerts.py +94 -0
- tokenjam/cli/cmd_budget.py +119 -0
- tokenjam/cli/cmd_cost.py +90 -0
- tokenjam/cli/cmd_demo.py +82 -0
- tokenjam/cli/cmd_doctor.py +173 -0
- tokenjam/cli/cmd_drift.py +238 -0
- tokenjam/cli/cmd_export.py +200 -0
- tokenjam/cli/cmd_mcp.py +78 -0
- tokenjam/cli/cmd_onboard.py +779 -0
- tokenjam/cli/cmd_serve.py +85 -0
- tokenjam/cli/cmd_status.py +153 -0
- tokenjam/cli/cmd_stop.py +87 -0
- tokenjam/cli/cmd_tools.py +45 -0
- tokenjam/cli/cmd_traces.py +161 -0
- tokenjam/cli/cmd_uninstall.py +159 -0
- tokenjam/cli/main.py +110 -0
- tokenjam/core/__init__.py +0 -0
- tokenjam/core/alerts.py +619 -0
- tokenjam/core/api_backend.py +235 -0
- tokenjam/core/config.py +360 -0
- tokenjam/core/cost.py +102 -0
- tokenjam/core/db.py +718 -0
- tokenjam/core/drift.py +256 -0
- tokenjam/core/ingest.py +265 -0
- tokenjam/core/models.py +225 -0
- tokenjam/core/pricing.py +54 -0
- tokenjam/core/retention.py +21 -0
- tokenjam/core/schema_validator.py +156 -0
- tokenjam/demo/__init__.py +0 -0
- tokenjam/demo/env.py +96 -0
- tokenjam/mcp/__init__.py +0 -0
- tokenjam/mcp/server.py +1067 -0
- tokenjam/otel/__init__.py +0 -0
- tokenjam/otel/exporters.py +26 -0
- tokenjam/otel/provider.py +207 -0
- tokenjam/otel/semconv.py +144 -0
- tokenjam/pricing/models.toml +70 -0
- tokenjam/py.typed +0 -0
- tokenjam/sdk/__init__.py +21 -0
- tokenjam/sdk/agent.py +206 -0
- tokenjam/sdk/bootstrap.py +120 -0
- tokenjam/sdk/http_exporter.py +109 -0
- tokenjam/sdk/integrations/__init__.py +0 -0
- tokenjam/sdk/integrations/anthropic.py +200 -0
- tokenjam/sdk/integrations/autogen.py +97 -0
- tokenjam/sdk/integrations/base.py +27 -0
- tokenjam/sdk/integrations/bedrock.py +103 -0
- tokenjam/sdk/integrations/crewai.py +96 -0
- tokenjam/sdk/integrations/gemini.py +131 -0
- tokenjam/sdk/integrations/langchain.py +156 -0
- tokenjam/sdk/integrations/langgraph.py +101 -0
- tokenjam/sdk/integrations/litellm.py +323 -0
- tokenjam/sdk/integrations/llamaindex.py +52 -0
- tokenjam/sdk/integrations/nemoclaw.py +139 -0
- tokenjam/sdk/integrations/openai.py +159 -0
- tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
- tokenjam/sdk/transport.py +98 -0
- tokenjam/ui/index.html +1213 -0
- tokenjam/utils/__init__.py +0 -0
- tokenjam/utils/formatting.py +43 -0
- tokenjam/utils/ids.py +15 -0
- tokenjam/utils/time_parse.py +54 -0
- tokenjam-0.2.0.dist-info/METADATA +622 -0
- tokenjam-0.2.0.dist-info/RECORD +86 -0
- tokenjam-0.2.0.dist-info/WHEEL +4 -0
- tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
- tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tokenjam.utils.formatting import console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command("serve")
|
|
10
|
+
@click.option("--host", default=None, help="Bind host (default: from config)")
|
|
11
|
+
@click.option("--port", default=None, type=int, help="Bind port (default: from config)")
|
|
12
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload for development")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def cmd_serve(ctx: click.Context, host: str | None, port: int | None,
|
|
15
|
+
reload: bool) -> None:
|
|
16
|
+
"""Start the tj API server."""
|
|
17
|
+
config = ctx.obj["config"]
|
|
18
|
+
bind_host = host or config.api.host
|
|
19
|
+
bind_port = port or config.api.port
|
|
20
|
+
|
|
21
|
+
import uvicorn
|
|
22
|
+
from tokenjam.api.app import create_app
|
|
23
|
+
from tokenjam.core.ingest import build_default_pipeline
|
|
24
|
+
|
|
25
|
+
db = ctx.obj["db"]
|
|
26
|
+
pipeline = build_default_pipeline(db, config)
|
|
27
|
+
app = create_app(config, db, pipeline)
|
|
28
|
+
|
|
29
|
+
# Schedule retention cleanup using a separate DB connection per run
|
|
30
|
+
# to avoid concurrent write conflicts with uvicorn worker threads.
|
|
31
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
32
|
+
from tokenjam.core.retention import run_retention_cleanup
|
|
33
|
+
from tokenjam.core.db import DuckDBBackend
|
|
34
|
+
|
|
35
|
+
def _retention_job() -> None:
|
|
36
|
+
retention_db = DuckDBBackend(config.storage)
|
|
37
|
+
try:
|
|
38
|
+
run_retention_cleanup(retention_db, config.storage)
|
|
39
|
+
finally:
|
|
40
|
+
retention_db.close()
|
|
41
|
+
|
|
42
|
+
scheduler = BackgroundScheduler()
|
|
43
|
+
scheduler.add_job(
|
|
44
|
+
_retention_job,
|
|
45
|
+
"cron",
|
|
46
|
+
hour=0,
|
|
47
|
+
minute=0,
|
|
48
|
+
)
|
|
49
|
+
scheduler.start()
|
|
50
|
+
|
|
51
|
+
@app.on_event("shutdown")
|
|
52
|
+
async def _shutdown_scheduler() -> None:
|
|
53
|
+
scheduler.shutdown(wait=False)
|
|
54
|
+
|
|
55
|
+
# Write the resolved config path so other subcommands (e.g. onboard --codex)
|
|
56
|
+
# can find the secret this server is using regardless of CWD. Defer the
|
|
57
|
+
# write to a FastAPI startup event so it only fires after uvicorn binds
|
|
58
|
+
# the port — otherwise a failed-to-bind serve clobbers the state file
|
|
59
|
+
# of the running daemon (D2).
|
|
60
|
+
import json as _json
|
|
61
|
+
_state_path = Path.home() / ".local" / "share" / "tj" / "server.state"
|
|
62
|
+
|
|
63
|
+
@app.on_event("startup")
|
|
64
|
+
async def _write_server_state() -> None:
|
|
65
|
+
_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
_state_path.write_text(
|
|
67
|
+
_json.dumps({
|
|
68
|
+
"config_path": str(config.config_path) if config.config_path else None,
|
|
69
|
+
"port": bind_port,
|
|
70
|
+
"pid": __import__("os").getpid(),
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
console.print(f"[bold]tj serve[/bold] starting on http://{bind_host}:{bind_port}")
|
|
75
|
+
console.print(f" API docs: http://{bind_host}:{bind_port}/docs")
|
|
76
|
+
if config.export.prometheus.enabled:
|
|
77
|
+
console.print(f" Metrics: http://{bind_host}:{bind_port}/metrics")
|
|
78
|
+
console.print()
|
|
79
|
+
|
|
80
|
+
if reload:
|
|
81
|
+
console.print(
|
|
82
|
+
"[yellow]Warning: --reload requires an import string, not an app instance. "
|
|
83
|
+
"Reload mode is not supported with injected db/config — ignoring --reload.[/yellow]"
|
|
84
|
+
)
|
|
85
|
+
uvicorn.run(app, host=bind_host, port=bind_port)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from tokenjam.core.models import AlertFilters
|
|
8
|
+
from tokenjam.utils.formatting import console, format_cost, format_tokens, status_icon
|
|
9
|
+
from tokenjam.utils.time_parse import utcnow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command("status")
|
|
13
|
+
@click.option("--agent", default=None, help="Filter to specific agent_id")
|
|
14
|
+
@click.option("--json", "output_json", is_flag=True)
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def cmd_status(ctx: click.Context, agent: str | None, output_json: bool) -> None:
|
|
17
|
+
"""Show agent status overview."""
|
|
18
|
+
db = ctx.obj["db"]
|
|
19
|
+
agent_filter = agent or ctx.obj.get("agent")
|
|
20
|
+
|
|
21
|
+
# Get all agents from recent sessions
|
|
22
|
+
if agent_filter:
|
|
23
|
+
agent_ids = [agent_filter]
|
|
24
|
+
elif hasattr(db, "conn"):
|
|
25
|
+
# Direct DB access
|
|
26
|
+
rows = db.conn.execute(
|
|
27
|
+
"SELECT DISTINCT agent_id FROM sessions WHERE agent_id IS NOT NULL ORDER BY agent_id"
|
|
28
|
+
).fetchall()
|
|
29
|
+
agent_ids = [r[0] for r in rows]
|
|
30
|
+
else:
|
|
31
|
+
# API mode — discover agents from recent traces
|
|
32
|
+
from tokenjam.core.models import TraceFilters
|
|
33
|
+
traces = db.get_traces(TraceFilters(limit=100))
|
|
34
|
+
agent_ids = sorted({t.agent_id for t in traces if t.agent_id})
|
|
35
|
+
|
|
36
|
+
if not agent_ids:
|
|
37
|
+
if output_json:
|
|
38
|
+
click.echo(json.dumps({"agents": [], "has_active_alerts": False}))
|
|
39
|
+
else:
|
|
40
|
+
console.print("[dim]No agents found. Run an instrumented agent first.[/dim]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
has_active_alerts = False
|
|
44
|
+
agents_data = []
|
|
45
|
+
|
|
46
|
+
for aid in agent_ids:
|
|
47
|
+
session = None
|
|
48
|
+
if hasattr(db, "conn"):
|
|
49
|
+
# Direct DB access
|
|
50
|
+
sessions = db.get_completed_sessions(aid, limit=1)
|
|
51
|
+
active_rows = db.conn.execute(
|
|
52
|
+
"SELECT * FROM sessions WHERE agent_id = $1 AND status = 'active' "
|
|
53
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
54
|
+
[aid],
|
|
55
|
+
).fetchall()
|
|
56
|
+
if active_rows:
|
|
57
|
+
cols = [d[0] for d in db.conn.description]
|
|
58
|
+
from tokenjam.core.db import _row_to_session
|
|
59
|
+
session = _row_to_session(active_rows[0], cols)
|
|
60
|
+
elif sessions:
|
|
61
|
+
session = sessions[0]
|
|
62
|
+
else:
|
|
63
|
+
# API mode — limited session info
|
|
64
|
+
sessions = db.get_completed_sessions(aid, limit=1)
|
|
65
|
+
if sessions:
|
|
66
|
+
session = sessions[0]
|
|
67
|
+
|
|
68
|
+
today_cost = db.get_daily_cost(aid, utcnow().date())
|
|
69
|
+
|
|
70
|
+
# Budget from config: per-agent overrides defaults
|
|
71
|
+
config = ctx.obj["config"]
|
|
72
|
+
agent_config = config.agents.get(aid)
|
|
73
|
+
if agent_config and agent_config.budget.daily_usd is not None:
|
|
74
|
+
daily_limit = agent_config.budget.daily_usd
|
|
75
|
+
elif hasattr(config, "defaults") and config.defaults.budget.daily_usd is not None:
|
|
76
|
+
daily_limit = config.defaults.budget.daily_usd
|
|
77
|
+
else:
|
|
78
|
+
daily_limit = None
|
|
79
|
+
|
|
80
|
+
# Active alerts
|
|
81
|
+
alerts = db.get_alerts(AlertFilters(agent_id=aid, unread=True, limit=50))
|
|
82
|
+
active_alerts = [a for a in alerts if not a.acknowledged and not a.suppressed]
|
|
83
|
+
if active_alerts:
|
|
84
|
+
has_active_alerts = True
|
|
85
|
+
|
|
86
|
+
agent_data = {
|
|
87
|
+
"agent_id": aid,
|
|
88
|
+
"status": session.status if session else "idle",
|
|
89
|
+
"session_id": session.session_id if session else None,
|
|
90
|
+
"cost_today": today_cost,
|
|
91
|
+
"daily_limit": daily_limit,
|
|
92
|
+
"input_tokens": session.input_tokens if session else 0,
|
|
93
|
+
"output_tokens": session.output_tokens if session else 0,
|
|
94
|
+
"tool_call_count": session.tool_call_count if session else 0,
|
|
95
|
+
"error_count": session.error_count if session else 0,
|
|
96
|
+
"active_alerts": len(active_alerts),
|
|
97
|
+
}
|
|
98
|
+
agents_data.append(agent_data)
|
|
99
|
+
|
|
100
|
+
if not output_json:
|
|
101
|
+
_print_agent_status(agent_data, active_alerts, session)
|
|
102
|
+
|
|
103
|
+
if output_json:
|
|
104
|
+
click.echo(json.dumps({
|
|
105
|
+
"agents": agents_data,
|
|
106
|
+
"has_active_alerts": has_active_alerts,
|
|
107
|
+
}, default=str))
|
|
108
|
+
|
|
109
|
+
ctx.exit(1 if has_active_alerts else 0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _print_agent_status(data: dict, active_alerts: list, session: object | None) -> None:
|
|
113
|
+
status = data["status"]
|
|
114
|
+
icon = status_icon(status)
|
|
115
|
+
style = "green" if status == "active" else "dim"
|
|
116
|
+
|
|
117
|
+
duration_str = ""
|
|
118
|
+
if session and hasattr(session, "duration_seconds") and session.duration_seconds:
|
|
119
|
+
secs = int(session.duration_seconds)
|
|
120
|
+
mins, s = divmod(secs, 60)
|
|
121
|
+
duration_str = f" ({mins}m {s}s)"
|
|
122
|
+
|
|
123
|
+
console.print(f"[{style}]{icon}[/] [bold]{data['agent_id']}[/bold] "
|
|
124
|
+
f"{status}{duration_str}")
|
|
125
|
+
console.print()
|
|
126
|
+
|
|
127
|
+
cost_str = format_cost(data["cost_today"])
|
|
128
|
+
if data["daily_limit"]:
|
|
129
|
+
cost_str += f" / {format_cost(data['daily_limit'])} limit"
|
|
130
|
+
console.print(f" Cost today: {cost_str}")
|
|
131
|
+
|
|
132
|
+
in_tok = format_tokens(data["input_tokens"])
|
|
133
|
+
out_tok = format_tokens(data["output_tokens"])
|
|
134
|
+
console.print(f" Tokens: {in_tok} in / {out_tok} out")
|
|
135
|
+
|
|
136
|
+
tool_str = str(data["tool_call_count"])
|
|
137
|
+
if data["error_count"]:
|
|
138
|
+
tool_str += f" ({data['error_count']} failed)"
|
|
139
|
+
console.print(f" Tool calls: {tool_str}")
|
|
140
|
+
|
|
141
|
+
if data["session_id"]:
|
|
142
|
+
console.print(f" Active session: {data['session_id']}")
|
|
143
|
+
|
|
144
|
+
console.print()
|
|
145
|
+
for alert in active_alerts:
|
|
146
|
+
from tokenjam.utils.formatting import severity_colour
|
|
147
|
+
colour = severity_colour(alert.severity.value)
|
|
148
|
+
console.print(f" [{colour}]{alert.title}[/]")
|
|
149
|
+
|
|
150
|
+
if not active_alerts:
|
|
151
|
+
console.print(" [green]No active alerts[/green]")
|
|
152
|
+
|
|
153
|
+
console.print()
|
tokenjam/cli/cmd_stop.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tokenjam.utils.formatting import console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("stop")
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def cmd_stop(ctx: click.Context) -> None:
|
|
16
|
+
"""Stop the tj serve daemon or background process."""
|
|
17
|
+
plist_path = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
|
|
18
|
+
systemd_path = Path.home() / ".config/systemd/user/tokenjam.service"
|
|
19
|
+
|
|
20
|
+
stopped_via: list[str] = []
|
|
21
|
+
|
|
22
|
+
# Try launchd first (macOS).
|
|
23
|
+
# -w writes a Disabled entry to launchd's database so the daemon does not
|
|
24
|
+
# auto-start on the next login (the plist file stays on disk so the user
|
|
25
|
+
# can re-enable with `launchctl load <plist>` or by re-running tj serve).
|
|
26
|
+
if plist_path.exists():
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["launchctl", "unload", "-w", str(plist_path)],
|
|
29
|
+
capture_output=True, text=True,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode == 0:
|
|
32
|
+
stopped_via.append("launchd daemon unloaded")
|
|
33
|
+
|
|
34
|
+
# Try systemd (Linux).
|
|
35
|
+
# `disable --now` stops the unit immediately AND removes it from the
|
|
36
|
+
# boot-time targets, so it does not auto-start on next login.
|
|
37
|
+
# The service file stays on disk; `systemctl --user enable --now tokenjam`
|
|
38
|
+
# re-enables it.
|
|
39
|
+
if systemd_path.exists():
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["systemctl", "--user", "disable", "--now", "tokenjam"],
|
|
42
|
+
capture_output=True, text=True,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode == 0:
|
|
45
|
+
stopped_via.append("systemd service stopped")
|
|
46
|
+
|
|
47
|
+
# Always sweep for orphan foreground `tj serve` processes started via
|
|
48
|
+
# `tj serve &` — launchd/systemd unload doesn't affect those, and they
|
|
49
|
+
# keep holding the port. Track signaled PIDs so a slow-shutting process
|
|
50
|
+
# doesn't get re-signaled (SIGTERM is async; pgrep can return the same
|
|
51
|
+
# PID before the handler fires, which would otherwise spin forever).
|
|
52
|
+
signaled: set[int] = set()
|
|
53
|
+
for _ in range(20): # hard cap — well above any realistic straggler count
|
|
54
|
+
pid = _find_serve_pid()
|
|
55
|
+
if not pid or pid in signaled:
|
|
56
|
+
break
|
|
57
|
+
try:
|
|
58
|
+
os.kill(pid, signal.SIGTERM)
|
|
59
|
+
except ProcessLookupError:
|
|
60
|
+
break
|
|
61
|
+
signaled.add(pid)
|
|
62
|
+
stopped_via.append(f"PID {pid}")
|
|
63
|
+
|
|
64
|
+
if stopped_via:
|
|
65
|
+
console.print(
|
|
66
|
+
f"[green]tj serve stopped.[/green] ({', '.join(stopped_via)})"
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
console.print("[dim]tj serve is not running.[/dim]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_serve_pid() -> int | None:
|
|
73
|
+
"""Find the PID of a running `tj serve`. process."""
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["pgrep", "-f", "tokenjam.serve|tj serve"],
|
|
77
|
+
capture_output=True, text=True,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode == 0:
|
|
80
|
+
for line in result.stdout.strip().splitlines():
|
|
81
|
+
pid = int(line.strip())
|
|
82
|
+
# Don't return our own PID
|
|
83
|
+
if pid != os.getpid():
|
|
84
|
+
return pid
|
|
85
|
+
except (FileNotFoundError, ValueError):
|
|
86
|
+
pass
|
|
87
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from tokenjam.utils.formatting import console, make_table
|
|
8
|
+
from tokenjam.utils.time_parse import parse_since
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("tools")
|
|
12
|
+
@click.option("--agent", default=None, help="Filter to specific agent_id")
|
|
13
|
+
@click.option("--since", default="24h", help="Time window (e.g. 1h, 7d)")
|
|
14
|
+
@click.option("--name", "tool_name", default=None, help="Filter to specific tool")
|
|
15
|
+
@click.option("--json", "output_json", is_flag=True)
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def cmd_tools(ctx: click.Context, agent: str | None, since: str,
|
|
18
|
+
tool_name: str | None, output_json: bool) -> None:
|
|
19
|
+
"""Show tool call summary."""
|
|
20
|
+
db = ctx.obj["db"]
|
|
21
|
+
agent_filter = agent or ctx.obj.get("agent")
|
|
22
|
+
since_dt = parse_since(since)
|
|
23
|
+
|
|
24
|
+
rows = db.get_tool_calls(agent_filter, since_dt, tool_name)
|
|
25
|
+
|
|
26
|
+
if output_json:
|
|
27
|
+
click.echo(json.dumps(rows, default=str))
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not rows:
|
|
31
|
+
console.print("[dim]No tool calls found for the given filters.[/dim]")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
table = make_table("TOOL", "AGENT", "CALLS", "AVG DUR")
|
|
35
|
+
for r in rows:
|
|
36
|
+
call_count = r["call_count"]
|
|
37
|
+
total_dur = r["total_duration_ms"]
|
|
38
|
+
avg_dur = f"{total_dur / call_count:.0f}ms" if call_count > 0 else "-"
|
|
39
|
+
table.add_row(
|
|
40
|
+
r["tool_name"],
|
|
41
|
+
r.get("agent_id") or "-",
|
|
42
|
+
str(call_count),
|
|
43
|
+
avg_dur,
|
|
44
|
+
)
|
|
45
|
+
console.print(table)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from tokenjam.core.models import NormalizedSpan, TraceFilters
|
|
8
|
+
from tokenjam.utils.formatting import console, format_cost, make_table
|
|
9
|
+
from tokenjam.utils.time_parse import parse_since
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command("traces")
|
|
13
|
+
@click.option("--agent", default=None, help="Filter to specific agent_id")
|
|
14
|
+
@click.option("--since", default="24h", help="Time window (e.g. 1h, 7d)")
|
|
15
|
+
@click.option("--limit", default=50, type=int)
|
|
16
|
+
@click.option("--type", "span_type", default=None, help="Filter by span name/type")
|
|
17
|
+
@click.option("--status", default=None, type=click.Choice(["ok", "error"]))
|
|
18
|
+
@click.option("--json", "output_json", is_flag=True)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def cmd_traces(ctx: click.Context, agent: str | None, since: str, limit: int,
|
|
21
|
+
span_type: str | None, status: str | None, output_json: bool) -> None:
|
|
22
|
+
"""List recent traces."""
|
|
23
|
+
db = ctx.obj["db"]
|
|
24
|
+
agent_filter = agent or ctx.obj.get("agent")
|
|
25
|
+
try:
|
|
26
|
+
since_dt = parse_since(since)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise click.BadParameter(str(exc), param_hint="'--since'") from exc
|
|
29
|
+
filters = TraceFilters(
|
|
30
|
+
agent_id=agent_filter,
|
|
31
|
+
since=since_dt,
|
|
32
|
+
span_name=span_type,
|
|
33
|
+
status=status,
|
|
34
|
+
limit=limit,
|
|
35
|
+
)
|
|
36
|
+
traces = db.get_traces(filters)
|
|
37
|
+
|
|
38
|
+
if output_json:
|
|
39
|
+
click.echo(json.dumps([
|
|
40
|
+
{
|
|
41
|
+
"trace_id": t.trace_id,
|
|
42
|
+
"agent_id": t.agent_id,
|
|
43
|
+
"name": t.name,
|
|
44
|
+
"start_time": t.start_time.isoformat() if t.start_time else None,
|
|
45
|
+
"duration_ms": t.duration_ms,
|
|
46
|
+
"cost_usd": t.cost_usd,
|
|
47
|
+
"status_code": t.status_code,
|
|
48
|
+
"span_count": t.span_count,
|
|
49
|
+
}
|
|
50
|
+
for t in traces
|
|
51
|
+
], default=str))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if not traces:
|
|
55
|
+
console.print("[dim]No traces found for the given filters.[/dim]")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
table = make_table("TRACE ID", "AGENT", "TYPE", "DUR", "COST", "STATUS")
|
|
59
|
+
for t in traces:
|
|
60
|
+
dur = f"{t.duration_ms:.0f}ms" if t.duration_ms else "-"
|
|
61
|
+
cost = format_cost(t.cost_usd) if t.cost_usd else "-"
|
|
62
|
+
status_style = "red" if t.status_code == "error" else ""
|
|
63
|
+
table.add_row(
|
|
64
|
+
t.trace_id[:12] + "...",
|
|
65
|
+
t.agent_id or "-",
|
|
66
|
+
t.name,
|
|
67
|
+
dur,
|
|
68
|
+
cost,
|
|
69
|
+
f"[{status_style}]{t.status_code}[/]" if status_style else t.status_code,
|
|
70
|
+
)
|
|
71
|
+
console.print(table)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.command("trace")
|
|
75
|
+
@click.argument("trace_id")
|
|
76
|
+
@click.option("--json", "output_json", is_flag=True)
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def cmd_trace(ctx: click.Context, trace_id: str, output_json: bool) -> None:
|
|
79
|
+
"""Show span waterfall for a single trace."""
|
|
80
|
+
db = ctx.obj["db"]
|
|
81
|
+
spans = db.get_trace_spans(trace_id)
|
|
82
|
+
|
|
83
|
+
# Support prefix matching (like git short hashes)
|
|
84
|
+
if not spans and len(trace_id) < 32:
|
|
85
|
+
if hasattr(db, "conn"):
|
|
86
|
+
rows = db.conn.execute(
|
|
87
|
+
"SELECT DISTINCT trace_id FROM spans WHERE trace_id LIKE $1 LIMIT 2",
|
|
88
|
+
[trace_id + "%"],
|
|
89
|
+
).fetchall()
|
|
90
|
+
if len(rows) == 1:
|
|
91
|
+
trace_id = rows[0][0]
|
|
92
|
+
spans = db.get_trace_spans(trace_id)
|
|
93
|
+
elif len(rows) > 1:
|
|
94
|
+
console.print(f"[red]Ambiguous prefix '{trace_id}' — matches "
|
|
95
|
+
f"{len(rows)} traces. Use more characters.[/red]")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not spans:
|
|
99
|
+
console.print(f"[dim]No spans found for trace {trace_id}[/dim]")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if output_json:
|
|
103
|
+
click.echo(json.dumps([
|
|
104
|
+
{
|
|
105
|
+
"span_id": s.span_id,
|
|
106
|
+
"parent_span_id": s.parent_span_id,
|
|
107
|
+
"name": s.name,
|
|
108
|
+
"kind": s.kind.value,
|
|
109
|
+
"status_code": s.status_code.value,
|
|
110
|
+
"start_time": s.start_time.isoformat() if s.start_time else None,
|
|
111
|
+
"duration_ms": s.duration_ms,
|
|
112
|
+
"provider": s.provider,
|
|
113
|
+
"model": s.model,
|
|
114
|
+
"tool_name": s.tool_name,
|
|
115
|
+
"input_tokens": s.input_tokens,
|
|
116
|
+
"output_tokens": s.output_tokens,
|
|
117
|
+
"cost_usd": s.cost_usd,
|
|
118
|
+
}
|
|
119
|
+
for s in spans
|
|
120
|
+
], default=str))
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Build parent->children map for tree rendering
|
|
124
|
+
children: dict[str | None, list[NormalizedSpan]] = {}
|
|
125
|
+
for s in spans:
|
|
126
|
+
children.setdefault(s.parent_span_id, []).append(s)
|
|
127
|
+
|
|
128
|
+
# Find root spans (no parent or parent not in this trace)
|
|
129
|
+
span_ids = {s.span_id for s in spans}
|
|
130
|
+
roots = [s for s in spans if s.parent_span_id is None
|
|
131
|
+
or s.parent_span_id not in span_ids]
|
|
132
|
+
|
|
133
|
+
for root in roots:
|
|
134
|
+
_print_span_tree(root, children, prefix="", is_last=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _print_span_tree(span: NormalizedSpan, children: dict[str | None, list[NormalizedSpan]],
|
|
138
|
+
prefix: str, is_last: bool) -> None:
|
|
139
|
+
connector = "\u2514\u2500 " if is_last else "\u251c\u2500 "
|
|
140
|
+
dur = f"{span.duration_ms:.0f}ms" if span.duration_ms else ""
|
|
141
|
+
cost = format_cost(span.cost_usd) if span.cost_usd else ""
|
|
142
|
+
|
|
143
|
+
parts = [f"[bold]{span.name}[/bold]"]
|
|
144
|
+
if dur:
|
|
145
|
+
parts.append(f"[dim]{dur}[/dim]")
|
|
146
|
+
if span.model:
|
|
147
|
+
parts.append(f"[cyan]{span.model}[/cyan]")
|
|
148
|
+
if span.tool_name:
|
|
149
|
+
parts.append(f"[magenta]{span.tool_name}[/magenta]")
|
|
150
|
+
if cost:
|
|
151
|
+
parts.append(cost)
|
|
152
|
+
if span.status_code.value == "error":
|
|
153
|
+
parts.append("[red]ERROR[/red]")
|
|
154
|
+
|
|
155
|
+
line = " ".join(parts)
|
|
156
|
+
console.print(f"{prefix}{connector}{line}")
|
|
157
|
+
|
|
158
|
+
child_spans = children.get(span.span_id, [])
|
|
159
|
+
for i, child in enumerate(child_spans):
|
|
160
|
+
child_prefix = prefix + (" " if is_last else "\u2502 ")
|
|
161
|
+
_print_span_tree(child, children, child_prefix, i == len(child_spans) - 1)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from tokenjam.utils.formatting import console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command("uninstall")
|
|
15
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation prompt")
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def cmd_uninstall(ctx: click.Context, yes: bool) -> None:
|
|
18
|
+
"""Remove all OCW data, config, and daemon."""
|
|
19
|
+
if not yes:
|
|
20
|
+
confirmed = click.confirm(
|
|
21
|
+
"This will delete all OCW data including telemetry history. Continue?",
|
|
22
|
+
default=False,
|
|
23
|
+
)
|
|
24
|
+
if not confirmed:
|
|
25
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# 1. Stop tj serve if running
|
|
29
|
+
from tokenjam.cli.cmd_stop import cmd_stop
|
|
30
|
+
ctx.invoke(cmd_stop)
|
|
31
|
+
|
|
32
|
+
# 2. Deregister MCP server from Claude Code (Gap #13)
|
|
33
|
+
if shutil.which("claude"):
|
|
34
|
+
subprocess.run(
|
|
35
|
+
["claude", "mcp", "remove", "tj", "--scope", "user"],
|
|
36
|
+
capture_output=True, text=True,
|
|
37
|
+
)
|
|
38
|
+
console.print(" Removed tj MCP server from Claude Code.")
|
|
39
|
+
|
|
40
|
+
# 3. Unload and delete launchd plist
|
|
41
|
+
plist_path = Path.home() / "Library/LaunchAgents/com.tokenjam.serve.plist"
|
|
42
|
+
if plist_path.exists():
|
|
43
|
+
subprocess.run(
|
|
44
|
+
["launchctl", "unload", str(plist_path)],
|
|
45
|
+
capture_output=True, text=True,
|
|
46
|
+
)
|
|
47
|
+
plist_path.unlink()
|
|
48
|
+
console.print(f" Removed {plist_path}")
|
|
49
|
+
|
|
50
|
+
# 4. Delete systemd service if present
|
|
51
|
+
systemd_path = Path.home() / ".config/systemd/user/tokenjam.service"
|
|
52
|
+
if systemd_path.exists():
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["systemctl", "--user", "disable", "--now", "tokenjam"],
|
|
55
|
+
capture_output=True, text=True,
|
|
56
|
+
)
|
|
57
|
+
systemd_path.unlink()
|
|
58
|
+
console.print(f" Removed {systemd_path}")
|
|
59
|
+
|
|
60
|
+
# 5. Delete ~/.tj/ (telemetry DB)
|
|
61
|
+
ocw_dir = Path.home() / ".tj"
|
|
62
|
+
if ocw_dir.exists():
|
|
63
|
+
shutil.rmtree(ocw_dir)
|
|
64
|
+
console.print(f" Removed {ocw_dir}")
|
|
65
|
+
|
|
66
|
+
# 6. Read projects index BEFORE deleting the global config dir.
|
|
67
|
+
global_config_dir = Path.home() / ".config" / "tj"
|
|
68
|
+
project_paths: list[Path] = []
|
|
69
|
+
projects_index = global_config_dir / "projects.json"
|
|
70
|
+
try:
|
|
71
|
+
if projects_index.exists():
|
|
72
|
+
paths = json.loads(projects_index.read_text())
|
|
73
|
+
project_paths = [Path(p) for p in paths if isinstance(p, str)]
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
# 7. Delete global config ~/.config/tj/
|
|
78
|
+
if global_config_dir.exists():
|
|
79
|
+
shutil.rmtree(global_config_dir)
|
|
80
|
+
console.print(f" Removed {global_config_dir}")
|
|
81
|
+
|
|
82
|
+
# 8. Delete local .tj/ if present
|
|
83
|
+
local_ocw = Path(".tj")
|
|
84
|
+
if local_ocw.exists():
|
|
85
|
+
shutil.rmtree(local_ocw)
|
|
86
|
+
console.print(f" Removed {local_ocw}")
|
|
87
|
+
|
|
88
|
+
# 9. Delete temp files
|
|
89
|
+
for tmp_file in ["/tmp/tj-serve.out", "/tmp/tj-serve.err"]:
|
|
90
|
+
p = Path(tmp_file)
|
|
91
|
+
if p.exists():
|
|
92
|
+
p.unlink()
|
|
93
|
+
console.print(f" Removed {tmp_file}")
|
|
94
|
+
|
|
95
|
+
# 10. Remove OCW env vars from ~/.claude/settings.json
|
|
96
|
+
_GLOBAL_TJ_KEYS = {
|
|
97
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
98
|
+
"OTEL_LOGS_EXPORTER",
|
|
99
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
100
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
101
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
102
|
+
}
|
|
103
|
+
global_settings_path = Path.home() / ".claude" / "settings.json"
|
|
104
|
+
if global_settings_path.exists():
|
|
105
|
+
try:
|
|
106
|
+
gs = json.loads(global_settings_path.read_text())
|
|
107
|
+
env = gs.get("env", {})
|
|
108
|
+
removed = [k for k in _GLOBAL_TJ_KEYS if k in env]
|
|
109
|
+
for k in removed:
|
|
110
|
+
del env[k]
|
|
111
|
+
if removed:
|
|
112
|
+
gs["env"] = env
|
|
113
|
+
global_settings_path.write_text(json.dumps(gs, indent=2) + "\n")
|
|
114
|
+
console.print(f" Cleaned {len(removed)} OCW env vars from {global_settings_path}")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
console.print(f" [yellow]Could not clean {global_settings_path}: {exc}[/yellow]")
|
|
117
|
+
|
|
118
|
+
# 11. Remove OTEL_RESOURCE_ATTRIBUTES from all onboarded project .claude/settings.json files.
|
|
119
|
+
# project_paths was read from projects.json before the global config dir was deleted above.
|
|
120
|
+
# Always include CWD so running uninstall from a project dir works even without the index
|
|
121
|
+
cwd = Path.cwd()
|
|
122
|
+
if cwd not in project_paths:
|
|
123
|
+
project_paths.append(cwd)
|
|
124
|
+
|
|
125
|
+
for proj in project_paths:
|
|
126
|
+
proj_settings = proj / ".claude" / "settings.json"
|
|
127
|
+
if not proj_settings.exists():
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
ps = json.loads(proj_settings.read_text())
|
|
131
|
+
env = ps.get("env", {})
|
|
132
|
+
if "OTEL_RESOURCE_ATTRIBUTES" in env:
|
|
133
|
+
del env["OTEL_RESOURCE_ATTRIBUTES"]
|
|
134
|
+
ps["env"] = env
|
|
135
|
+
proj_settings.write_text(json.dumps(ps, indent=2) + "\n")
|
|
136
|
+
console.print(f" Removed OTEL_RESOURCE_ATTRIBUTES from {proj_settings}")
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
console.print(f" [yellow]Could not clean {proj_settings}: {exc}[/yellow]")
|
|
139
|
+
|
|
140
|
+
# 11. Remove # tj harness observability block from ~/.zshrc
|
|
141
|
+
zshrc = Path.home() / ".zshrc"
|
|
142
|
+
if zshrc.exists():
|
|
143
|
+
try:
|
|
144
|
+
text = zshrc.read_text()
|
|
145
|
+
# Match the marker line plus all following export lines (any count)
|
|
146
|
+
cleaned = re.sub(
|
|
147
|
+
r"# tj harness observability\n(?:export [^\n]+\n)*",
|
|
148
|
+
"",
|
|
149
|
+
text,
|
|
150
|
+
)
|
|
151
|
+
if cleaned != text:
|
|
152
|
+
zshrc.write_text(cleaned)
|
|
153
|
+
console.print(f" Removed OCW env block from {zshrc}")
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
console.print(f" [yellow]Could not clean {zshrc}: {exc}[/yellow]")
|
|
156
|
+
|
|
157
|
+
console.print()
|
|
158
|
+
console.print("[green]TokenJam data and config removed.[/green]")
|
|
159
|
+
console.print("To remove the package itself, run: [bold]pip uninstall tokenjam[/bold]")
|