codetalker-mcp 0.3.1__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.
codetalker/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ CodeTalker: Cross-harness agent conversation transcript normalizer and MCP server.
3
+ """
4
+
5
+ __version__ = "0.3.1"
@@ -0,0 +1,432 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Sequence
5
+
6
+ from codetalker.schema import (
7
+ ActorRole,
8
+ BlockType,
9
+ BranchDiff,
10
+ BranchSummary,
11
+ ConversationBranchTree,
12
+ ForkPoint,
13
+ NormalizedSession,
14
+ NormalizedStep,
15
+ StepPagination,
16
+ TextBlock,
17
+ ThinkingBlock,
18
+ ToolResultBlock,
19
+ )
20
+ from codetalker.utils.timestamps import timestamp_gte, timestamp_lte
21
+
22
+
23
+ class BaseAdapter(ABC):
24
+ """Abstract base class for all harness adapters."""
25
+
26
+ harness_name: str
27
+
28
+ @abstractmethod
29
+ def discover_sessions(
30
+ self,
31
+ root_path: str | None = None,
32
+ ) -> list[NormalizedSession]:
33
+ """Discover and return session shells (metadata only, without full steps)."""
34
+ pass
35
+
36
+ @abstractmethod
37
+ def load_steps(
38
+ self,
39
+ session: NormalizedSession,
40
+ since: str | None = None,
41
+ until: str | None = None,
42
+ since_last_user_input: bool = False,
43
+ include_step_types: list[BlockType] | None = None,
44
+ include_actor_roles: list[ActorRole] | None = None,
45
+ exclude_actor_roles: list[ActorRole] | None = None,
46
+ include_thinking: bool = True,
47
+ include_raw_data: bool = False,
48
+ max_step_chars: int | None = None,
49
+ offset: int = 0,
50
+ from_end: bool = False,
51
+ limit: int | None = None,
52
+ ) -> list[NormalizedStep]:
53
+ """Load and normalize steps for a given session with filtering applied."""
54
+ pass
55
+
56
+ def count_steps(self, session: NormalizedSession) -> int:
57
+ """Return total step count for a session (may use a cheap peek when available)."""
58
+ return len(
59
+ self.load_steps(
60
+ session=session,
61
+ include_raw_data=False,
62
+ include_thinking=True,
63
+ )
64
+ )
65
+
66
+ def load_steps_paginated(
67
+ self,
68
+ session: NormalizedSession,
69
+ since: str | None = None,
70
+ until: str | None = None,
71
+ since_last_user_input: bool = False,
72
+ include_step_types: list[BlockType] | None = None,
73
+ include_actor_roles: list[ActorRole] | None = None,
74
+ exclude_actor_roles: list[ActorRole] | None = None,
75
+ include_thinking: bool = True,
76
+ include_raw_data: bool = False,
77
+ max_step_chars: int | None = None,
78
+ offset: int = 0,
79
+ from_end: bool = False,
80
+ limit: int | None = None,
81
+ ) -> tuple[list[NormalizedStep], StepPagination]:
82
+ """Load steps and return pagination metadata for the applied slice."""
83
+ all_steps = self.load_steps(
84
+ session=session,
85
+ since=since,
86
+ until=until,
87
+ since_last_user_input=since_last_user_input,
88
+ include_step_types=include_step_types,
89
+ include_actor_roles=include_actor_roles,
90
+ exclude_actor_roles=exclude_actor_roles,
91
+ include_thinking=include_thinking,
92
+ include_raw_data=include_raw_data,
93
+ max_step_chars=max_step_chars,
94
+ offset=0,
95
+ from_end=False,
96
+ limit=None,
97
+ )
98
+ return self.paginate_steps(
99
+ all_steps, offset=offset, from_end=from_end, limit=limit
100
+ )
101
+
102
+ @staticmethod
103
+ def paginate_steps(
104
+ steps: Sequence[NormalizedStep],
105
+ offset: int = 0,
106
+ from_end: bool = False,
107
+ limit: int | None = None,
108
+ ) -> tuple[list[NormalizedStep], StepPagination]:
109
+ total = len(steps)
110
+ offset = max(0, offset)
111
+ if total == 0:
112
+ return [], StepPagination(
113
+ offset=offset,
114
+ limit=limit,
115
+ from_end=from_end,
116
+ returned_step_count=0,
117
+ total_steps_available=0,
118
+ has_more_before=False,
119
+ has_more_after=False,
120
+ next_offset=None,
121
+ )
122
+
123
+ if from_end:
124
+ end_exclusive = max(0, total - offset)
125
+ if limit is not None and limit > 0:
126
+ start = max(0, end_exclusive - limit)
127
+ else:
128
+ start = 0
129
+ sliced = list(steps[start:end_exclusive])
130
+ has_more_before = start > 0
131
+ has_more_after = offset > 0
132
+ next_offset = offset + len(sliced) if has_more_before and sliced else None
133
+ start_idx = sliced[0].step_index if sliced else None
134
+ end_idx = sliced[-1].step_index if sliced else None
135
+ else:
136
+ start = min(offset, total)
137
+ if limit is not None and limit > 0:
138
+ sliced = list(steps[start : start + limit])
139
+ else:
140
+ sliced = list(steps[start:])
141
+ end_exclusive = start + len(sliced)
142
+ has_more_before = start > 0
143
+ has_more_after = end_exclusive < total
144
+ next_offset = end_exclusive if has_more_after else None
145
+ start_idx = sliced[0].step_index if sliced else None
146
+ end_idx = sliced[-1].step_index if sliced else None
147
+
148
+ return sliced, StepPagination(
149
+ offset=offset,
150
+ limit=limit,
151
+ from_end=from_end,
152
+ returned_step_count=len(sliced),
153
+ total_steps_available=total,
154
+ has_more_before=has_more_before,
155
+ has_more_after=has_more_after,
156
+ next_offset=next_offset,
157
+ start_step_index=start_idx,
158
+ end_step_index=end_idx,
159
+ )
160
+
161
+ def get_branch_tree(
162
+ self,
163
+ conversation_id: str,
164
+ root_path: str | None = None,
165
+ ) -> ConversationBranchTree | None:
166
+ """Construct the branch/DAG tree for a given conversation ID."""
167
+ sessions = self.discover_sessions(root_path=root_path)
168
+ matching_branches = [
169
+ s
170
+ for s in sessions
171
+ if s.conversation_id == conversation_id or s.session_id == conversation_id
172
+ ]
173
+ if not matching_branches:
174
+ return None
175
+
176
+ active_branch = next(
177
+ (s for s in matching_branches if s.branch_root_step_id is None),
178
+ matching_branches[0],
179
+ )
180
+
181
+ display_name = active_branch.display_name or f"{self.harness_name} {conversation_id[:8]}"
182
+ branches_summary: list[BranchSummary] = []
183
+ fork_map: dict[str, list[str]] = {}
184
+ child_subagents: list[str] = []
185
+
186
+ for s in matching_branches:
187
+ is_active = s.session_id == active_branch.session_id
188
+ branches_summary.append(
189
+ BranchSummary(
190
+ branch_id=s.session_id,
191
+ branch_label=s.branch_label or ("Main Thread" if is_active else "Branch"),
192
+ divergence_step_id=s.branch_root_step_id,
193
+ leaf_step_id=s.active_node_id or s.session_id,
194
+ step_count=s.step_count,
195
+ user_turn_count=s.user_turn_count,
196
+ assistant_turn_count=s.assistant_turn_count,
197
+ model=s.model,
198
+ is_active_path=is_active,
199
+ started_at=s.started_at,
200
+ last_activity=s.last_activity,
201
+ )
202
+ )
203
+ if s.branch_root_step_id:
204
+ fork_map.setdefault(s.branch_root_step_id, []).append(s.session_id)
205
+ if s.child_session_ids:
206
+ child_subagents.extend(s.child_session_ids)
207
+
208
+ fork_points: list[ForkPoint] = []
209
+ for step_id, b_ids in fork_map.items():
210
+ fork_points.append(
211
+ ForkPoint(
212
+ step_id=step_id,
213
+ variant_count=len(b_ids) + 1,
214
+ branch_ids=b_ids,
215
+ )
216
+ )
217
+
218
+ return ConversationBranchTree(
219
+ conversation_id=conversation_id,
220
+ harness=self.harness_name,
221
+ display_name=display_name,
222
+ active_branch_id=active_branch.session_id,
223
+ branch_count=len(matching_branches),
224
+ branches=branches_summary,
225
+ fork_points=fork_points,
226
+ child_subagent_sessions=list(set(child_subagents)),
227
+ has_dag=len(matching_branches) > 1 or any(s.has_dag for s in matching_branches),
228
+ )
229
+
230
+ def diff_branches(
231
+ self,
232
+ conversation_id: str,
233
+ branch_a: str,
234
+ branch_b: str,
235
+ root_path: str | None = None,
236
+ summary_only: bool = True,
237
+ include_raw_data: bool = False,
238
+ limit_per_branch: int = 20,
239
+ from_end: bool = True,
240
+ ) -> BranchDiff | None:
241
+ """Compute step divergence between two branches of a conversation."""
242
+ sessions = self.discover_sessions(root_path=root_path)
243
+ matching_map = {
244
+ s.session_id: s
245
+ for s in sessions
246
+ if s.conversation_id == conversation_id or s.session_id == conversation_id
247
+ }
248
+
249
+ sess_a = matching_map.get(branch_a)
250
+ sess_b = matching_map.get(branch_b)
251
+
252
+ if not sess_a:
253
+ sess_a = next((s for s in sessions if s.session_id == branch_a), None)
254
+ if not sess_b:
255
+ sess_b = next((s for s in sessions if s.session_id == branch_b), None)
256
+
257
+ if not sess_a or not sess_b:
258
+ return None
259
+
260
+ steps_a = self.load_steps(sess_a, include_raw_data=include_raw_data)
261
+ steps_b = self.load_steps(sess_b, include_raw_data=include_raw_data)
262
+
263
+ common_len = 0
264
+ min_len = min(len(steps_a), len(steps_b))
265
+
266
+ for i in range(min_len):
267
+ sa = steps_a[i]
268
+ sb = steps_b[i]
269
+ if sa.branch and sb.branch and sa.branch.step_id == sb.branch.step_id:
270
+ common_len += 1
271
+ elif (
272
+ sa.actor.role == sb.actor.role
273
+ and len(sa.blocks) == len(sb.blocks)
274
+ and sa.model_dump(exclude={"timestamp", "branch", "raw_data"})
275
+ == sb.model_dump(exclude={"timestamp", "branch", "raw_data"})
276
+ ):
277
+ common_len += 1
278
+ else:
279
+ break
280
+
281
+ common_steps = steps_a[:common_len]
282
+ distinct_a = steps_a[common_len:]
283
+ distinct_b = steps_b[common_len:]
284
+
285
+ last_common = common_steps[-1] if common_steps else None
286
+ divergence_step_id = last_common.branch.step_id if last_common and last_common.branch else None
287
+ divergence_index = common_len - 1 if common_len > 0 else None
288
+
289
+ if summary_only:
290
+ common_steps = []
291
+ if from_end and limit_per_branch > 0:
292
+ distinct_a = distinct_a[-limit_per_branch:]
293
+ distinct_b = distinct_b[-limit_per_branch:]
294
+ elif limit_per_branch > 0:
295
+ distinct_a = distinct_a[:limit_per_branch]
296
+ distinct_b = distinct_b[:limit_per_branch]
297
+ else:
298
+ if from_end and limit_per_branch > 0:
299
+ distinct_a = distinct_a[-limit_per_branch:]
300
+ distinct_b = distinct_b[-limit_per_branch:]
301
+ elif limit_per_branch > 0:
302
+ distinct_a = distinct_a[:limit_per_branch]
303
+ distinct_b = distinct_b[:limit_per_branch]
304
+
305
+ return BranchDiff(
306
+ conversation_id=conversation_id,
307
+ harness=self.harness_name,
308
+ branch_a_id=branch_a,
309
+ branch_b_id=branch_b,
310
+ divergence_step_id=divergence_step_id,
311
+ divergence_step_index=divergence_index,
312
+ common_step_count=common_len,
313
+ branch_a_distinct_step_count=len(steps_a) - common_len,
314
+ branch_b_distinct_step_count=len(steps_b) - common_len,
315
+ common_steps=common_steps,
316
+ branch_a_distinct_steps=distinct_a,
317
+ branch_b_distinct_steps=distinct_b,
318
+ summary_only=summary_only,
319
+ )
320
+
321
+ @staticmethod
322
+ def _truncate_block_text(text: str, max_chars: int) -> tuple[str, bool]:
323
+ if len(text) <= max_chars:
324
+ return text, False
325
+ if max_chars <= 1:
326
+ return "…", True
327
+ return text[: max_chars - 1] + "…", True
328
+
329
+ @staticmethod
330
+ def filter_normalized_steps(
331
+ steps: Sequence[NormalizedStep],
332
+ since: str | None = None,
333
+ until: str | None = None,
334
+ since_last_user_input: bool = False,
335
+ include_step_types: list[BlockType] | None = None,
336
+ include_actor_roles: list[ActorRole] | None = None,
337
+ exclude_actor_roles: list[ActorRole] | None = None,
338
+ include_thinking: bool = True,
339
+ include_raw_data: bool = False,
340
+ max_step_chars: int | None = None,
341
+ offset: int = 0,
342
+ from_end: bool = False,
343
+ limit: int | None = None,
344
+ ) -> list[NormalizedStep]:
345
+ """Standard filter implementation for a list of normalized steps."""
346
+ filtered: list[NormalizedStep] = list(steps)
347
+
348
+ if since_last_user_input:
349
+ last_user_idx = -1
350
+ for idx, step in enumerate(filtered):
351
+ if step.actor.role == ActorRole.USER:
352
+ last_user_idx = idx
353
+ if last_user_idx != -1:
354
+ filtered = filtered[last_user_idx:]
355
+
356
+ if since is not None:
357
+ filtered = [
358
+ s for s in filtered if s.timestamp is None or timestamp_gte(s.timestamp, since)
359
+ ]
360
+ if until is not None:
361
+ filtered = [
362
+ s for s in filtered if s.timestamp is None or timestamp_lte(s.timestamp, until)
363
+ ]
364
+
365
+ if include_actor_roles is not None:
366
+ role_set = set(include_actor_roles)
367
+ filtered = [s for s in filtered if s.actor.role in role_set]
368
+
369
+ if exclude_actor_roles is not None:
370
+ excluded = set(exclude_actor_roles)
371
+ filtered = [s for s in filtered if s.actor.role not in excluded]
372
+
373
+ result: list[NormalizedStep] = []
374
+ for step in filtered:
375
+ step_blocks = list(step.blocks)
376
+
377
+ if not include_thinking:
378
+ step_blocks = [b for b in step_blocks if b.type != BlockType.THINKING]
379
+
380
+ if include_step_types is not None:
381
+ type_set = set(include_step_types)
382
+ step_blocks = [b for b in step_blocks if b.type in type_set]
383
+
384
+ if max_step_chars is not None and max_step_chars > 0:
385
+ clipped_blocks = []
386
+ for block in step_blocks:
387
+ if isinstance(block, (TextBlock, ThinkingBlock)):
388
+ new_text, truncated = BaseAdapter._truncate_block_text(
389
+ block.text, max_step_chars
390
+ )
391
+ clipped_blocks.append(
392
+ block.model_copy(
393
+ update={"text": new_text, "is_truncated": truncated or block.is_truncated}
394
+ )
395
+ )
396
+ elif isinstance(block, ToolResultBlock):
397
+ new_content, truncated = BaseAdapter._truncate_block_text(
398
+ block.content, max_step_chars
399
+ )
400
+ clipped_blocks.append(
401
+ block.model_copy(
402
+ update={
403
+ "content": new_content,
404
+ "is_truncated": truncated or block.is_truncated,
405
+ }
406
+ )
407
+ )
408
+ else:
409
+ clipped_blocks.append(block)
410
+ step_blocks = clipped_blocks
411
+
412
+ if step.blocks and not step_blocks:
413
+ continue
414
+
415
+ has_sig = any(
416
+ isinstance(b, ThinkingBlock) and b.has_signature for b in step_blocks
417
+ )
418
+ raw = step.raw_data if (include_raw_data or has_sig) else {}
419
+
420
+ result.append(
421
+ step.model_copy(
422
+ update={
423
+ "blocks": step_blocks,
424
+ "raw_data": raw,
425
+ }
426
+ )
427
+ )
428
+
429
+ sliced, _ = BaseAdapter.paginate_steps(
430
+ result, offset=offset, from_end=from_end, limit=limit
431
+ )
432
+ return sliced
@@ -0,0 +1,76 @@
1
+ from codetalker.adapters.aider import AiderAdapter
2
+ from codetalker.adapters.antigravity import AntigravityAdapter
3
+ from codetalker.adapters.chatgpt import ChatGPTAdapter
4
+ from codetalker.adapters.claude import ClaudeCodeAdapter
5
+ from codetalker.adapters.copilot import GitHubCopilotAdapter
6
+ from codetalker.adapters.cursor import CursorAdapter
7
+ from codetalker.adapters.freebuff import FreebuffAdapter
8
+ from codetalker.adapters.opencode import OpenCodeAdapter
9
+ from codetalker.adapters.windsurf import WindsurfAdapter
10
+ from codetalker.registry import registry
11
+
12
+ # 1. ChatGPT / Codex adapter
13
+ registry.register(
14
+ ChatGPTAdapter(),
15
+ aliases=["codex", "chatgpt_desktop", "chatgpt.exe", "openai", "openai_codex", "codex_cli", "chat_gpt"],
16
+ )
17
+
18
+ # 2. Devin / Windsurf adapter
19
+ registry.register(
20
+ WindsurfAdapter(),
21
+ aliases=["devin", "codeium", "windsurf_ide", "windsurf-ide", "windsurf"],
22
+ )
23
+
24
+ # 3. Antigravity adapter
25
+ registry.register(
26
+ AntigravityAdapter(),
27
+ aliases=["agy", "gemini", "google_antigravity", "antigravity_ide", "antigravity-ide", "google-antigravity", "google_antigravity_ide"],
28
+ )
29
+
30
+ # 4. Cursor adapter
31
+ registry.register(
32
+ CursorAdapter(),
33
+ aliases=["cursor_ide", "cursor-ide", "anysphere"],
34
+ )
35
+
36
+ # 5. Claude Code adapter
37
+ registry.register(
38
+ ClaudeCodeAdapter(),
39
+ aliases=["claudecode", "claude_code", "claude-code", "anthropic", "claude"],
40
+ )
41
+
42
+ # 6. Aider adapter
43
+ registry.register(
44
+ AiderAdapter(),
45
+ aliases=["aider_chat", "aider-chat"],
46
+ )
47
+
48
+ # 7. GitHub Copilot adapter
49
+ registry.register(
50
+ GitHubCopilotAdapter(),
51
+ aliases=["github_copilot", "copilot_chat", "vscode_copilot", "github-copilot"],
52
+ )
53
+
54
+ # 8. Freebuff / Codebuff adapter
55
+ registry.register(
56
+ FreebuffAdapter(),
57
+ aliases=["codebuff", "freebuff_desktop", "freebuff-desktop", "free_buff"],
58
+ )
59
+
60
+ # 9. OpenCode adapter
61
+ registry.register(
62
+ OpenCodeAdapter(),
63
+ aliases=["open_code", "opencode_desktop", "opencode-ai", "open-code"],
64
+ )
65
+
66
+ __all__ = [
67
+ "ChatGPTAdapter",
68
+ "WindsurfAdapter",
69
+ "AntigravityAdapter",
70
+ "CursorAdapter",
71
+ "ClaudeCodeAdapter",
72
+ "AiderAdapter",
73
+ "GitHubCopilotAdapter",
74
+ "FreebuffAdapter",
75
+ "OpenCodeAdapter",
76
+ ]