agent-decoder 0.3.2__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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
clau_decode/parser.py ADDED
@@ -0,0 +1,466 @@
1
+ """JSONL parser — reads a session file and returns structured messages.
2
+
3
+ Contract (for Agent 1 to implement):
4
+ parse_session(path: Path) -> tuple[Session, list[Message]]
5
+ - Read every line of the JSONL file
6
+ - Build Session metadata from non-message records (custom-title, permission-mode, etc.)
7
+ - Build Message objects from user/assistant records
8
+ - Return (session, messages) — messages are flat, NOT yet threaded
9
+
10
+ build_message_tree(messages: list[Message]) -> list[MessageTree]
11
+ - Walk parentUuid chains to build parent→child tree
12
+ - Sidechain messages (isSidechain=True) are children of their parent
13
+ - Root messages (parentId=None) are top-level entries
14
+ - Preserves chronological order within siblings
15
+
16
+ SOLID notes:
17
+ - Single Responsibility: this module only parses; it does not write to DB
18
+ - Open/Closed: add new record type handlers without changing existing ones
19
+ - The _RECORD_HANDLERS dict is the extension point
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from .models import (
31
+ ContentBlock,
32
+ ImageBlock,
33
+ Message,
34
+ MessageTree,
35
+ Session,
36
+ TextBlock,
37
+ ThinkingBlock,
38
+ ToolResultBlock,
39
+ ToolUseBlock,
40
+ TokenUsage,
41
+ )
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # UUID validation pattern
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Shared title inference helper
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _infer_title_from_messages(messages: list[Message]) -> str | None:
56
+ """Infer a session title from the first non-meta user TextBlock.
57
+
58
+ Strips system XML tags, takes the first non-empty line, returns it or None
59
+ if no suitable text is found. Used by both the Claude parser and the
60
+ CodexAdapter so title logic stays in one place.
61
+
62
+ The tag-name class allows underscores (``[a-z0-9_-]``) so wrapper tags like
63
+ Codex's ``<environment_context>…</environment_context>`` — which is the
64
+ entire first user message in real Codex rollouts — get stripped; otherwise
65
+ every Codex session's title would be the literal ``<environment_context>``.
66
+ """
67
+ for m in messages:
68
+ if m.role == "user" and not m.is_meta:
69
+ for block in m.content_blocks:
70
+ if isinstance(block, TextBlock) and block.text.strip():
71
+ text = re.sub(
72
+ r"<[a-z][a-z0-9_-]*>[\s\S]*?</[a-z][a-z0-9_-]*>",
73
+ "",
74
+ block.text,
75
+ ).strip()
76
+ text = text.splitlines()[0].strip() if text else ""
77
+ if text:
78
+ return text
79
+ return None
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Public API
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def parse_session(path: Path) -> tuple[Session, list[Message]]:
88
+ """Parse a JSONL session file into a Session + flat message list.
89
+
90
+ Session id resolution order:
91
+ 1. If the filename stem is a valid UUID, use it.
92
+ 2. Otherwise scan the file content for a sessionId field.
93
+ Raise ValueError if no valid UUID can be found.
94
+
95
+ Raises:
96
+ FileNotFoundError: if path does not exist
97
+ ValueError: if no valid UUID session ID is found
98
+ """
99
+ if not path.exists():
100
+ raise FileNotFoundError(f"Session file not found: {path}")
101
+
102
+ # Derive session id: prefer filename UUID, fall back to content
103
+ try:
104
+ session_id = _derive_session_id(path)
105
+ except ValueError:
106
+ session_id = _session_id_from_content(path)
107
+
108
+ project_id = _unmangle_project_id(path.parent.name)
109
+
110
+ session = Session(
111
+ id=session_id,
112
+ project_id=project_id,
113
+ file_path=str(path),
114
+ )
115
+
116
+ messages: list[Message] = []
117
+ cwd_set = False
118
+ git_branch_set = False
119
+ model_set = False
120
+
121
+ with path.open(encoding="utf-8") as fh:
122
+ for raw_line in fh:
123
+ raw_line = raw_line.strip()
124
+ if not raw_line:
125
+ continue
126
+ try:
127
+ record = json.loads(raw_line)
128
+ except json.JSONDecodeError:
129
+ continue
130
+
131
+ record_type = record.get("type")
132
+
133
+ # --- metadata records ---
134
+ if record_type == "permission-mode":
135
+ session.permission_mode = record.get("permissionMode")
136
+ continue
137
+
138
+ if record_type == "custom-title":
139
+ session.title = record.get("customTitle")
140
+ continue
141
+
142
+ if record_type == "worktree-state":
143
+ session.is_worktree = True
144
+ continue
145
+
146
+ if record_type in ("clau-decode-fork", "clau-decode-backup"):
147
+ session.is_fork = True
148
+ continue
149
+
150
+ # --- mid-stream queued commands ---
151
+ # When the user types a message while the assistant is working, the session
152
+ # stores it as an "attachment" record with type "queued_command".
153
+ # We surface it as a regular user message so it appears in the thread.
154
+ if record_type == "attachment":
155
+ attachment = record.get("attachment", {})
156
+ if attachment.get("type") == "queued_command":
157
+ prompt = attachment.get("prompt", "")
158
+ # prompt is either a plain string or a list of content blocks
159
+ if isinstance(prompt, str):
160
+ blocks_qa = [TextBlock(text=prompt)] if prompt else []
161
+ elif isinstance(prompt, list):
162
+ blocks_qa = _parse_content_blocks(prompt)
163
+ else:
164
+ blocks_qa = []
165
+ if blocks_qa:
166
+ uuid = record.get("uuid", "")
167
+ parent_uuid = record.get("parentUuid")
168
+ timestamp_str = record.get("timestamp")
169
+ timestamp_qa: datetime | None = None
170
+ if timestamp_str:
171
+ try:
172
+ ts = timestamp_str
173
+ if ts.endswith("Z"):
174
+ ts = ts[:-1] + "+00:00"
175
+ timestamp_qa = datetime.fromisoformat(ts)
176
+ except ValueError:
177
+ pass
178
+ msg = Message(
179
+ id=uuid,
180
+ session_id=session_id,
181
+ parent_id=parent_uuid,
182
+ role="user",
183
+ content_blocks=blocks_qa,
184
+ timestamp=timestamp_qa,
185
+ is_sidechain=bool(record.get("isSidechain", False)),
186
+ is_meta=False,
187
+ cwd=record.get("cwd"),
188
+ git_branch=record.get("gitBranch"),
189
+ )
190
+ messages.append(msg)
191
+ if timestamp_qa:
192
+ if (
193
+ session.started_at is None
194
+ or timestamp_qa < session.started_at
195
+ ):
196
+ session.started_at = timestamp_qa
197
+ if (
198
+ session.updated_at is None
199
+ or timestamp_qa > session.updated_at
200
+ ):
201
+ session.updated_at = timestamp_qa
202
+ continue
203
+
204
+ # --- message records ---
205
+ if record_type not in ("user", "assistant"):
206
+ continue
207
+
208
+ is_meta = bool(record.get("isMeta", False))
209
+ is_sidechain = bool(record.get("isSidechain", False))
210
+ uuid = record.get("uuid", "")
211
+ parent_uuid = record.get("parentUuid")
212
+ timestamp_str = record.get("timestamp")
213
+ timestamp: datetime | None = None
214
+ if timestamp_str:
215
+ # Handle trailing Z or offset
216
+ try:
217
+ ts = timestamp_str
218
+ if ts.endswith("Z"):
219
+ ts = ts[:-1] + "+00:00"
220
+ timestamp = datetime.fromisoformat(ts)
221
+ except ValueError:
222
+ pass
223
+
224
+ msg_cwd = record.get("cwd")
225
+ msg_git_branch = record.get("gitBranch")
226
+ request_id = record.get("requestId")
227
+ source_tool_uuid = record.get("sourceToolAssistantUUID")
228
+
229
+ # Collect cwd / git_branch from first record that has them
230
+ if not cwd_set and msg_cwd:
231
+ session.cwd = msg_cwd
232
+ cwd_set = True
233
+ if not git_branch_set and msg_git_branch:
234
+ session.git_branch = msg_git_branch
235
+ git_branch_set = True
236
+
237
+ # Parse content
238
+ if record_type == "assistant":
239
+ msg_record = record.get("message", {})
240
+ raw_content = msg_record.get("content", [])
241
+ msg_model = msg_record.get("model")
242
+ if not model_set and msg_model:
243
+ session.model = msg_model
244
+ model_set = True
245
+ content_blocks = _parse_content_blocks(raw_content)
246
+ raw_usage = msg_record.get("usage")
247
+ msg_usage: TokenUsage | None = None
248
+ if isinstance(raw_usage, dict):
249
+ msg_usage = TokenUsage(
250
+ input_tokens=raw_usage.get("input_tokens", 0),
251
+ output_tokens=raw_usage.get("output_tokens", 0),
252
+ cache_creation_input_tokens=raw_usage.get(
253
+ "cache_creation_input_tokens", 0
254
+ ),
255
+ cache_read_input_tokens=raw_usage.get(
256
+ "cache_read_input_tokens", 0
257
+ ),
258
+ )
259
+ msg = Message(
260
+ id=uuid,
261
+ session_id=session_id,
262
+ parent_id=parent_uuid,
263
+ role="assistant",
264
+ content_blocks=content_blocks,
265
+ timestamp=timestamp,
266
+ model=msg_model,
267
+ is_sidechain=is_sidechain,
268
+ is_meta=is_meta,
269
+ cwd=msg_cwd,
270
+ git_branch=msg_git_branch,
271
+ request_id=request_id,
272
+ source_tool_assistant_uuid=source_tool_uuid,
273
+ usage=msg_usage,
274
+ )
275
+ else:
276
+ # user record
277
+ msg_record = record.get("message", {})
278
+ raw_content = msg_record.get("content", [])
279
+ content_blocks = _parse_content_blocks(raw_content)
280
+ msg = Message(
281
+ id=uuid,
282
+ session_id=session_id,
283
+ parent_id=parent_uuid,
284
+ role="user",
285
+ content_blocks=content_blocks,
286
+ timestamp=timestamp,
287
+ is_sidechain=is_sidechain,
288
+ is_meta=is_meta,
289
+ cwd=msg_cwd,
290
+ git_branch=msg_git_branch,
291
+ request_id=request_id,
292
+ source_tool_assistant_uuid=source_tool_uuid,
293
+ )
294
+
295
+ messages.append(msg)
296
+
297
+ # Track session timestamps
298
+ if timestamp:
299
+ if session.started_at is None or timestamp < session.started_at:
300
+ session.started_at = timestamp
301
+ if session.updated_at is None or timestamp > session.updated_at:
302
+ session.updated_at = timestamp
303
+
304
+ session.message_count = len(messages)
305
+ session.user_message_count = sum(
306
+ 1 for m in messages if m.role == "user" and not m.is_meta
307
+ )
308
+
309
+ # Infer title from first user message when no custom-title record exists
310
+ if session.title is None:
311
+ session.title = _infer_title_from_messages(messages)
312
+
313
+ return session, messages
314
+
315
+
316
+ def build_message_tree(messages: list[Message]) -> list[MessageTree]:
317
+ """Convert a flat message list into a parent→child tree.
318
+
319
+ Root messages (parent_id=None, is_sidechain=False) are returned as the
320
+ top-level list. Sidechain branches hang off their parent as children.
321
+ """
322
+ # Build node map
323
+ nodes: dict[str, MessageTree] = {m.id: MessageTree(message=m) for m in messages}
324
+
325
+ roots: list[MessageTree] = []
326
+
327
+ for m in messages:
328
+ node = nodes[m.id]
329
+
330
+ if m.is_sidechain:
331
+ # Sidechain: always attach to parent
332
+ if m.parent_id and m.parent_id in nodes:
333
+ nodes[m.parent_id].children.append(node)
334
+ # If parent not found, treat as orphan root (edge case)
335
+ else:
336
+ roots.append(node)
337
+ elif m.parent_id is None:
338
+ # True root: no parent, not sidechain
339
+ roots.append(node)
340
+ else:
341
+ # Normal child message with a parent
342
+ if m.parent_id in nodes:
343
+ nodes[m.parent_id].children.append(node)
344
+ else:
345
+ # Orphaned non-root: add to roots as fallback
346
+ roots.append(node)
347
+
348
+ # Sort children by timestamp within each node
349
+ def _sort_node(node: MessageTree) -> None:
350
+ node.children.sort(
351
+ key=lambda n: n.message.timestamp or datetime.min.replace(tzinfo=None)
352
+ )
353
+ for child in node.children:
354
+ _sort_node(child)
355
+
356
+ for root in roots:
357
+ _sort_node(root)
358
+
359
+ # Sort roots by timestamp
360
+ roots.sort(key=lambda n: n.message.timestamp or datetime.min.replace(tzinfo=None))
361
+
362
+ return roots
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Internal helpers — implement these, tests cover them individually
367
+ # ---------------------------------------------------------------------------
368
+
369
+
370
+ def _session_id_from_content(path: Path) -> str:
371
+ """Scan the first few lines of a JSONL file for a valid UUID sessionId.
372
+
373
+ Raises ValueError if none is found.
374
+ """
375
+ with path.open(encoding="utf-8") as fh:
376
+ for raw_line in fh:
377
+ raw_line = raw_line.strip()
378
+ if not raw_line:
379
+ continue
380
+ try:
381
+ record = json.loads(raw_line)
382
+ except json.JSONDecodeError:
383
+ continue
384
+ session_id = record.get("sessionId", "")
385
+ if session_id and _UUID_RE.match(str(session_id)):
386
+ return str(session_id)
387
+ raise ValueError(
388
+ f"No valid UUID sessionId found in '{path}'. "
389
+ "This does not appear to be a valid session file."
390
+ )
391
+
392
+
393
+ def _parse_content_blocks(raw_content: Any) -> list[ContentBlock]:
394
+ """Convert raw message.content (str or list) into typed ContentBlock list."""
395
+ if isinstance(raw_content, str):
396
+ return [TextBlock(text=raw_content)]
397
+
398
+ if not isinstance(raw_content, list):
399
+ return []
400
+
401
+ blocks: list[ContentBlock] = []
402
+ for item in raw_content:
403
+ if not isinstance(item, dict):
404
+ continue
405
+ block_type = item.get("type")
406
+ if block_type == "text":
407
+ blocks.append(TextBlock(text=item.get("text", "")))
408
+ elif block_type == "thinking":
409
+ blocks.append(
410
+ ThinkingBlock(
411
+ thinking=item.get("thinking", ""),
412
+ signature=item.get("signature"),
413
+ )
414
+ )
415
+ elif block_type == "tool_use":
416
+ blocks.append(
417
+ ToolUseBlock(
418
+ id=item.get("id", ""),
419
+ name=item.get("name", ""),
420
+ input=item.get("input", {}),
421
+ )
422
+ )
423
+ elif block_type == "tool_result":
424
+ blocks.append(
425
+ ToolResultBlock(
426
+ tool_use_id=item.get("tool_use_id", ""),
427
+ content=item.get("content"),
428
+ is_error=item.get("is_error", False),
429
+ )
430
+ )
431
+ elif block_type == "image":
432
+ blocks.append(ImageBlock(source=item.get("source", {})))
433
+ # Unknown types are silently skipped
434
+
435
+ return blocks
436
+
437
+
438
+ def _derive_session_id(path: Path) -> str:
439
+ """Extract UUID string from filename, raise ValueError if not valid."""
440
+ stem = path.stem # filename without extension
441
+ if not _UUID_RE.match(stem):
442
+ raise ValueError(
443
+ f"Filename '{path.name}' is not a valid UUID session file. "
444
+ f"Expected pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.jsonl"
445
+ )
446
+ return stem
447
+
448
+
449
+ def _unmangle_project_id(directory_name: str) -> str:
450
+ """Convert directory name like '-Volumes-ExternalDrive-Work-foo' → 'Volumes/ExternalDrive/Work/foo'.
451
+
452
+ Rules:
453
+ - Strip the leading '-'
454
+ - Double hyphens '--' become a literal '-' in the path
455
+ - Single hyphens '-' become '/'
456
+ """
457
+ if directory_name.startswith("-"):
458
+ directory_name = directory_name[1:]
459
+
460
+ # Replace double hyphens with a placeholder, convert single hyphens to /,
461
+ # then restore the placeholder as literal hyphens.
462
+ _PLACEHOLDER = "\x00"
463
+ result = directory_name.replace("--", _PLACEHOLDER)
464
+ result = result.replace("-", "/")
465
+ result = result.replace(_PLACEHOLDER, "-")
466
+ return result
@@ -0,0 +1,30 @@
1
+ """Provider abstraction layer — adapters bridge external AI tools into clau-decode.
2
+
3
+ Each supported tool (Claude Code, Codex, etc.) ships as a ``ProviderAdapter``
4
+ subclass registered via ``providers.registry``. The server and scanner never
5
+ import concrete adapter modules directly; they work against the abstract seam
6
+ defined in ``providers.base``.
7
+ """
8
+
9
+ from . import (
10
+ registry,
11
+ ) # re-exported for convenience: ``from .providers import registry``
12
+
13
+
14
+ def register_builtins() -> None:
15
+ """Register all built-in provider adapters (idempotent).
16
+
17
+ Call this once near application startup (e.g. inside ``create_app`` or
18
+ ``_do_scan``) before iterating ``registry.all_adapters()``. Registering
19
+ the same adapter name twice simply overwrites the entry, so it is safe to
20
+ call multiple times.
21
+
22
+ Import is deferred inside the function body to avoid import-time side
23
+ effects and circular imports — concrete adapter modules may themselves
24
+ import from ``..models`` or ``..scanner``.
25
+ """
26
+ from .claude import ClaudeAdapter
27
+ from .codex import CodexAdapter
28
+
29
+ registry.register(ClaudeAdapter())
30
+ registry.register(CodexAdapter())
@@ -0,0 +1,132 @@
1
+ """Abstract base classes for provider adapters.
2
+
3
+ A ``ProviderAdapter`` bridges an external AI tool's on-disk format into
4
+ clau-decode's domain model. Every adapter must declare its ``capabilities``
5
+ and implement the four operations the server uses:
6
+
7
+ configured_roots — which paths to scan, per-provider config knowledge
8
+ discover — async-generate (Project, session_file_path) pairs
9
+ parse — turn a session file into (Session, [Message])
10
+ owns_path — quick membership test for dispatcher routing
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import abc
16
+ from collections.abc import AsyncIterator
17
+ from pathlib import Path
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from ..models import AppConfig, Message, Project, Session
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Capabilities descriptor
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class ProviderCaps(BaseModel):
30
+ """Declares which interactive operations this provider supports.
31
+
32
+ The frontend (and server) check these before enabling UI affordances so
33
+ that read-only providers (e.g. an imported Codex export) never see a
34
+ send / resume / fork button.
35
+ """
36
+
37
+ can_send: bool
38
+ can_resume: bool
39
+ can_fork: bool
40
+ can_edit: bool
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Abstract adapter
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ class ProviderAdapter(abc.ABC):
49
+ """Seam every concrete provider must implement.
50
+
51
+ Subclasses set the class attribute ``name`` to a short, unique identifier
52
+ (e.g. ``"claude"``, ``"codex"``). The registry keys on this string.
53
+ """
54
+
55
+ name: str # e.g. "claude" — concrete subclasses assign this at class level
56
+
57
+ # -- Capability declaration -----------------------------------------------
58
+
59
+ @property
60
+ @abc.abstractmethod
61
+ def capabilities(self) -> ProviderCaps:
62
+ """Return this adapter's static capability descriptor."""
63
+ ...
64
+
65
+ # -- Config-aware root resolution -----------------------------------------
66
+
67
+ @abc.abstractmethod
68
+ def configured_roots(self, config: AppConfig) -> list[Path]:
69
+ """Return the expanded root directories this provider should scan.
70
+
71
+ Centralises the knowledge of which ``AppConfig`` fields belong to this
72
+ provider so callers never need to know the field names.
73
+
74
+ Args:
75
+ config: The live application configuration.
76
+
77
+ Returns:
78
+ Fully-expanded ``Path`` objects (no ``~`` / env vars).
79
+ """
80
+ ...
81
+
82
+ # -- Discovery (async generator) ------------------------------------------
83
+
84
+ @abc.abstractmethod
85
+ async def discover(self, roots: list[Path]) -> AsyncIterator[tuple[Project, Path]]:
86
+ """Yield ``(Project, session_file_path)`` pairs found under *roots*.
87
+
88
+ Mirrors the contract of ``scanner.scan_paths``. Declared as an async
89
+ generator so I/O can be interleaved with DB writes in the scan loop.
90
+
91
+ Args:
92
+ roots: Expanded root directories to walk.
93
+
94
+ Yields:
95
+ Two-tuples of ``(Project, Path)`` where ``Path`` points to an
96
+ individual session file.
97
+ """
98
+ # Abstract async generators must have a body. Subclasses override
99
+ # the entire method; this line is never reached.
100
+ raise NotImplementedError # pragma: no cover
101
+ yield # makes Python treat this as a generator function
102
+
103
+ # -- Parsing (synchronous) ------------------------------------------------
104
+
105
+ @abc.abstractmethod
106
+ def parse(self, path: Path) -> tuple[Session, list[Message]]:
107
+ """Parse *path* into a ``(Session, messages)`` pair.
108
+
109
+ Intentionally synchronous — callers wrap this in
110
+ ``asyncio.to_thread`` when they need non-blocking behaviour.
111
+
112
+ Args:
113
+ path: Absolute path to a session file.
114
+
115
+ Returns:
116
+ ``(Session, list[Message])`` populated from the file contents.
117
+ """
118
+ ...
119
+
120
+ # -- Path ownership -------------------------------------------------------
121
+
122
+ @abc.abstractmethod
123
+ def owns_path(self, path: Path) -> bool:
124
+ """Return True if this adapter recognises and can parse *path*.
125
+
126
+ Used by the registry dispatcher to route incoming file paths to the
127
+ correct adapter without opening the file.
128
+
129
+ Args:
130
+ path: Absolute path to a candidate session file.
131
+ """
132
+ ...