agentplay 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 (75) hide show
  1. agentplay/__init__.py +6 -0
  2. agentplay/__main__.py +5 -0
  3. agentplay/_claude_role.py +45 -0
  4. agentplay/_core/__init__.py +4 -0
  5. agentplay/_core/claude_edit_evidence.py +397 -0
  6. agentplay/_core/code_authorship.py +401 -0
  7. agentplay/_core/ingestion/__init__.py +0 -0
  8. agentplay/_core/ingestion/coding_agent/__init__.py +0 -0
  9. agentplay/_core/ingestion/coding_agent/_paths.py +111 -0
  10. agentplay/_core/ingestion/coding_agent/_sources/__init__.py +0 -0
  11. agentplay/_core/ingestion/coding_agent/_sources/_base.py +183 -0
  12. agentplay/_core/ingestion/coding_agent/_sources/_jsonl_utils.py +752 -0
  13. agentplay/_core/ingestion/coding_agent/_sources/local/__init__.py +0 -0
  14. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/__init__.py +56 -0
  15. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/base.py +156 -0
  16. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/claude_code.py +957 -0
  17. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/claude_cost_summary.py +148 -0
  18. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/codex.py +1055 -0
  19. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/copilot.py +912 -0
  20. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/copilot_vscode.py +505 -0
  21. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/cursor_agent_transcript.py +309 -0
  22. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/cursor_store.py +1072 -0
  23. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/kiro.py +537 -0
  24. agentplay/_core/ingestion/coding_agent/_sources/local/parsers/opencode.py +436 -0
  25. agentplay/_core/ingestion/coding_agent/_sources/local/source.py +2441 -0
  26. agentplay/_core/ingestion/coding_agent/behavior_signals.py +851 -0
  27. agentplay/_core/ingestion/coding_agent/edit_lines.py +239 -0
  28. agentplay/_core/ingestion/coding_agent/file_paths.py +87 -0
  29. agentplay/_core/ingestion/coding_agent/metrics.py +316 -0
  30. agentplay/_core/ingestion/coding_agent/native_operations.py +271 -0
  31. agentplay/_core/ingestion/coding_agent/produced.py +570 -0
  32. agentplay/_core/ingestion/coding_agent/session_pure.py +1176 -0
  33. agentplay/_core/ingestion/coding_agent/skill_names.py +358 -0
  34. agentplay/_core/ingestion/coding_agent/spans.py +325 -0
  35. agentplay/_core/ingestion/coding_agent/tool_taxonomy.py +246 -0
  36. agentplay/_core/pricing.py +681 -0
  37. agentplay/_util.py +25 -0
  38. agentplay/builder.py +2455 -0
  39. agentplay/cli.py +437 -0
  40. agentplay/compare.py +96 -0
  41. agentplay/discover.py +332 -0
  42. agentplay/doctor.py +618 -0
  43. agentplay/fix.py +477 -0
  44. agentplay/hooks/__init__.py +0 -0
  45. agentplay/hooks/compress.py +167 -0
  46. agentplay/hooks/trim.py +437 -0
  47. agentplay/materialize.py +1671 -0
  48. agentplay/pipeline.py +140 -0
  49. agentplay/server.py +214 -0
  50. agentplay/store.py +129 -0
  51. agentplay/viewer/assets/index-Bc-cysF_.css +1 -0
  52. agentplay/viewer/assets/index-DvI1RDgp.js +51 -0
  53. agentplay/viewer/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  54. agentplay/viewer/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  55. agentplay/viewer/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  56. agentplay/viewer/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  57. agentplay/viewer/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  58. agentplay/viewer/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  59. agentplay/viewer/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  60. agentplay/viewer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  61. agentplay/viewer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  62. agentplay/viewer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  63. agentplay/viewer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  64. agentplay/viewer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  65. agentplay/viewer/demo/claude-code-rate-limit.json +1166 -0
  66. agentplay/viewer/demo/codex-flaky-test.json +579 -0
  67. agentplay/viewer/demo/cursor-copy-tweak.json +242 -0
  68. agentplay/viewer/demo/index.json +136 -0
  69. agentplay/viewer/index.html +40 -0
  70. agentplay-0.1.0.dist-info/METADATA +40 -0
  71. agentplay-0.1.0.dist-info/RECORD +75 -0
  72. agentplay-0.1.0.dist-info/WHEEL +4 -0
  73. agentplay-0.1.0.dist-info/entry_points.txt +2 -0
  74. agentplay-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. agentplay-0.1.0.dist-info/licenses/NOTICE +13 -0
