zeno-cli 0.3.4__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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- zeno_session_intel/taxonomy.py +71 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Agent-behavioral signal extraction from a parsed transcript.
|
|
2
|
+
|
|
3
|
+
Ported from agentsview (MIT, see THIRD_PARTY_LICENSES.md): tool-health
|
|
4
|
+
(``internal/signals/toolhealth.go``), context pressure/compaction
|
|
5
|
+
(``internal/signals/context.go``), outcome classification
|
|
6
|
+
(``internal/signals/outcome.go``), and the health score (``internal/signals/score.go``,
|
|
7
|
+
ported verbatim - the penalty constants are the contract).
|
|
8
|
+
|
|
9
|
+
These score *agent* behavior and are stored on ``transcript_sessions``. They are
|
|
10
|
+
ORTHOGONAL to zeno's human-cognition ``attention_score``; the two complement, never
|
|
11
|
+
overlap. Pure computation over a ``ParsedSession``; stdlib-only, never raises.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from .model import ParsedSession
|
|
21
|
+
from .taxonomy import normalize_tool_category
|
|
22
|
+
|
|
23
|
+
# tool-failure content heuristics (toolhealth.go IsFailure)
|
|
24
|
+
_GOROUTINE = re.compile(r"goroutine \d+")
|
|
25
|
+
_EXIT_CODE = re.compile(r"exit (status|code) [1-9]\d*")
|
|
26
|
+
_JS_AT = re.compile(r"^\s+at ", re.MULTILINE)
|
|
27
|
+
_BASH_FAIL_SUBSTR = ("command not found", "permission denied", "traceback (most recent call last)")
|
|
28
|
+
_GIVE_UP = (
|
|
29
|
+
"i'm unable to",
|
|
30
|
+
"i am unable to",
|
|
31
|
+
"i can't proceed",
|
|
32
|
+
"i cannot proceed",
|
|
33
|
+
"i don't have access",
|
|
34
|
+
)
|
|
35
|
+
_EDIT_PATH_KEYS = ("file_path", "path", "target_file", "filePath")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SessionSignals:
|
|
40
|
+
message_count: int = 0
|
|
41
|
+
user_message_count: int = 0
|
|
42
|
+
total_output_tokens: int = 0
|
|
43
|
+
peak_context_tokens: int = 0
|
|
44
|
+
is_automated: bool = False
|
|
45
|
+
tool_failure_signal_count: int = 0
|
|
46
|
+
tool_retry_count: int = 0
|
|
47
|
+
edit_churn_count: int = 0
|
|
48
|
+
consecutive_failure_max: int = 0
|
|
49
|
+
final_failure_streak: int = 0
|
|
50
|
+
compaction_count: int = 0
|
|
51
|
+
mid_task_compaction_count: int = 0
|
|
52
|
+
compaction_boundaries: list[int] = field(default_factory=list) # message ordinals
|
|
53
|
+
context_pressure_max: float | None = None
|
|
54
|
+
has_tool_calls: bool = False
|
|
55
|
+
has_context_data: bool = False
|
|
56
|
+
ended_with_role: str = ""
|
|
57
|
+
outcome: str = "unknown"
|
|
58
|
+
outcome_confidence: str = "low"
|
|
59
|
+
health_score: int | None = None
|
|
60
|
+
health_grade: str = ""
|
|
61
|
+
penalties: dict[str, int] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_failure(category: str, result_content: str, is_error: bool) -> bool:
|
|
65
|
+
"""A tool result is a failure if flagged is_error, or its content matches the
|
|
66
|
+
category-specific heuristics (Bash error shapes; Edit/Write 'FAILED')."""
|
|
67
|
+
if is_error:
|
|
68
|
+
return True
|
|
69
|
+
text = result_content or ""
|
|
70
|
+
low = text.lower()
|
|
71
|
+
if category in ("Bash",):
|
|
72
|
+
if any(s in low for s in _BASH_FAIL_SUBSTR):
|
|
73
|
+
return True
|
|
74
|
+
if _GOROUTINE.search(text) or _EXIT_CODE.search(low):
|
|
75
|
+
return True
|
|
76
|
+
if len(_JS_AT.findall(text)) >= 3:
|
|
77
|
+
return True
|
|
78
|
+
if category in ("Edit", "Write") and "FAILED" in text:
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _tool_failure_sequence(session: ParsedSession) -> list[bool]:
|
|
84
|
+
"""Per-tool-result failure flags in message order. A result is matched to the
|
|
85
|
+
tool_use it answers (by category, via the preceding assistant turn's tool_uses)."""
|
|
86
|
+
# map tool_use_id -> category for results that carry an id; else fall back to
|
|
87
|
+
# the most recent assistant tool_use category.
|
|
88
|
+
id_to_cat: dict[str, str] = {}
|
|
89
|
+
last_cat = "Other"
|
|
90
|
+
seq: list[bool] = []
|
|
91
|
+
for m in session.messages:
|
|
92
|
+
for tu in m.tool_uses:
|
|
93
|
+
cat = normalize_tool_category(tu.name)
|
|
94
|
+
last_cat = cat
|
|
95
|
+
if tu.id:
|
|
96
|
+
id_to_cat[tu.id] = cat
|
|
97
|
+
for tr in m.tool_results:
|
|
98
|
+
cat = id_to_cat.get(tr.tool_use_id, last_cat)
|
|
99
|
+
seq.append(_is_failure(cat, tr.content, tr.is_error))
|
|
100
|
+
return seq
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _retry_count(session: ParsedSession) -> int:
|
|
104
|
+
"""Runs of 3+ identical (tool_name, input_json) -> sum(runLen - 1)."""
|
|
105
|
+
calls = [(tu.name, tu.input_json) for m in session.messages for tu in m.tool_uses]
|
|
106
|
+
total = 0
|
|
107
|
+
i = 0
|
|
108
|
+
n = len(calls)
|
|
109
|
+
while i < n:
|
|
110
|
+
j = i + 1
|
|
111
|
+
while j < n and calls[j] == calls[i]:
|
|
112
|
+
j += 1
|
|
113
|
+
run = j - i
|
|
114
|
+
if run >= 3:
|
|
115
|
+
total += run - 1
|
|
116
|
+
i = j
|
|
117
|
+
return total
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _edit_churn_count(session: ParsedSession) -> int:
|
|
121
|
+
"""Excess Edit/Write touches: a touch counts as churn when the same file already
|
|
122
|
+
had >=2 touches within the trailing 10-ordinal window."""
|
|
123
|
+
touches: list[tuple[int, str]] = [] # (ordinal, file)
|
|
124
|
+
for m in session.messages:
|
|
125
|
+
for tu in m.tool_uses:
|
|
126
|
+
if normalize_tool_category(tu.name) in ("Edit", "Write"):
|
|
127
|
+
path = _extract_path(tu.input_json)
|
|
128
|
+
if path:
|
|
129
|
+
touches.append((m.ordinal, path))
|
|
130
|
+
churn = 0
|
|
131
|
+
for idx, (ordinal, path) in enumerate(touches):
|
|
132
|
+
prior = [1 for o, p in touches[:idx] if p == path and (ordinal - o) <= 10]
|
|
133
|
+
if len(prior) >= 2:
|
|
134
|
+
churn += 1
|
|
135
|
+
return churn
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _extract_path(input_json: str) -> str:
|
|
139
|
+
if not input_json:
|
|
140
|
+
return ""
|
|
141
|
+
try:
|
|
142
|
+
obj = json.loads(input_json)
|
|
143
|
+
except Exception:
|
|
144
|
+
return ""
|
|
145
|
+
if isinstance(obj, dict):
|
|
146
|
+
for k in _EDIT_PATH_KEYS:
|
|
147
|
+
v = obj.get(k)
|
|
148
|
+
if isinstance(v, str) and v:
|
|
149
|
+
return v
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _compactions(session: ParsedSession) -> tuple[int, list[int]]:
|
|
154
|
+
"""Count >30% context-token drops between consecutive turns that carry context.
|
|
155
|
+
Returns (count, list of boundary message-indexes)."""
|
|
156
|
+
count = 0
|
|
157
|
+
boundaries: list[int] = []
|
|
158
|
+
prev: int | None = None
|
|
159
|
+
for idx, m in enumerate(session.messages):
|
|
160
|
+
ctx = m.context_tokens
|
|
161
|
+
if ctx <= 0:
|
|
162
|
+
continue
|
|
163
|
+
if prev is not None and prev * 0.7 > ctx:
|
|
164
|
+
count += 1
|
|
165
|
+
boundaries.append(idx)
|
|
166
|
+
prev = ctx
|
|
167
|
+
return count, boundaries
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _mid_task_compactions(session: ParsedSession, boundaries: list[int]) -> int:
|
|
171
|
+
"""A compaction is mid-task when the agent resumes similar work across it: >=2 tool
|
|
172
|
+
names shared between the 5 messages after the boundary and the 10 before it."""
|
|
173
|
+
msgs = session.messages
|
|
174
|
+
mid = 0
|
|
175
|
+
for b in boundaries:
|
|
176
|
+
before = {
|
|
177
|
+
normalize_tool_category(tu.name) for m in msgs[max(0, b - 10) : b] for tu in m.tool_uses
|
|
178
|
+
}
|
|
179
|
+
after = {normalize_tool_category(tu.name) for m in msgs[b : b + 5] for tu in m.tool_uses}
|
|
180
|
+
if len(before & after) >= 2:
|
|
181
|
+
mid += 1
|
|
182
|
+
return mid
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _classify_outcome(
|
|
186
|
+
*,
|
|
187
|
+
is_automated: bool,
|
|
188
|
+
message_count: int,
|
|
189
|
+
ended_with_role: str,
|
|
190
|
+
final_failure_streak: int,
|
|
191
|
+
last_text: str,
|
|
192
|
+
) -> tuple[str, str]:
|
|
193
|
+
"""Port of outcome.go: heuristic on conversation shape, not content quality."""
|
|
194
|
+
if is_automated:
|
|
195
|
+
return "unknown", "low"
|
|
196
|
+
if message_count == 2 and ended_with_role == "assistant":
|
|
197
|
+
return "completed", "medium"
|
|
198
|
+
if message_count < 3:
|
|
199
|
+
return "unknown", "low"
|
|
200
|
+
if ended_with_role == "user":
|
|
201
|
+
return "abandoned", ("high" if message_count >= 10 else "medium")
|
|
202
|
+
if final_failure_streak >= 3:
|
|
203
|
+
return "errored", "medium"
|
|
204
|
+
if ended_with_role == "assistant":
|
|
205
|
+
gave_up = any(p in last_text.lower() for p in _GIVE_UP)
|
|
206
|
+
return "completed", ("low" if gave_up else "medium")
|
|
207
|
+
return "unknown", "low"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cap(raw: int, cap: int) -> int:
|
|
211
|
+
return cap if raw > cap else raw
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def compute_health_score(s: SessionSignals) -> tuple[int | None, str, dict[str, int]]:
|
|
215
|
+
"""Verbatim port of agentsview signals/score.go ComputeHealthScore.
|
|
216
|
+
|
|
217
|
+
Start 100, floor 0. Unscored (None) iff outcome==unknown AND confidence==low AND no
|
|
218
|
+
tool/context data. Grade: >=90 A, >=75 B, >=60 C, >=40 D, else F.
|
|
219
|
+
"""
|
|
220
|
+
basis = ["outcome"]
|
|
221
|
+
if s.has_tool_calls:
|
|
222
|
+
basis.append("tool_health")
|
|
223
|
+
if s.has_context_data:
|
|
224
|
+
basis.append("context_pressure")
|
|
225
|
+
can_score = (s.outcome != "unknown" or s.outcome_confidence != "low") or len(basis) > 1
|
|
226
|
+
if not can_score:
|
|
227
|
+
return None, "", {}
|
|
228
|
+
|
|
229
|
+
pen: dict[str, int] = {}
|
|
230
|
+
if s.outcome == "errored":
|
|
231
|
+
pen["outcome_errored"] = 30
|
|
232
|
+
elif s.outcome == "abandoned":
|
|
233
|
+
pen["outcome_abandoned"] = 15
|
|
234
|
+
if s.has_tool_calls:
|
|
235
|
+
if (p := _cap(s.tool_failure_signal_count * 3, 30)) > 0:
|
|
236
|
+
pen["tool_failure_signals"] = p
|
|
237
|
+
if (p := _cap(s.tool_retry_count * 5, 25)) > 0:
|
|
238
|
+
pen["tool_retries"] = p
|
|
239
|
+
if (p := _cap(s.edit_churn_count * 4, 20)) > 0:
|
|
240
|
+
pen["edit_churn"] = p
|
|
241
|
+
if s.consecutive_failure_max >= 3:
|
|
242
|
+
pen["consecutive_failures"] = 10
|
|
243
|
+
if s.has_context_data:
|
|
244
|
+
if s.compaction_count >= 2 and (p := _cap((s.compaction_count - 1) * 5, 15)) > 0:
|
|
245
|
+
pen["compactions"] = p
|
|
246
|
+
if s.mid_task_compaction_count > 0 and (p := _cap(s.mid_task_compaction_count * 8, 18)) > 0:
|
|
247
|
+
pen["mid_task_compactions"] = p
|
|
248
|
+
if s.context_pressure_max is not None and s.context_pressure_max > 0.9:
|
|
249
|
+
pen["context_pressure_high"] = 10
|
|
250
|
+
|
|
251
|
+
score = max(0, 100 - sum(pen.values()))
|
|
252
|
+
grade = (
|
|
253
|
+
"A"
|
|
254
|
+
if score >= 90
|
|
255
|
+
else "B" if score >= 75 else "C" if score >= 60 else "D" if score >= 40 else "F"
|
|
256
|
+
)
|
|
257
|
+
return score, grade, pen
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def extract_signals(session: ParsedSession, *, context_window: int | None = None) -> SessionSignals:
|
|
261
|
+
"""Compute all behavioral signals + the health score for one parsed session."""
|
|
262
|
+
msgs = session.messages
|
|
263
|
+
s = SessionSignals()
|
|
264
|
+
s.message_count = len(msgs)
|
|
265
|
+
s.user_message_count = sum(1 for m in msgs if m.role == "user")
|
|
266
|
+
s.total_output_tokens = sum(int(m.output_tokens or 0) for m in msgs)
|
|
267
|
+
s.peak_context_tokens = max((m.context_tokens for m in msgs), default=0)
|
|
268
|
+
s.has_tool_calls = any(m.tool_uses for m in msgs)
|
|
269
|
+
s.has_context_data = any(m.context_tokens > 0 for m in msgs)
|
|
270
|
+
s.ended_with_role = msgs[-1].role if msgs else ""
|
|
271
|
+
|
|
272
|
+
# automation heuristic: a one-shot autonomous run (>=6 turns, <=1 human turn).
|
|
273
|
+
s.is_automated = s.message_count >= 6 and s.user_message_count <= 1 and s.has_tool_calls
|
|
274
|
+
|
|
275
|
+
failures = _tool_failure_sequence(session)
|
|
276
|
+
s.tool_failure_signal_count = sum(1 for f in failures if f)
|
|
277
|
+
s.consecutive_failure_max = _max_run(failures)
|
|
278
|
+
s.final_failure_streak = _trailing_run(failures)
|
|
279
|
+
s.tool_retry_count = _retry_count(session)
|
|
280
|
+
s.edit_churn_count = _edit_churn_count(session)
|
|
281
|
+
|
|
282
|
+
s.compaction_count, boundaries = _compactions(session)
|
|
283
|
+
s.mid_task_compaction_count = _mid_task_compactions(session, boundaries)
|
|
284
|
+
s.compaction_boundaries = [msgs[i].ordinal for i in boundaries if 0 <= i < len(msgs)]
|
|
285
|
+
if context_window and s.peak_context_tokens > 0:
|
|
286
|
+
s.context_pressure_max = round(s.peak_context_tokens / float(context_window), 4)
|
|
287
|
+
|
|
288
|
+
last_text = ""
|
|
289
|
+
for m in reversed(msgs):
|
|
290
|
+
if m.role == "assistant" and m.content:
|
|
291
|
+
last_text = m.content
|
|
292
|
+
break
|
|
293
|
+
s.outcome, s.outcome_confidence = _classify_outcome(
|
|
294
|
+
is_automated=s.is_automated,
|
|
295
|
+
message_count=s.message_count,
|
|
296
|
+
ended_with_role=s.ended_with_role,
|
|
297
|
+
final_failure_streak=s.final_failure_streak,
|
|
298
|
+
last_text=last_text,
|
|
299
|
+
)
|
|
300
|
+
s.health_score, s.health_grade, s.penalties = compute_health_score(s)
|
|
301
|
+
return s
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _max_run(flags: list[bool]) -> int:
|
|
305
|
+
best = run = 0
|
|
306
|
+
for f in flags:
|
|
307
|
+
run = run + 1 if f else 0
|
|
308
|
+
best = max(best, run)
|
|
309
|
+
return best
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _trailing_run(flags: list[bool]) -> int:
|
|
313
|
+
run = 0
|
|
314
|
+
for f in reversed(flags):
|
|
315
|
+
if f:
|
|
316
|
+
run += 1
|
|
317
|
+
else:
|
|
318
|
+
break
|
|
319
|
+
return run
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tool-name -> category normalization for tool-mix analytics.
|
|
2
|
+
|
|
3
|
+
Ported from agentsview ``internal/parser/taxonomy.go`` (MIT, see THIRD_PARTY_LICENSES.md):
|
|
4
|
+
a flat name->bucket map plus a ``"subagent" in name -> Task`` fallback, collapsing the
|
|
5
|
+
long tail of agent tool names into 9 stable buckets. Categorize at ingest so the
|
|
6
|
+
dashboard tool-mix is a plain ``GROUP BY category``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
CATEGORIES = ("Read", "Edit", "Write", "Bash", "Grep", "Glob", "Task", "Tool", "Other")
|
|
12
|
+
|
|
13
|
+
# explicit name -> category (lowercased keys). Covers Claude Code + Codex + Cursor names.
|
|
14
|
+
_MAP: dict[str, str] = {
|
|
15
|
+
# read
|
|
16
|
+
"read": "Read",
|
|
17
|
+
"read_file": "Read",
|
|
18
|
+
"notebookread": "Read",
|
|
19
|
+
"view": "Read",
|
|
20
|
+
# edit
|
|
21
|
+
"edit": "Edit",
|
|
22
|
+
"multiedit": "Edit",
|
|
23
|
+
"str_replace_based_edit_tool": "Edit",
|
|
24
|
+
"search_replace": "Edit",
|
|
25
|
+
"apply_patch": "Edit",
|
|
26
|
+
"notebookedit": "Edit",
|
|
27
|
+
# write
|
|
28
|
+
"write": "Write",
|
|
29
|
+
"create_file": "Write",
|
|
30
|
+
"write_file": "Write",
|
|
31
|
+
# bash / shell
|
|
32
|
+
"bash": "Bash",
|
|
33
|
+
"bashoutput": "Bash",
|
|
34
|
+
"killshell": "Bash",
|
|
35
|
+
"run_terminal_cmd": "Bash",
|
|
36
|
+
"shell": "Bash",
|
|
37
|
+
"local_shell": "Bash",
|
|
38
|
+
# grep / search
|
|
39
|
+
"grep": "Grep",
|
|
40
|
+
"codebase_search": "Grep",
|
|
41
|
+
"search": "Grep",
|
|
42
|
+
# glob
|
|
43
|
+
"glob": "Glob",
|
|
44
|
+
"list_dir": "Glob",
|
|
45
|
+
"file_search": "Glob",
|
|
46
|
+
# task / subagent
|
|
47
|
+
"task": "Task",
|
|
48
|
+
"agent": "Task",
|
|
49
|
+
# generic tool buckets (web, mcp, todo, etc. that are not core file/shell ops)
|
|
50
|
+
"webfetch": "Tool",
|
|
51
|
+
"websearch": "Tool",
|
|
52
|
+
"web_search": "Tool",
|
|
53
|
+
"fetch": "Tool",
|
|
54
|
+
"todowrite": "Tool",
|
|
55
|
+
"update_plan": "Tool",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_tool_category(name: str | None) -> str:
|
|
60
|
+
"""Map a raw tool name to one of the 9 buckets. Unknown -> Other; any name
|
|
61
|
+
containing 'subagent' -> Task (mirrors agentsview's fallback)."""
|
|
62
|
+
if not name:
|
|
63
|
+
return "Other"
|
|
64
|
+
key = name.strip().lower()
|
|
65
|
+
if key in _MAP:
|
|
66
|
+
return _MAP[key]
|
|
67
|
+
if "subagent" in key:
|
|
68
|
+
return "Task"
|
|
69
|
+
if key.startswith("mcp__") or key.startswith("mcp_"):
|
|
70
|
+
return "Tool"
|
|
71
|
+
return "Other"
|