agentprof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprof/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """agentprof: analyse AI coding agent sessions."""
4
+
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("agentprof")
@@ -0,0 +1,15 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Agent adapters: turn agent-specific session files into the neutral model."""
4
+
5
+ from importlib.metadata import entry_points
6
+
7
+ from agentprof.adapters.base import AdapterConfig, AgentAdapter
8
+
9
+ ENTRY_POINT_GROUP = "agentprof.adapters"
10
+
11
+
12
+ def load_adapters(config: AdapterConfig) -> list[AgentAdapter]:
13
+ """Instantiate every adapter registered under the `agentprof.adapters` entry point group."""
14
+ registered = sorted(entry_points(group=ENTRY_POINT_GROUP), key=lambda entry_point: entry_point.name)
15
+ return [entry_point.load()(config) for entry_point in registered]
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """The adapter protocol and the lightweight types the registry works with."""
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ from agentprof.model import CostMetric, Session
11
+
12
+
13
+ def latest_mtime(paths: Iterable[Path]) -> float:
14
+ """The newest modification time among `paths`; paths that vanished meanwhile are ignored."""
15
+ times: list[float] = []
16
+ for path in paths:
17
+ try:
18
+ times.append(path.stat().st_mtime)
19
+ except OSError:
20
+ continue
21
+ return max(times, default=0.0)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AdapterConfig:
26
+ """User configuration shared by all adapters.
27
+
28
+ `roots` maps an adapter name to an overriding data root; `pricing_file` replaces the bundled price table.
29
+ """
30
+
31
+ roots: dict[str, Path] = field(default_factory=dict)
32
+ pricing_file: Path | None = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SessionRef:
37
+ """A cheap handle to one session file."""
38
+
39
+ agent: str
40
+ native_id: str
41
+ path: Path
42
+ mtime: float
43
+
44
+ @property
45
+ def id(self) -> str:
46
+ return f"{self.agent}:{self.native_id}"
47
+
48
+
49
+ @dataclass
50
+ class SessionSummary:
51
+ """One row of the session list: only what an adapter can read without building the tree."""
52
+
53
+ id: str
54
+ agent: str
55
+ title: str
56
+ workspace: str | None
57
+ start_ms: float | None
58
+ end_ms: float | None
59
+ file_size: int
60
+ last_activity_ms: float | None = None
61
+ cost_total: CostMetric = field(default_factory=CostMetric.not_available)
62
+
63
+
64
+ class AgentAdapter(Protocol):
65
+ """Turns one agent's session files into the neutral model.
66
+
67
+ Implementations are constructed with an `AdapterConfig` and registered under the entry point group
68
+ `agentprof.adapters`. `summarize` and `analyze` raise on sessions they cannot read.
69
+ """
70
+
71
+ name: str
72
+
73
+ def discover(self) -> Iterable[SessionRef]: ...
74
+
75
+ def open_path(self, path: Path) -> SessionRef | None: ...
76
+
77
+ def summarize(self, ref: SessionRef) -> SessionSummary: ...
78
+
79
+ def analyze(self, ref: SessionRef) -> Session: ...
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Claude Code adapter."""
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """The Claude Code adapter."""
4
+
5
+ import json
6
+ import os
7
+ from collections import Counter
8
+ from collections.abc import Iterator
9
+ from pathlib import Path
10
+
11
+ from agentprof.adapters.base import AdapterConfig, SessionRef, SessionSummary, latest_mtime
12
+ from agentprof.adapters.claude_code.discovery import (
13
+ default_projects_root,
14
+ load_subagents,
15
+ session_files,
16
+ subagent_dir,
17
+ )
18
+ from agentprof.adapters.claude_code.prompts import NO_PROMPT, prompt_topic
19
+ from agentprof.adapters.claude_code.tools import is_known_tool_id
20
+ from agentprof.adapters.claude_code.transcript import Transcript, load_transcript
21
+ from agentprof.adapters.claude_code.tree import build_root, message_cost
22
+ from agentprof.model import Diagnostics, Session, sum_costs
23
+ from agentprof.pricing import PriceTable
24
+
25
+ _TITLE_LENGTH = 80
26
+ _SNIFF_LINES = 20
27
+ _SUBAGENT_DIR = "subagents"
28
+
29
+
30
+ def _title(main: Transcript, fallback: str) -> str:
31
+ if main.title:
32
+ return main.title
33
+ for prompt in main.prompts:
34
+ topic = prompt_topic(prompt.text)
35
+ if topic != NO_PROMPT:
36
+ return topic[:_TITLE_LENGTH]
37
+ return fallback
38
+
39
+
40
+ def _looks_like_session(path: Path) -> bool:
41
+ """Whether one of the first lines is a Claude Code entry carrying a `sessionId`."""
42
+ try:
43
+ with path.open(encoding="utf-8") as lines:
44
+ for _, line in zip(range(_SNIFF_LINES), lines, strict=False):
45
+ try:
46
+ entry = json.loads(line)
47
+ except ValueError:
48
+ continue
49
+ if isinstance(entry, dict) and isinstance(entry.get("sessionId"), str):
50
+ return True
51
+ except (OSError, UnicodeDecodeError):
52
+ return False
53
+ return False
54
+
55
+
56
+ class ClaudeCodeAdapter:
57
+ """Reads Claude Code sessions from `~/.claude/projects`."""
58
+
59
+ name = "claude-code"
60
+
61
+ def __init__(self, config: AdapterConfig) -> None:
62
+ override = config.roots.get(self.name)
63
+ self._root = override if override is not None else default_projects_root(os.environ, Path.home())
64
+ self._prices = PriceTable.load(config.pricing_file)
65
+
66
+ def _ref(self, path: Path) -> SessionRef:
67
+ files = [path, *subagent_dir(path).glob("agent-*")]
68
+ return SessionRef(agent=self.name, native_id=path.stem, path=path, mtime=latest_mtime(files))
69
+
70
+ def discover(self) -> Iterator[SessionRef]:
71
+ for path in session_files(self._root):
72
+ yield self._ref(path)
73
+
74
+ def open_path(self, path: Path) -> SessionRef | None:
75
+ if path.suffix != ".jsonl" or path.parent.name == _SUBAGENT_DIR or not path.is_file():
76
+ return None
77
+ return self._ref(path) if _looks_like_session(path) else None
78
+
79
+ def summarize(self, ref: SessionRef) -> SessionSummary:
80
+ main = load_transcript(ref.path)
81
+ transcripts = [main, *(subagent.transcript for subagent in load_subagents(ref.path))]
82
+ return SessionSummary(
83
+ id=ref.id,
84
+ agent=self.name,
85
+ title=_title(main, ref.native_id),
86
+ workspace=main.cwd,
87
+ start_ms=main.first_timestamp_ms if main.first_timestamp_ms is not None else ref.mtime * 1000,
88
+ end_ms=ref.mtime * 1000,
89
+ file_size=ref.path.stat().st_size,
90
+ last_activity_ms=max(
91
+ (timestamp for transcript in transcripts if (timestamp := transcript.last_message_ms) is not None),
92
+ default=None,
93
+ ),
94
+ cost_total=sum_costs(
95
+ [message_cost(message, self._prices) for transcript in transcripts for message in transcript.messages]
96
+ ),
97
+ )
98
+
99
+ def analyze(self, ref: SessionRef) -> Session:
100
+ main = load_transcript(ref.path)
101
+ subagents = load_subagents(ref.path)
102
+ transcripts = [main, *(subagent.transcript for subagent in subagents)]
103
+ diagnostics = Diagnostics(malformed_lines=sum(transcript.malformed_lines for transcript in transcripts))
104
+ diagnostics.malformed_line_details = [
105
+ detail for transcript in transcripts for detail in transcript.malformed_line_details
106
+ ][:20]
107
+ diagnostics.unknown_tool_ids = dict(
108
+ Counter(
109
+ tool_use.name
110
+ for transcript in transcripts
111
+ for tool_use in transcript.tool_uses
112
+ if not is_known_tool_id(tool_use.name)
113
+ )
114
+ )
115
+ unpriced = sorted(
116
+ {
117
+ message.model
118
+ for transcript in transcripts
119
+ for message in transcript.messages
120
+ if message.model and self._prices.price_for(message.model) is None
121
+ }
122
+ )
123
+ diagnostics.warnings.extend(f"No price for model {model!r}: its cost is unavailable." for model in unpriced)
124
+ title = _title(main, ref.native_id)
125
+ return Session(
126
+ id=ref.id,
127
+ agent=self.name,
128
+ title=title,
129
+ workspace=main.cwd,
130
+ root=build_root(main, subagents, self._prices, title),
131
+ sources=["transcript", *(["subagents"] if subagents else [])],
132
+ diagnostics=diagnostics,
133
+ )
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Locate Claude Code session files and their subagent transcripts."""
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from agentprof.adapters.claude_code.transcript import Transcript, load_transcript
11
+
12
+ _AGENT_PREFIX = "agent-"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SubagentMeta:
17
+ """Contents of `agent-<id>.meta.json`; `tool_use_id` names the `Agent` call that spawned the subagent."""
18
+
19
+ agent_id: str
20
+ tool_use_id: str | None
21
+ agent_type: str | None
22
+ description: str | None
23
+ model: str | None
24
+
25
+
26
+ @dataclass
27
+ class Subagent:
28
+ meta: SubagentMeta
29
+ transcript: Transcript
30
+
31
+
32
+ def default_projects_root(env: Mapping[str, str], home: Path) -> Path:
33
+ """`$CLAUDE_CONFIG_DIR/projects` if set, else `~/.claude/projects`."""
34
+ config_dir = env.get("CLAUDE_CONFIG_DIR")
35
+ return (Path(config_dir) if config_dir else home / ".claude") / "projects"
36
+
37
+
38
+ def session_files(root: Path) -> list[Path]:
39
+ """Main transcripts: `<root>/<project-dir>/<session-id>.jsonl` (subagent files sit deeper)."""
40
+ return sorted(root.glob("*/*.jsonl"))
41
+
42
+
43
+ def subagent_dir(session_file: Path) -> Path:
44
+ """`<project-dir>/<session-id>/subagents/`, next to the main transcript."""
45
+ return session_file.parent / session_file.stem / "subagents"
46
+
47
+
48
+ def read_meta(agent_id: str, meta_file: Path) -> SubagentMeta:
49
+ """Read a subagent's meta file; missing or invalid files yield a meta without spawning call."""
50
+ try:
51
+ data = json.loads(meta_file.read_text(encoding="utf-8"))
52
+ except (OSError, ValueError):
53
+ data = {}
54
+ if not isinstance(data, dict):
55
+ data = {}
56
+
57
+ def text(key: str) -> str | None:
58
+ value = data.get(key)
59
+ return value if isinstance(value, str) and value else None
60
+
61
+ return SubagentMeta(
62
+ agent_id=agent_id,
63
+ tool_use_id=text("toolUseId"),
64
+ agent_type=text("agentType"),
65
+ description=text("description"),
66
+ model=text("model"),
67
+ )
68
+
69
+
70
+ def load_subagents(session_file: Path) -> list[Subagent]:
71
+ """Parse every subagent transcript of a session, flat regardless of nesting depth."""
72
+ directory = subagent_dir(session_file)
73
+ subagents: list[Subagent] = []
74
+ for path in sorted(directory.glob(f"{_AGENT_PREFIX}*.jsonl")):
75
+ agent_id = path.stem.removeprefix(_AGENT_PREFIX)
76
+ subagents.append(
77
+ Subagent(meta=read_meta(agent_id, path.with_suffix(".meta.json")), transcript=load_transcript(path))
78
+ )
79
+ return subagents
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Derive a short, human-readable topic from a Claude Code turn prompt."""
4
+
5
+ import re
6
+
7
+ NO_PROMPT = "(no prompt)"
8
+ _TOPIC_LENGTH = 200
9
+ _BLOCK_TAGS = ("ide_opened_file", "ide_selection", "system-reminder", "local-command-caveat")
10
+ _BLOCK_RE = re.compile("|".join(rf"<{tag}>.*?</{tag}>" for tag in _BLOCK_TAGS), re.DOTALL)
11
+ _COMMAND_RE = re.compile(r"<command-name>(.*?)</command-name>(?:<command-args>(.*?)</command-args>)?", re.DOTALL)
12
+ _LOCAL_COMMAND_STDOUT_RE = re.compile(r"<local-command-stdout>.*?</local-command-stdout>", re.DOTALL)
13
+ _TASK_NOTIFICATION_RE = re.compile(r"<task-notification>(.*?)</task-notification>", re.DOTALL)
14
+ _SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.DOTALL)
15
+
16
+
17
+ def first_line(text: str, limit: int) -> str:
18
+ """The first non-blank line of `text`, stripped and cut to `limit` characters."""
19
+ return next((line.strip()[:limit] for line in text.splitlines() if line.strip()), "")
20
+
21
+
22
+ def prompt_topic(text: str) -> str:
23
+ """A short topic for a turn prompt, stripping IDE/harness context and special markup."""
24
+ stripped = _BLOCK_RE.sub("", text)
25
+
26
+ command_match = _COMMAND_RE.search(stripped)
27
+ if command_match is not None:
28
+ name = command_match.group(1).strip()
29
+ args = (command_match.group(2) or "").strip()
30
+ return f"{name} {args}" if args else name
31
+
32
+ without_stdout = _LOCAL_COMMAND_STDOUT_RE.sub("", stripped)
33
+ if without_stdout != stripped and not without_stdout.strip():
34
+ return "(command output)"
35
+ stripped = without_stdout
36
+
37
+ notification_match = _TASK_NOTIFICATION_RE.search(stripped)
38
+ if notification_match is not None:
39
+ summary_match = _SUMMARY_RE.search(notification_match.group(1))
40
+ if summary_match is not None:
41
+ return f"Task notification: {summary_match.group(1).strip()}"
42
+ return "Task notification"
43
+
44
+ return first_line(stripped, _TOPIC_LENGTH) or NO_PROMPT
@@ -0,0 +1,102 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 epicodic
3
+ """Map Claude Code tool names to neutral tool categories and normalised arguments."""
4
+
5
+ from agentprof.model import ToolCategory, ToolInfo
6
+
7
+ _CATEGORIES: dict[str, ToolCategory] = {
8
+ "Read": ToolCategory.READ,
9
+ "Edit": ToolCategory.EDIT,
10
+ "Write": ToolCategory.EDIT,
11
+ "NotebookEdit": ToolCategory.EDIT,
12
+ "Grep": ToolCategory.SEARCH,
13
+ "Glob": ToolCategory.SEARCH,
14
+ "Bash": ToolCategory.SHELL,
15
+ "BashOutput": ToolCategory.SHELL_POLL,
16
+ "TaskOutput": ToolCategory.SHELL_POLL,
17
+ "Monitor": ToolCategory.SHELL_POLL,
18
+ "WebFetch": ToolCategory.WEB,
19
+ "WebSearch": ToolCategory.WEB,
20
+ "Agent": ToolCategory.SUBAGENT,
21
+ "Task": ToolCategory.SUBAGENT,
22
+ }
23
+
24
+ # Built-in harness tools without a dedicated category; they map to `other` but are not reported as unknown.
25
+ _KNOWN_OTHER_TOOL_IDS = {
26
+ "AskUserQuestion",
27
+ "ToolSearch",
28
+ "Skill",
29
+ "SendMessage",
30
+ "ListAgents",
31
+ "TaskCreate",
32
+ "TaskUpdate",
33
+ "TaskList",
34
+ "TaskGet",
35
+ "TaskStop",
36
+ "KillShell",
37
+ "TodoWrite",
38
+ "EnterPlanMode",
39
+ "ExitPlanMode",
40
+ "EnterWorktree",
41
+ "ExitWorktree",
42
+ "ScheduleWakeup",
43
+ "CronCreate",
44
+ "CronDelete",
45
+ "CronList",
46
+ "PushNotification",
47
+ "RemoteTrigger",
48
+ "ReadNotifications",
49
+ "SendUserFile",
50
+ "SlashCommand",
51
+ "LSP",
52
+ "Artifact",
53
+ "ArtifactComments",
54
+ "ArtifactData",
55
+ "ReportFindings",
56
+ "SendFeedback",
57
+ }
58
+ _CATEGORIES_CASEFOLDED = {name.casefold(): category for name, category in _CATEGORIES.items()}
59
+ _KNOWN_OTHER_TOOL_IDS_CASEFOLDED = {name.casefold() for name in _KNOWN_OTHER_TOOL_IDS}
60
+ _MCP_PREFIX = "mcp__"
61
+ _FILE_CATEGORIES = (ToolCategory.READ, ToolCategory.EDIT)
62
+ _WHOLE_FILE_WRITERS = {"write"}
63
+
64
+
65
+ def is_known_tool_id(name: str) -> bool:
66
+ """Whether `name` is expected; unknown names are counted in the session diagnostics."""
67
+ normalized = name.casefold()
68
+ return (
69
+ normalized in _CATEGORIES_CASEFOLDED
70
+ or normalized in _KNOWN_OTHER_TOOL_IDS_CASEFOLDED
71
+ or normalized.startswith(_MCP_PREFIX)
72
+ )
73
+
74
+
75
+ def _positive_int(value: object) -> int | None:
76
+ return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
77
+
78
+
79
+ def _line_range(arguments: dict[str, object]) -> tuple[int, int] | None:
80
+ """`Read` takes a 1-based start line `offset` and a line count `limit`; without both it may read the whole file."""
81
+ offset = _positive_int(arguments.get("offset"))
82
+ limit = _positive_int(arguments.get("limit"))
83
+ if limit is None:
84
+ return None
85
+ start = offset or 1
86
+ return (start, start + limit - 1)
87
+
88
+
89
+ def tool_info(name: str, arguments: dict[str, object]) -> ToolInfo:
90
+ """Build the neutral `ToolInfo` for one Claude Code tool call."""
91
+ category = _CATEGORIES_CASEFOLDED.get(name.casefold(), ToolCategory.OTHER)
92
+ path = arguments.get("file_path") or arguments.get("notebook_path")
93
+ command = arguments.get("command")
94
+ return ToolInfo(
95
+ native_id=name,
96
+ category=category,
97
+ path=str(path) if path and category in _FILE_CATEGORIES else None,
98
+ line_range=_line_range(arguments) if category is ToolCategory.READ else None,
99
+ command=str(command) if command and category is ToolCategory.SHELL else None,
100
+ writes_file=name.casefold() in _WHOLE_FILE_WRITERS,
101
+ arguments=arguments,
102
+ )