converseek 0.2.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.
converseek/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """converseek — Cross-tool conversation search, browse, and export."""
2
+ __version__ = "0.2.0"
converseek/__main__.py ADDED
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env python3
2
+ """converseek — cross-tool conversation search, browse, and export.
3
+
4
+ Search and read sessions from 7 AI coding tools:
5
+ claude, hermes, opencode, paseo, zcode, cursor, antigravity
6
+
7
+ Usage:
8
+ converseek list [--tool TOOL] [--limit N] [--since DATE] [--project PATH]
9
+ converseek search QUERY [--tool TOOL] [--limit N] [--project PATH]
10
+ converseek show TOOL:SESSION_ID [--window N]
11
+ converseek export TOOL:SESSION_ID [-o FILE]
12
+ converseek projects
13
+ converseek tools
14
+
15
+ Examples:
16
+ converseek list --limit 10
17
+ converseek search "docker networking"
18
+ converseek search "auth refactor" --tool claude,hermes
19
+ converseek show hermes:20260620_201309_a8e8cb95
20
+ converseek export hermes:20260620_201309_a8e8cb95
21
+ converseek tools
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import sys
27
+ from collections import Counter
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+
31
+ from .adapters.claude_code import ClaudeCodeAdapter
32
+ from .adapters.hermes import HermesAdapter
33
+ from .adapters.opencode import OpenCodeAdapter
34
+ from .adapters.paseo import PaseoAdapter
35
+ from .adapters.zcode import ZCodeAdapter
36
+ from .adapters.cursor import CursorAdapter
37
+ from .adapters.antigravity import AntigravityAdapter
38
+
39
+
40
+ ADAPTERS = {
41
+ "claude": ClaudeCodeAdapter,
42
+ "hermes": HermesAdapter,
43
+ "opencode": OpenCodeAdapter,
44
+ "paseo": PaseoAdapter,
45
+ "zcode": ZCodeAdapter,
46
+ "cursor": CursorAdapter,
47
+ "antigravity": AntigravityAdapter,
48
+ }
49
+
50
+
51
+ def get_adapters(tools: str | None = None) -> list:
52
+ """Get adapter instances for specified tools (or all available)."""
53
+ if tools:
54
+ names = [t.strip() for t in tools.split(",")]
55
+ else:
56
+ names = list(ADAPTERS.keys())
57
+ instances = []
58
+ for name in names:
59
+ cls = ADAPTERS.get(name)
60
+ if not cls:
61
+ print(f"Warning: unknown tool '{name}'", file=sys.stderr)
62
+ continue
63
+ instance = cls()
64
+ if instance.is_available():
65
+ instances.append((name, instance))
66
+ else:
67
+ print(f"Info: '{name}' data not found, skipping", file=sys.stderr)
68
+ return instances
69
+
70
+
71
+ def _fmt_time(ts: float) -> str:
72
+ if not ts:
73
+ return "?"
74
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
75
+
76
+
77
+ def _with_timeout(fn, adapter_name: str, *args, **kwargs):
78
+ """Run fn with a 15s SIGALRM timeout. Returns (result, timed_out)."""
79
+ import signal
80
+
81
+ def _timeout_handler(signum, frame):
82
+ raise TimeoutError()
83
+
84
+ try:
85
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
86
+ signal.alarm(15)
87
+ try:
88
+ return fn(*args, **kwargs), False
89
+ finally:
90
+ signal.alarm(0)
91
+ signal.signal(signal.SIGALRM, old_handler)
92
+ except TimeoutError:
93
+ print(f"Warning: '{adapter_name}' timed out, skipping", file=sys.stderr)
94
+ return [], True
95
+
96
+
97
+ def _project_name(cwd: str | None) -> str:
98
+ """Extract a short project name from a cwd path."""
99
+ if not cwd:
100
+ return "(unknown)"
101
+ p = Path(cwd)
102
+ # Use the last meaningful segment
103
+ if p.name:
104
+ return str(p)
105
+ return cwd
106
+
107
+
108
+ def _matches_project(cwd: str | None, project: str) -> bool:
109
+ """Check if a session's cwd matches the project filter.
110
+
111
+ Matches if the project string appears anywhere in the cwd path
112
+ (case-insensitive), or if cwd ends with the project string.
113
+ """
114
+ if not cwd:
115
+ return False
116
+ return project.lower() in cwd.lower()
117
+
118
+
119
+ def cmd_projects(args):
120
+ """List all unique project directories with session counts."""
121
+ adapters = get_adapters(args.tool)
122
+ project_counter: Counter = Counter() # {cwd: total_count}
123
+
124
+ for name, adapter in adapters:
125
+ sessions, _ = _with_timeout(
126
+ adapter.list_sessions, name, limit=9999
127
+ )
128
+ for s in sessions:
129
+ if s.cwd:
130
+ project_counter[s.cwd] += 1
131
+
132
+ if not project_counter:
133
+ print("No project data found.")
134
+ return 0
135
+
136
+ # Sort by session count descending
137
+ sorted_projects = project_counter.most_common()
138
+
139
+ # Apply --limit
140
+ if args.limit:
141
+ sorted_projects = sorted_projects[: args.limit]
142
+
143
+ print(f"Found {len(project_counter)} unique projects:\n")
144
+ print(f"{'SESSIONS':>8} PROJECT PATH")
145
+ print("-" * 100)
146
+ for cwd, count in sorted_projects:
147
+ print(f"{count:>8} {cwd}")
148
+
149
+ print(f"\nUse --project <path> with list/search to filter by project.")
150
+ return 0
151
+
152
+
153
+ def cmd_list(args):
154
+ since = None
155
+ if args.since:
156
+ try:
157
+ since = datetime.fromisoformat(args.since).timestamp()
158
+ except ValueError:
159
+ print(f"Error: invalid date format '{args.since}', use YYYY-MM-DD", file=sys.stderr)
160
+ return 1
161
+
162
+ adapters = get_adapters(args.tool)
163
+ all_sessions = []
164
+
165
+ # --project is a CLI-side post-filter, so the per-adapter limit must NOT
166
+ # truncate before filtering (otherwise we'd only ever filter the globally
167
+ # most-recent `limit` sessions). Fetch a large pool when filtering.
168
+ fetch_limit = 100000 if args.project else args.limit
169
+
170
+ for name, adapter in adapters:
171
+ sessions, _ = _with_timeout(
172
+ adapter.list_sessions, name,
173
+ limit=fetch_limit, since=since, cwd=args.cwd
174
+ )
175
+ all_sessions.extend(sessions)
176
+
177
+ # Filter by project
178
+ if args.project:
179
+ all_sessions = [s for s in all_sessions if _matches_project(s.cwd, args.project)]
180
+
181
+ all_sessions.sort(key=lambda m: m.updated_at, reverse=True)
182
+ all_sessions = all_sessions[: args.limit]
183
+
184
+ if not all_sessions:
185
+ print("No sessions found.")
186
+ return 0
187
+
188
+ print(f"Found {len(all_sessions)} sessions:\n")
189
+ if args.project:
190
+ print(f" (filtered by project: '{args.project}')\n")
191
+ print(f"{'TOOL':<14} {'ID':<42} {'UPDATED':<17} {'MSG':>4} {'PROJECT':<30} TITLE")
192
+ print("-" * 140)
193
+ for s in all_sessions:
194
+ title = s.title[:40] + "..." if len(s.title) > 40 else s.title
195
+ sid = s.session_id
196
+ proj = _project_name(s.cwd)[:28] if s.cwd else "-"
197
+ print(f"{s.tool:<14} {sid:<42} {_fmt_time(s.updated_at):<17} {s.message_count:>4} {proj:<30} {title}")
198
+
199
+ print(f"\nReference format: @session:<tool>:<session_id>")
200
+ return 0
201
+
202
+
203
+ def cmd_search(args):
204
+ adapters = get_adapters(args.tool)
205
+ all_results: list[tuple] = []
206
+
207
+ # See cmd_list: --project filters after the fact, so fetch a larger pool.
208
+ fetch_limit = 100000 if args.project else args.limit
209
+
210
+ for name, adapter in adapters:
211
+ results, _ = _with_timeout(
212
+ adapter.search_sessions, name, args.query, limit=fetch_limit
213
+ )
214
+ all_results.extend(results)
215
+
216
+ # Filter by project
217
+ if args.project:
218
+ all_results = [
219
+ (meta, snippet) for meta, snippet in all_results
220
+ if _matches_project(meta.cwd, args.project)
221
+ ]
222
+
223
+ all_results.sort(key=lambda r: r[0].updated_at, reverse=True)
224
+ all_results = all_results[: args.limit]
225
+
226
+ if not all_results:
227
+ print(f'No sessions found for "{args.query}".')
228
+ return 0
229
+
230
+ print(f'Found {len(all_results)} sessions matching "{args.query}":\n')
231
+ if args.project:
232
+ print(f" (filtered by project: '{args.project}')\n")
233
+ for i, (meta, snippet) in enumerate(all_results, 1):
234
+ snippet = snippet.replace("\n", " ").strip()
235
+ if len(snippet) > 120:
236
+ snippet = snippet[:120] + "..."
237
+ proj = _project_name(meta.cwd)[:40] if meta.cwd else ""
238
+ print(f" {i}. [{meta.tool}] {meta.title[:60]}")
239
+ print(f" ref: {meta.ref}")
240
+ if proj:
241
+ print(f" project: {proj}")
242
+ print(f" {snippet}")
243
+ print()
244
+ return 0
245
+
246
+
247
+ def cmd_show(args):
248
+ """Display messages from a session in the terminal."""
249
+ parts = args.ref.split(":", 1)
250
+ if len(parts) != 2:
251
+ print(f"Error: invalid reference '{args.ref}'. Use TOOL:SESSION_ID", file=sys.stderr)
252
+ return 1
253
+ tool_name, session_id = parts
254
+
255
+ cls = ADAPTERS.get(tool_name)
256
+ if not cls:
257
+ print(f"Error: unknown tool '{tool_name}'", file=sys.stderr)
258
+ return 1
259
+
260
+ adapter = cls()
261
+ if not adapter.is_available():
262
+ print(f"Error: '{tool_name}' data not found", file=sys.stderr)
263
+ return 1
264
+
265
+ meta = adapter.get_session(session_id)
266
+ if not meta:
267
+ print(f"Session not found: {args.ref}", file=sys.stderr)
268
+ return 1
269
+
270
+ print(f"Session: {meta.ref}")
271
+ print(f"Title: {meta.title}")
272
+ print(f"Tool: {meta.tool}")
273
+ print(f"CWD: {meta.cwd or '?'}")
274
+ print(f"Created: {_fmt_time(meta.created_at)}")
275
+ print(f"Updated: {_fmt_time(meta.updated_at)}")
276
+ print(f"Model: {meta.model or '?'}")
277
+ print(f"Messages: {meta.message_count}")
278
+ print()
279
+
280
+ messages = adapter.read_messages(session_id, window=args.window)
281
+ if not messages:
282
+ print("(No messages found or message reading not supported for this tool)")
283
+ return 0
284
+
285
+ for msg in messages:
286
+ role_label = {"user": "👤 USER", "assistant": "🤖 ASSISTANT", "system": "⚙️ SYSTEM"}
287
+ label = role_label.get(msg.role, f"📋 {msg.role.upper()}")
288
+ ts = _fmt_time(msg.timestamp) if msg.timestamp else ""
289
+ print(f"{'─' * 80}")
290
+ print(f"{label} {ts} {msg.msg_id[:20] if msg.msg_id else ''}")
291
+ print(f"{'─' * 80}")
292
+ content = msg.content
293
+ if args.max_chars and len(content) > args.max_chars:
294
+ content = content[: args.max_chars] + f"\n... ({len(msg.content)} chars total)"
295
+ print(content)
296
+ if msg.tool_name:
297
+ print(f"\n[tool: {msg.tool_name}]")
298
+ print()
299
+ return 0
300
+
301
+
302
+ def cmd_export(args):
303
+ """Export a session to a Markdown file."""
304
+ parts = args.ref.split(":", 1)
305
+ if len(parts) != 2:
306
+ print(f"Error: invalid reference '{args.ref}'. Use TOOL:SESSION_ID", file=sys.stderr)
307
+ return 1
308
+ tool_name, session_id = parts
309
+
310
+ cls = ADAPTERS.get(tool_name)
311
+ if not cls:
312
+ print(f"Error: unknown tool '{tool_name}'", file=sys.stderr)
313
+ return 1
314
+
315
+ adapter = cls()
316
+ if not adapter.is_available():
317
+ print(f"Error: '{tool_name}' data not found", file=sys.stderr)
318
+ return 1
319
+
320
+ meta = adapter.get_session(session_id)
321
+ if not meta:
322
+ print(f"Session not found: {args.ref}", file=sys.stderr)
323
+ return 1
324
+
325
+ messages = adapter.read_messages(session_id)
326
+ if not messages:
327
+ print("No messages found to export.", file=sys.stderr)
328
+ return 1
329
+
330
+ # Build Markdown content
331
+ lines: list[str] = []
332
+ # Front matter
333
+ lines.append(f"# {meta.title or meta.ref}")
334
+ lines.append("")
335
+ lines.append("| Field | Value |")
336
+ lines.append("|-------|-------|")
337
+ lines.append(f"| **Tool** | {meta.tool} |")
338
+ lines.append(f"| **Session ID** | `{meta.session_id}` |")
339
+ lines.append(f"| **Project** | `{meta.cwd or '?'}` |")
340
+ lines.append(f"| **Created** | {_fmt_time(meta.created_at)} |")
341
+ lines.append(f"| **Updated** | {_fmt_time(meta.updated_at)} |")
342
+ if meta.model:
343
+ lines.append(f"| **Model** | {meta.model} |")
344
+ lines.append(f"| **Messages** | {len(messages)} |")
345
+ lines.append("")
346
+ lines.append("---")
347
+ lines.append("")
348
+
349
+ for msg in messages:
350
+ role_label = {
351
+ "user": "User",
352
+ "assistant": "Assistant",
353
+ "system": "System",
354
+ "tool": "Tool",
355
+ }.get(msg.role, msg.role.capitalize())
356
+
357
+ ts = _fmt_time(msg.timestamp) if msg.timestamp else ""
358
+ # Message header as H3
359
+ header_parts = [f"### {role_label}"]
360
+ if ts:
361
+ header_parts.append(ts)
362
+ if msg.model:
363
+ header_parts.append(f"_{msg.model}_")
364
+ lines.append(" ".join(header_parts))
365
+ lines.append("")
366
+
367
+ # Content
368
+ content = msg.content.strip()
369
+ if not content and msg.tool_name:
370
+ content = f"_(tool call: {msg.tool_name})_"
371
+ if content:
372
+ lines.append(content)
373
+ lines.append("")
374
+
375
+ # Tool call annotation
376
+ if msg.tool_name:
377
+ lines.append(f"> 🔧 **Tool**: `{msg.tool_name}`")
378
+ lines.append("")
379
+
380
+ if msg.reasoning:
381
+ lines.append("<details><summary>💭 Reasoning</summary>")
382
+ lines.append("")
383
+ lines.append(msg.reasoning.strip())
384
+ lines.append("")
385
+ lines.append("</details>")
386
+ lines.append("")
387
+
388
+ md_content = "\n".join(lines)
389
+
390
+ # Determine output path
391
+ if args.output:
392
+ out_path = Path(args.output)
393
+ else:
394
+ # Default: ./<tool>-<session_id>.md
395
+ safe_id = meta.session_id.replace("/", "_")[:40]
396
+ out_path = Path(f"{meta.tool}-{safe_id}.md")
397
+
398
+ out_path.write_text(md_content, encoding="utf-8")
399
+ print(f"Exported {len(messages)} messages to {out_path}")
400
+ return 0
401
+
402
+
403
+ def cmd_tools(args):
404
+ print("Available adapters:\n")
405
+ print(f"{'TOOL':<16} {'STATUS':<10} {'LOCATION'}")
406
+ print("-" * 90)
407
+ for name, cls in ADAPTERS.items():
408
+ instance = cls()
409
+ status = "✅ active" if instance.is_available() else "❌ not found"
410
+ # Show data location
411
+ for attr in ("base_dir", "db_path"):
412
+ if hasattr(instance, attr):
413
+ loc = str(getattr(instance, attr))
414
+ break
415
+ else:
416
+ loc = "?"
417
+ print(f"{name:<16} {status:<10} {loc}")
418
+ return 0
419
+
420
+
421
+ def main():
422
+ parser = argparse.ArgumentParser(
423
+ prog="converseek",
424
+ description="Cross-tool conversation search, browse, and export",
425
+ )
426
+ sub = parser.add_subparsers(dest="command", required=True)
427
+
428
+ # list
429
+ p_list = sub.add_parser("list", help="List sessions")
430
+ p_list.add_argument("--tool", "-t", help="Comma-separated tool names (default: all)")
431
+ p_list.add_argument("--limit", "-n", type=int, default=20)
432
+ p_list.add_argument("--since", help="Only sessions since date (YYYY-MM-DD)")
433
+ p_list.add_argument("--cwd", help="Filter by working directory prefix (exact match)")
434
+ p_list.add_argument("--project", "-p", help="Filter by project name/path (fuzzy match)")
435
+ p_list.set_defaults(func=cmd_list)
436
+
437
+ # search
438
+ p_search = sub.add_parser("search", help="Search sessions by keyword")
439
+ p_search.add_argument("query", help="Search query")
440
+ p_search.add_argument("--tool", "-t", help="Comma-separated tool names (default: all)")
441
+ p_search.add_argument("--limit", "-n", type=int, default=20)
442
+ p_search.add_argument("--project", "-p", help="Filter by project name/path (fuzzy match)")
443
+ p_search.set_defaults(func=cmd_search)
444
+
445
+ # show
446
+ p_show = sub.add_parser("show", help="Show a session's messages")
447
+ p_show.add_argument("ref", help="Session reference: TOOL:SESSION_ID")
448
+ p_show.add_argument("--window", "-w", type=int, help="Only show last N messages")
449
+ p_show.add_argument("--max-chars", type=int, default=2000, help="Max chars per message")
450
+ p_show.set_defaults(func=cmd_show)
451
+
452
+ # export
453
+ p_export = sub.add_parser("export", help="Export a session to a Markdown file")
454
+ p_export.add_argument("ref", help="Session reference: TOOL:SESSION_ID")
455
+ p_export.add_argument("-o", "--output", help="Output file path (default: ./<tool>-<session_id>.md)")
456
+ p_export.set_defaults(func=cmd_export)
457
+
458
+ # projects
459
+ p_projects = sub.add_parser("projects", help="List all projects with session counts")
460
+ p_projects.add_argument("--tool", "-t", help="Comma-separated tool names (default: all)")
461
+ p_projects.add_argument("--limit", "-n", type=int, default=50, help="Max projects to show")
462
+ p_projects.set_defaults(func=cmd_projects)
463
+
464
+ # tools
465
+ p_tools = sub.add_parser("tools", help="List available adapters")
466
+ p_tools.set_defaults(func=cmd_tools)
467
+
468
+ args = parser.parse_args()
469
+ return args.func(args)
470
+
471
+
472
+ if __name__ == "__main__":
473
+ sys.exit(main())
File without changes