agentplay/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Agentplay: replay coding-agent sessions and find what is wasting your agent's time.
2
+
3
+ Everything runs locally. See https://github.com/Entelligence-AI/agentplay.
4
+ """
5
+
6
+ __version__ = "0.1.0"
agentplay/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """Claude Code transcript role detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+
8
+ def claude_subagent_transcript(data: bytes) -> bool:
9
+ """Whether this transcript is a Claude Code subagent's, not a session of its own.
10
+
11
+ Claude Code writes a subagent's turns to their own file and marks every
12
+ conversation record ``isSidechain: true``. Newer builds front-load metadata
13
+ records (``queue-operation``, ``last-prompt``, ``ai-title``) that carry no
14
+ flag, so checking only the first line lets a subagent file pass as a
15
+ standalone session.
16
+
17
+ The test is ALL, not ANY: a real session that merely used subagents has its
18
+ own unflagged turns. Only a transcript whose every conversation record is
19
+ flagged is itself a subagent's.
20
+
21
+ Args:
22
+ data: raw transcript bytes.
23
+
24
+ Returns:
25
+ True only when at least one conversation record is a sidechain and none
26
+ is not. False for an empty, unparseable, or ordinary transcript.
27
+ """
28
+ saw_sidechain = False
29
+ for raw in data.split(b"\n"):
30
+ raw = raw.strip()
31
+ if not raw:
32
+ continue
33
+ try:
34
+ record = json.loads(raw)
35
+ except (ValueError, UnicodeDecodeError):
36
+ continue
37
+ if not isinstance(record, dict):
38
+ continue
39
+ if record.get("type") not in ("user", "assistant"):
40
+ continue
41
+ if record.get("isSidechain") is True:
42
+ saw_sidechain = True
43
+ continue
44
+ return False
45
+ return saw_sidechain
@@ -0,0 +1,4 @@
1
+ """memory-core — substrate-free agent-transcript compute (client and server paths shared).
2
+
3
+ The single source for the coding-agent parse/segment-input/metrics compute.
4
+ The live ``memory`` package re-exports these via shims."""
@@ -0,0 +1,397 @@
1
+ """Recover full-line edit evidence from Claude's recorded successful patch result."""
2
+
3
+ from difflib import SequenceMatcher
4
+ from typing import Any
5
+
6
+ from agentplay._core.code_authorship import (
7
+ MAX_EDITS_PER_CALL,
8
+ MAX_HASHES_PER_CALL,
9
+ extract_edit_evidence,
10
+ line_fingerprints,
11
+ )
12
+
13
+
14
+ def result_patches(lines: list[dict]) -> dict[str, dict | None]:
15
+ """Index native results; None fences invalid/conflicting IDs from legacy fallback."""
16
+ results: dict[str, dict] = {}
17
+ conflicting: set[str] = set()
18
+ for line in lines:
19
+ result = line.get("toolUseResult")
20
+ message = line.get("message")
21
+ content = message.get("content") if isinstance(message, dict) else None
22
+ if not isinstance(content, list):
23
+ continue
24
+ blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_result"]
25
+ for block in blocks:
26
+ key = block.get("tool_use_id")
27
+ if not isinstance(key, str) or not key:
28
+ continue
29
+ if block.get("is_error"):
30
+ conflicting.add(key)
31
+ elif "toolUseResult" in line:
32
+ if len(blocks) != 1 or not isinstance(result, dict):
33
+ conflicting.add(key)
34
+ elif key in results and results[key] != result:
35
+ conflicting.add(key)
36
+ else:
37
+ results[key] = result
38
+ return {**results, **dict.fromkeys(conflicting)}
39
+
40
+
41
+ def call_edit_evidence(
42
+ tool_name: Any, tool_input: Any, native: Any, *, has_native: bool, successful: bool
43
+ ) -> dict:
44
+ """Choose a Claude call's edit evidence: its native result when that is the truth.
45
+
46
+ Edit always prefers its recorded patch: a present-but-unverifiable result
47
+ yields ``invalid_edits`` rather than the weaker request-string capture. A
48
+ Write whose result is an ``update`` (it overwrote an existing file) replays
49
+ as that result's hunks, because a full-content ``write`` cannot be placed
50
+ over a base; a fenced Write result (conflicting or invalid) yields
51
+ ``invalid_edits`` like a fenced Edit. Every other call keeps request-based
52
+ capture.
53
+
54
+ Args:
55
+ tool_name: The tool's name as recorded.
56
+ tool_input: The tool_use input.
57
+ native: This call's entry from ``result_patches`` (None when fenced).
58
+ has_native: Whether ``result_patches`` has an entry for this call.
59
+ successful: Whether the call produced a non-error result.
60
+
61
+ Returns:
62
+ A ``{"status", "edits"}`` evidence dict.
63
+ """
64
+ name = tool_name.lower() if isinstance(tool_name, str) else ""
65
+ if (
66
+ successful
67
+ and has_native
68
+ and (
69
+ name == "edit"
70
+ # None is result_patches' conflict fence: never fall back to the request.
71
+ or (
72
+ name == "write"
73
+ and (native is None or isinstance(native, dict) and native.get("type") == "update")
74
+ )
75
+ )
76
+ ):
77
+ return extract_result_edit(tool_name, tool_input, native) or {
78
+ "status": "invalid_edits",
79
+ "edits": [],
80
+ }
81
+ return extract_edit_evidence(tool_name, tool_input, successful=successful)
82
+
83
+
84
+ def extract_result_edit(tool_name: str, tool_input: Any, result: Any) -> dict | None:
85
+ """Hash a verified native Edit/overwriting-Write result; mismatches yield no evidence.
86
+
87
+ The patch records the actual complete lines changed by the tool, including
88
+ boundary text omitted from an Edit substring. A Write over an existing file
89
+ records an ``update`` patch of the same shape, verified against the written
90
+ content (and the original file when recorded). No current working-tree read
91
+ is used to reconstruct historical content. No source text leaves this helper.
92
+ """
93
+ if (
94
+ not isinstance(tool_name, str)
95
+ or tool_name.lower() not in {"edit", "write"}
96
+ or not isinstance(tool_input, dict)
97
+ or not isinstance(result, dict)
98
+ ):
99
+ return None
100
+ write = tool_name.lower() == "write"
101
+ path = tool_input.get("file_path")
102
+ if (
103
+ not isinstance(path, str)
104
+ or not path
105
+ or result.get("filePath") != path
106
+ or result.get("userModified") is not False
107
+ ):
108
+ return None
109
+ if write:
110
+ written = tool_input.get("content")
111
+ if (
112
+ result.get("type") != "update"
113
+ or not isinstance(written, str)
114
+ or result.get("content") != written
115
+ or len(written) > 1_000_000
116
+ ):
117
+ return None
118
+ elif (
119
+ result.get("oldString") != tool_input.get("old_string")
120
+ or result.get("newString") != tool_input.get("new_string")
121
+ or not isinstance(tool_input.get("old_string"), str)
122
+ or not tool_input["old_string"]
123
+ or not isinstance(tool_input.get("new_string"), str)
124
+ or type(result.get("replaceAll")) is not bool
125
+ or type(tool_input.get("replace_all", False)) is not bool
126
+ or result["replaceAll"] != tool_input.get("replace_all", False)
127
+ ):
128
+ return None
129
+ hunks = result.get("structuredPatch")
130
+ if not isinstance(hunks, list) or not 0 < len(hunks) <= MAX_EDITS_PER_CALL:
131
+ return None
132
+ original = result.get("originalFile")
133
+ reference = None
134
+ if write:
135
+ if isinstance(original, str):
136
+ if len(original) > 1_000_000:
137
+ return None
138
+ reference = (original.splitlines(), written.splitlines())
139
+ elif original is not None:
140
+ return None
141
+ elif isinstance(original, str):
142
+ if len(original) > 1_000_000 or tool_input["old_string"] not in original:
143
+ return None
144
+ replacements = original.count(tool_input["old_string"]) if result["replaceAll"] else 1
145
+ if (
146
+ len(original)
147
+ + replacements * (len(tool_input["new_string"]) - len(tool_input["old_string"]))
148
+ > 1_000_000
149
+ ):
150
+ return None
151
+ updated = original.replace(
152
+ tool_input["old_string"],
153
+ tool_input["new_string"],
154
+ -1 if result["replaceAll"] else 1,
155
+ )
156
+ reference = (original.splitlines(), updated.splitlines())
157
+ elif original is not None:
158
+ return None
159
+ reconstructed = []
160
+ next_original_line = 0
161
+ direct_match = len(hunks) == 1 or result.get("replaceAll") is True
162
+ edits = []
163
+ total = 0
164
+ total_chars = 0
165
+ last_old = last_new = 0
166
+ for hunk in hunks:
167
+ if not isinstance(hunk, dict):
168
+ return None
169
+ if any(
170
+ type(hunk.get(k)) is not int or hunk[k] < 0
171
+ for k in ("oldStart", "newStart", "oldLines", "newLines")
172
+ ):
173
+ return None
174
+ if hunk["oldStart"] == 0:
175
+ return None
176
+ if hunk["oldStart"] < last_old or hunk["newStart"] < last_new:
177
+ return None
178
+ last_old = hunk["oldStart"] + hunk["oldLines"]
179
+ last_new = hunk["newStart"] + hunk["newLines"]
180
+ lines = hunk.get("lines")
181
+ if not isinstance(lines, list):
182
+ return None
183
+ before, after = [], []
184
+ for line in lines:
185
+ if (
186
+ not isinstance(line, str)
187
+ or not line
188
+ or line[0] not in " +-"
189
+ or len(line.splitlines()) != 1
190
+ or line.splitlines()[0] != line
191
+ or any(0xD800 <= ord(c) <= 0xDFFF for c in line)
192
+ ):
193
+ return None
194
+ total_chars += len(line)
195
+ if total_chars > 1_000_000:
196
+ return None
197
+ if line[0] != "+":
198
+ before.append(line[1:])
199
+ if line[0] != "-":
200
+ after.append(line[1:])
201
+ if (
202
+ len(before) != hunk["oldLines"]
203
+ or len(after) != hunk["newLines"]
204
+ or not before
205
+ or before == after
206
+ ):
207
+ return None
208
+ if reference is not None:
209
+ old_start = hunk["oldStart"] - 1
210
+ new_start = max(0, hunk["newStart"] - 1)
211
+ if (
212
+ old_start < 0
213
+ or reference[0][old_start : old_start + len(before)] != before
214
+ or reference[1][new_start : new_start + len(after)] != after
215
+ ):
216
+ return None
217
+ reconstructed.extend(reference[0][next_original_line:old_start])
218
+ reconstructed.extend(after)
219
+ next_original_line = old_start + len(before)
220
+ if reference is None and write:
221
+ # No original recorded: the full written content still pins every
222
+ # hunk's after side to its exact position.
223
+ new_start = max(0, hunk["newStart"] - 1)
224
+ if written.splitlines()[new_start : new_start + len(after)] != after:
225
+ return None
226
+ elif reference is None:
227
+ before_text = "\n".join(before) + "\n"
228
+ after_text = "\n".join(after) + "\n" if after else ""
229
+ direct_match = direct_match and _matches_overlapping_replacement(
230
+ before_text,
231
+ after_text,
232
+ tool_input["old_string"],
233
+ tool_input["new_string"],
234
+ result["replaceAll"],
235
+ )
236
+ total += len(before) + len(after)
237
+ if total > MAX_HASHES_PER_CALL:
238
+ return None
239
+ edits.append(
240
+ {
241
+ "path": path,
242
+ "operation": "replace",
243
+ "before": line_fingerprints("\n".join(before) + "\n"),
244
+ "after": line_fingerprints("\n".join(after) + "\n") if after else [],
245
+ }
246
+ )
247
+ if reference is not None:
248
+ reconstructed.extend(reference[0][next_original_line:])
249
+ if reconstructed != reference[1]:
250
+ return None
251
+ if (
252
+ reference is None
253
+ and not write
254
+ and not direct_match
255
+ and not _matches_requested_changes(
256
+ hunks, tool_input["old_string"], tool_input["new_string"], result["replaceAll"]
257
+ )
258
+ ):
259
+ return None
260
+ return {"status": "captured", "edits": edits}
261
+
262
+
263
+ def _matches_overlapping_replacement(
264
+ before: str, after: str, old: str, new: str, replace_all: bool
265
+ ) -> bool:
266
+ """Check a full replacement or a clipped patch with consistent shared context."""
267
+ replacements = before.count(old) if replace_all else 1
268
+ if len(before) + replacements * (len(new) - len(old)) > 1_000_000:
269
+ return False
270
+ if old in before and before.replace(old, new, -1 if replace_all else 1) == after:
271
+ return True
272
+ if replace_all or len(old) + len(new) > 1_000_000:
273
+ return False
274
+ prefix = 0
275
+ while prefix < min(len(old), len(new)) and old[prefix] == new[prefix]:
276
+ prefix += 1
277
+ suffix = 0
278
+ while suffix < min(len(old), len(new)) - prefix and old[-suffix - 1] == new[-suffix - 1]:
279
+ suffix += 1
280
+ old_core = old[prefix : len(old) - suffix]
281
+ new_core = new[prefix : len(new) - suffix]
282
+ needle, haystack = (old_core, before) if old_core else (new_core, after)
283
+ if not needle:
284
+ return False
285
+ position = haystack.find(needle)
286
+ for _ in range(64):
287
+ if position < 0:
288
+ return False
289
+ start = position - prefix
290
+ if before[:position] + new_core + before[position + len(old_core) :] == after:
291
+ if all(
292
+ observed[max(0, start) : min(len(observed), start + len(requested))]
293
+ == requested[max(0, -start) : min(len(requested), len(observed) - start)]
294
+ for observed, requested in ((before, old), (after, new))
295
+ ):
296
+ return True
297
+ position = haystack.find(needle, position + 1)
298
+ return False
299
+
300
+
301
+ def _matches_requested_changes(hunks: list[dict], old: str, new: str, replace_all: bool) -> bool:
302
+ """Validate clipped hunks against request changes, positions and overlapping context.
303
+
304
+ Native patches can omit unchanged lines from a long replacement. Align each
305
+ changed group with the request diff; only the request's boundary lines may
306
+ have surrounding file text, and that surrounding text must remain unchanged.
307
+ """
308
+ old_lines, new_lines = old.splitlines(), new.splitlines()
309
+ if len(old) + len(new) > 1_000_000 or len(old_lines) + len(new_lines) > MAX_HASHES_PER_CALL:
310
+ return False
311
+ expected = [
312
+ op
313
+ for op in SequenceMatcher(None, old_lines, new_lines, autojunk=False).get_opcodes()
314
+ if op[0] != "equal"
315
+ ]
316
+ actual = []
317
+ for hunk in hunks:
318
+ old_pos, new_pos = hunk["oldStart"] - 1, max(0, hunk["newStart"] - 1)
319
+ group = None
320
+ for line in [*hunk["lines"], " "]:
321
+ if line[0] == " ":
322
+ if group is not None:
323
+ actual.append(group)
324
+ group = None
325
+ old_pos += 1
326
+ new_pos += 1
327
+ else:
328
+ if group is None:
329
+ group = [old_pos, new_pos, [], []]
330
+ if line[0] == "-":
331
+ group[2].append(line[1:])
332
+ old_pos += 1
333
+ else:
334
+ group[3].append(line[1:])
335
+ new_pos += 1
336
+ if (
337
+ not expected
338
+ or not actual
339
+ or len(actual) % len(expected)
340
+ or (not replace_all and len(actual) != len(expected))
341
+ ):
342
+ return False
343
+ for occurrence in range(len(actual) // len(expected)):
344
+ offsets = None
345
+ for group, (_, i1, i2, j1, j2) in zip(actual[occurrence * len(expected) :], expected, strict=False):
346
+ before, after = "\n".join(group[2]), "\n".join(group[3])
347
+ wanted_before, wanted_after = "\n".join(old_lines[i1:i2]), "\n".join(new_lines[j1:j2])
348
+ position = before.find(wanted_before)
349
+ matched = False
350
+ while position >= 0:
351
+ prefix, suffix = before[:position], before[position + len(wanted_before) :]
352
+ if (
353
+ (not prefix or (i1 == j1 == 0 and "\n" not in prefix))
354
+ and (
355
+ not suffix
356
+ or (
357
+ i2 == len(old_lines)
358
+ and j2 == len(new_lines)
359
+ and not old.endswith("\n")
360
+ and not new.endswith("\n")
361
+ and "\n" not in suffix
362
+ )
363
+ )
364
+ and after == prefix + wanted_after + suffix
365
+ and (wanted_before or not before)
366
+ ):
367
+ matched = True
368
+ break
369
+ position = before.find(wanted_before, position + 1) if wanted_before else -1
370
+ alignment = (group[0] - i1, group[1] - j1)
371
+ if not matched or (offsets is not None and alignment != offsets):
372
+ return False
373
+ offsets = alignment
374
+ if offsets[1] - offsets[0] != occurrence * (new.count("\n") - old.count("\n")):
375
+ return False
376
+ for hunk in hunks:
377
+ for marker, start, offset, lines, text in (
378
+ ("+", hunk["oldStart"] - 1, offsets[0], old_lines, old),
379
+ ("-", max(0, hunk["newStart"] - 1), offsets[1], new_lines, new),
380
+ ):
381
+ visible = [line[1:] for line in hunk["lines"] if line[0] != marker]
382
+ for index, line in enumerate(visible):
383
+ relative = start + index - offset
384
+ if not 0 <= relative < len(lines):
385
+ continue
386
+ expected_line = lines[relative]
387
+ if relative == 0 and relative == len(lines) - 1 and not text.endswith("\n"):
388
+ valid = expected_line in line
389
+ elif relative == 0:
390
+ valid = line.endswith(expected_line)
391
+ elif relative == len(lines) - 1 and not text.endswith("\n"):
392
+ valid = line.startswith(expected_line)
393
+ else:
394
+ valid = line == expected_line
395
+ if not valid:
396
+ return False
397
+ return True