sift-cc 0.2.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.
sift.py ADDED
@@ -0,0 +1,2459 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sift — mine your Claude Code conversation archive.
4
+
5
+ Browse, search, and extract from the JSONL session files Claude Code
6
+ leaves behind in ~/.claude/projects/. Zero dependencies, pure stdlib.
7
+
8
+ Run `sift --help` for the command list,
9
+ or `sift <command> --help` for details on each command.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ import argparse
17
+ import json
18
+ import math
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import textwrap
25
+ from collections import Counter, defaultdict
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timedelta, timezone
28
+ from pathlib import Path
29
+ from typing import Iterator
30
+
31
+ ARCHIVE_ROOT = Path(
32
+ os.environ.get("SIFT_ARCHIVE", Path.home() / ".claude" / "projects")
33
+ )
34
+
35
+ # ============================================================
36
+ # terminal helpers
37
+ # ============================================================
38
+
39
+ def _supports_color() -> bool:
40
+ if os.environ.get("NO_COLOR") or os.environ.get("SIFT_NO_COLOR"):
41
+ return False
42
+ return sys.stdout.isatty()
43
+
44
+
45
+ USE_COLOR = _supports_color()
46
+
47
+
48
+ class C:
49
+ RESET = "\033[0m" if USE_COLOR else ""
50
+ DIM = "\033[2m" if USE_COLOR else ""
51
+ BOLD = "\033[1m" if USE_COLOR else ""
52
+ RED = "\033[31m" if USE_COLOR else ""
53
+ GREEN = "\033[32m" if USE_COLOR else ""
54
+ YELLOW = "\033[33m" if USE_COLOR else ""
55
+ BLUE = "\033[34m" if USE_COLOR else ""
56
+ MAGENTA = "\033[35m" if USE_COLOR else ""
57
+ CYAN = "\033[36m" if USE_COLOR else ""
58
+ GREY = "\033[90m" if USE_COLOR else ""
59
+
60
+
61
+ def term_width(default: int = 100) -> int:
62
+ try:
63
+ return shutil.get_terminal_size((default, 24)).columns
64
+ except Exception:
65
+ return default
66
+
67
+
68
+ def hr(char: str = "─") -> str:
69
+ return C.GREY + char * term_width() + C.RESET
70
+
71
+
72
+ # ============================================================
73
+ # date / time helpers
74
+ # ============================================================
75
+
76
+ _WHEN_UNITS = {"h": 3600, "d": 86400, "w": 604800}
77
+
78
+
79
+ def parse_when(s: str | None) -> datetime | None:
80
+ """Accept '7d', '2w', '12h' or 'YYYY-MM-DD' / ISO datetime. Returns UTC."""
81
+ if not s:
82
+ return None
83
+ s = s.strip()
84
+ m = re.fullmatch(r"(\d+)([hdw])", s)
85
+ if m:
86
+ n = int(m.group(1))
87
+ return datetime.now(timezone.utc) - timedelta(seconds=n * _WHEN_UNITS[m.group(2)])
88
+ try:
89
+ d = datetime.fromisoformat(s)
90
+ if d.tzinfo is None:
91
+ d = d.replace(tzinfo=timezone.utc)
92
+ return d
93
+ except ValueError:
94
+ return None
95
+
96
+
97
+ # ============================================================
98
+ # data model
99
+ # ============================================================
100
+
101
+ @dataclass
102
+ class SessionInfo:
103
+ project_dir: str
104
+ project_path: str
105
+ session_id: str
106
+ file: Path
107
+ first_ts: datetime | None = None
108
+ last_ts: datetime | None = None
109
+ title: str | None = None
110
+ user_msgs: int = 0
111
+ assistant_msgs: int = 0
112
+ input_tokens: int = 0
113
+ output_tokens: int = 0
114
+ cache_read: int = 0
115
+ cache_create: int = 0
116
+ first_user_prompt: str = ""
117
+ cwd: str = ""
118
+ git_branch: str = ""
119
+ models: set[str] = field(default_factory=set)
120
+
121
+ @property
122
+ def duration(self) -> timedelta | None:
123
+ if self.first_ts and self.last_ts:
124
+ return self.last_ts - self.first_ts
125
+ return None
126
+
127
+ @property
128
+ def total_msgs(self) -> int:
129
+ return self.user_msgs + self.assistant_msgs
130
+
131
+
132
+ # ============================================================
133
+ # parsing
134
+ # ============================================================
135
+
136
+ def decode_project_dir(name: str) -> str:
137
+ if name.startswith("-"):
138
+ return "/" + name[1:].replace("-", "/")
139
+ return name
140
+
141
+
142
+ def parse_ts(s: str | None) -> datetime | None:
143
+ if not s:
144
+ return None
145
+ try:
146
+ if s.endswith("Z"):
147
+ s = s[:-1] + "+00:00"
148
+ return datetime.fromisoformat(s)
149
+ except Exception:
150
+ return None
151
+
152
+
153
+ def iter_session_files(project_filter: str | None = None) -> Iterator[Path]:
154
+ if not ARCHIVE_ROOT.exists():
155
+ return
156
+ for proj_dir in sorted(ARCHIVE_ROOT.iterdir()):
157
+ if not proj_dir.is_dir():
158
+ continue
159
+ if project_filter and project_filter.lower() not in decode_project_dir(proj_dir.name).lower():
160
+ continue
161
+ for f in sorted(proj_dir.glob("*.jsonl")):
162
+ yield f
163
+
164
+
165
+ def iter_records(path: Path) -> Iterator[dict]:
166
+ try:
167
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
168
+ for line in fh:
169
+ line = line.strip()
170
+ if not line:
171
+ continue
172
+ try:
173
+ yield json.loads(line)
174
+ except json.JSONDecodeError:
175
+ continue
176
+ except OSError:
177
+ return
178
+
179
+
180
+ def text_of_user_message(msg: dict) -> str:
181
+ if not isinstance(msg, dict):
182
+ return ""
183
+ content = msg.get("content")
184
+ if isinstance(content, str):
185
+ return content
186
+ if isinstance(content, list):
187
+ chunks: list[str] = []
188
+ for block in content:
189
+ if not isinstance(block, dict):
190
+ continue
191
+ if block.get("type") == "text":
192
+ chunks.append(block.get("text") or "")
193
+ elif block.get("type") == "tool_result":
194
+ tc = block.get("content")
195
+ if isinstance(tc, str):
196
+ chunks.append(tc)
197
+ elif isinstance(tc, list):
198
+ for b in tc:
199
+ if isinstance(b, dict) and b.get("type") == "text":
200
+ chunks.append(b.get("text") or "")
201
+ return "\n".join(chunks)
202
+ return ""
203
+
204
+
205
+ def text_of_assistant_message(msg: dict) -> tuple[str, list[dict]]:
206
+ if not isinstance(msg, dict):
207
+ return "", []
208
+ content = msg.get("content")
209
+ if isinstance(content, str):
210
+ return content, []
211
+ tool_uses: list[dict] = []
212
+ chunks: list[str] = []
213
+ if isinstance(content, list):
214
+ for block in content:
215
+ if not isinstance(block, dict):
216
+ continue
217
+ btype = block.get("type")
218
+ if btype == "text":
219
+ chunks.append(block.get("text") or "")
220
+ elif btype == "tool_use":
221
+ tool_uses.append(block)
222
+ return "\n".join(chunks), tool_uses
223
+
224
+
225
+ def thinking_of_assistant_message(msg: dict) -> str:
226
+ if not isinstance(msg, dict):
227
+ return ""
228
+ content = msg.get("content")
229
+ if not isinstance(content, list):
230
+ return ""
231
+ return "\n".join(
232
+ (b.get("thinking") or "")
233
+ for b in content
234
+ if isinstance(b, dict) and b.get("type") == "thinking"
235
+ )
236
+
237
+
238
+ def _looks_like_tool_result(msg: dict) -> bool:
239
+ content = msg.get("content")
240
+ if not isinstance(content, list):
241
+ return False
242
+ return any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content)
243
+
244
+
245
+ def summarize_session(path: Path) -> SessionInfo:
246
+ project_dir = path.parent.name
247
+ info = SessionInfo(
248
+ project_dir=project_dir,
249
+ project_path=decode_project_dir(project_dir),
250
+ session_id=path.stem,
251
+ file=path,
252
+ )
253
+ for rec in iter_records(path):
254
+ rtype = rec.get("type")
255
+ ts = parse_ts(rec.get("timestamp"))
256
+ if ts:
257
+ if info.first_ts is None or ts < info.first_ts:
258
+ info.first_ts = ts
259
+ if info.last_ts is None or ts > info.last_ts:
260
+ info.last_ts = ts
261
+ if rtype == "ai-title":
262
+ info.title = rec.get("aiTitle")
263
+ elif rtype == "user":
264
+ msg = rec.get("message") or {}
265
+ txt = text_of_user_message(msg)
266
+ if (
267
+ not info.first_user_prompt
268
+ and txt
269
+ and not txt.startswith("<")
270
+ and not _looks_like_tool_result(msg)
271
+ ):
272
+ info.first_user_prompt = txt.strip().splitlines()[0][:240]
273
+ info.user_msgs += 1
274
+ if not info.cwd and rec.get("cwd"):
275
+ info.cwd = rec["cwd"]
276
+ if not info.git_branch and rec.get("gitBranch"):
277
+ info.git_branch = rec["gitBranch"]
278
+ elif rtype == "assistant":
279
+ info.assistant_msgs += 1
280
+ msg = rec.get("message") or {}
281
+ model = msg.get("model")
282
+ if model:
283
+ info.models.add(model)
284
+ usage = msg.get("usage") or {}
285
+ info.input_tokens += int(usage.get("input_tokens") or 0)
286
+ info.output_tokens += int(usage.get("output_tokens") or 0)
287
+ info.cache_read += int(usage.get("cache_read_input_tokens") or 0)
288
+ info.cache_create += int(usage.get("cache_creation_input_tokens") or 0)
289
+ return info
290
+
291
+
292
+ def collect_sessions(
293
+ project_filter: str | None = None,
294
+ since: datetime | None = None,
295
+ until: datetime | None = None,
296
+ ) -> list[SessionInfo]:
297
+ sessions: list[SessionInfo] = []
298
+ for f in iter_session_files(project_filter):
299
+ info = summarize_session(f)
300
+ if since and (info.last_ts is None or info.last_ts < since):
301
+ continue
302
+ if until and (info.first_ts is None or info.first_ts > until):
303
+ continue
304
+ sessions.append(info)
305
+ sessions.sort(
306
+ key=lambda s: (s.last_ts or datetime.min.replace(tzinfo=timezone.utc)),
307
+ reverse=True,
308
+ )
309
+ return sessions
310
+
311
+
312
+ def iter_tool_uses(path: Path) -> Iterator[tuple[datetime | None, dict]]:
313
+ """Yield (timestamp, tool_use_block) for every assistant tool call."""
314
+ for rec in iter_records(path):
315
+ if rec.get("type") != "assistant":
316
+ continue
317
+ ts = parse_ts(rec.get("timestamp"))
318
+ msg = rec.get("message") or {}
319
+ content = msg.get("content")
320
+ if not isinstance(content, list):
321
+ continue
322
+ for block in content:
323
+ if isinstance(block, dict) and block.get("type") == "tool_use":
324
+ yield ts, block
325
+
326
+
327
+ URL_RE = re.compile(r"https?://[^\s\)\]\>\"'`]+")
328
+
329
+
330
+ def extract_urls(text: str) -> list[str]:
331
+ return URL_RE.findall(text or "")
332
+
333
+
334
+ # ============================================================
335
+ # formatters
336
+ # ============================================================
337
+
338
+ def fmt_ts(ts: datetime | None) -> str:
339
+ if ts is None:
340
+ return "—"
341
+ local = ts.astimezone()
342
+ now = datetime.now(local.tzinfo)
343
+ delta = now - local
344
+ if delta < timedelta(minutes=1):
345
+ return "just now"
346
+ if delta < timedelta(hours=1):
347
+ return f"{int(delta.total_seconds() // 60)}m ago"
348
+ if delta < timedelta(days=1):
349
+ return f"{int(delta.total_seconds() // 3600)}h ago"
350
+ if delta < timedelta(days=7):
351
+ return f"{delta.days}d ago"
352
+ return local.strftime("%Y-%m-%d")
353
+
354
+
355
+ def fmt_num(n: int) -> str:
356
+ if n >= 1_000_000_000:
357
+ return f"{n/1_000_000_000:.1f}G"
358
+ if n >= 1_000_000:
359
+ return f"{n/1_000_000:.1f}M"
360
+ if n >= 1_000:
361
+ return f"{n/1_000:.1f}k"
362
+ return str(n)
363
+
364
+
365
+ def fmt_duration(td: timedelta | None) -> str:
366
+ if td is None:
367
+ return "—"
368
+ s = int(td.total_seconds())
369
+ if s < 60:
370
+ return f"{s}s"
371
+ if s < 3600:
372
+ return f"{s // 60}m"
373
+ h = s // 3600
374
+ m = (s % 3600) // 60
375
+ if h < 24:
376
+ return f"{h}h{m:02d}m" if m else f"{h}h"
377
+ d = h // 24
378
+ return f"{d}d{h % 24:02d}h"
379
+
380
+
381
+ def short_path(p: str, width: int) -> str:
382
+ if len(p) <= width:
383
+ return p
384
+ home = str(Path.home())
385
+ if p.startswith(home):
386
+ p = "~" + p[len(home):]
387
+ if len(p) <= width:
388
+ return p
389
+ return "…" + p[-(width - 1):]
390
+
391
+
392
+ def truncate(s: str, n: int) -> str:
393
+ s = " ".join((s or "").split())
394
+ return s if len(s) <= n else s[: n - 1] + "…"
395
+
396
+
397
+ def find_session(prefix: str) -> Path | None:
398
+ matches: list[Path] = []
399
+ for f in iter_session_files():
400
+ if f.stem == prefix:
401
+ return f
402
+ if f.stem.startswith(prefix):
403
+ matches.append(f)
404
+ if len(matches) == 1:
405
+ return matches[0]
406
+ if len(matches) > 1:
407
+ print(
408
+ f"{C.RED}Ambiguous prefix '{prefix}' matches {len(matches)} sessions:{C.RESET}",
409
+ file=sys.stderr,
410
+ )
411
+ for m in matches[:10]:
412
+ print(
413
+ f" {m.stem} {C.GREY}{decode_project_dir(m.parent.name)}{C.RESET}",
414
+ file=sys.stderr,
415
+ )
416
+ return None
417
+ return None
418
+
419
+
420
+ def _session_to_dict(s: SessionInfo) -> dict:
421
+ return {
422
+ "session_id": s.session_id,
423
+ "project": s.project_path,
424
+ "title": s.title,
425
+ "first_prompt": s.first_user_prompt,
426
+ "first_ts": s.first_ts.isoformat() if s.first_ts else None,
427
+ "last_ts": s.last_ts.isoformat() if s.last_ts else None,
428
+ "duration_seconds": int(s.duration.total_seconds()) if s.duration else None,
429
+ "user_msgs": s.user_msgs,
430
+ "assistant_msgs": s.assistant_msgs,
431
+ "input_tokens": s.input_tokens,
432
+ "output_tokens": s.output_tokens,
433
+ "cache_read": s.cache_read,
434
+ "cache_create": s.cache_create,
435
+ "models": sorted(s.models),
436
+ "cwd": s.cwd,
437
+ "git_branch": s.git_branch,
438
+ "file": str(s.file),
439
+ }
440
+
441
+
442
+ # ============================================================
443
+ # pager
444
+ # ============================================================
445
+
446
+ def _maybe_pager(disable: bool) -> subprocess.Popen | None:
447
+ if disable or not sys.stdout.isatty():
448
+ return None
449
+ pager = os.environ.get("PAGER") or "less"
450
+ try:
451
+ cmd = [pager]
452
+ if Path(pager).name == "less":
453
+ cmd += ["-RFX"]
454
+ return subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True)
455
+ except OSError:
456
+ return None
457
+
458
+
459
+ # ============================================================
460
+ # tool-input summarization (used by show / files / export)
461
+ # ============================================================
462
+
463
+ def _summarize_tool_input(name: str, inp: dict) -> str:
464
+ if not isinstance(inp, dict):
465
+ return ""
466
+ if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
467
+ return inp.get("file_path", "")
468
+ if name == "Bash":
469
+ return truncate(inp.get("command", ""), 100)
470
+ if name == "Grep":
471
+ return f"{inp.get('pattern','')} in {inp.get('path','.')}"
472
+ if name == "Glob":
473
+ return inp.get("pattern", "")
474
+ if name == "WebFetch":
475
+ return inp.get("url", "")
476
+ if name == "WebSearch":
477
+ return inp.get("query", "")
478
+ if name in ("Task", "Agent"):
479
+ return truncate(inp.get("description", ""), 80)
480
+ for k, v in inp.items():
481
+ if isinstance(v, str):
482
+ return f"{k}={truncate(v, 80)}"
483
+ return ""
484
+
485
+
486
+ # ============================================================
487
+ # rendering: terminal + markdown
488
+ # ============================================================
489
+
490
+ def _render_session(
491
+ path: Path,
492
+ out,
493
+ show_thinking: bool = False,
494
+ show_tool_results: bool = False,
495
+ ) -> None:
496
+ info = summarize_session(path)
497
+
498
+ def emit(s: str = "") -> None:
499
+ try:
500
+ out.write(s + "\n")
501
+ except (BrokenPipeError, OSError):
502
+ raise SystemExit(0)
503
+
504
+ emit(f"{C.BOLD}{C.YELLOW}{info.session_id}{C.RESET}")
505
+ emit(f" {C.DIM}title {C.RESET}{info.title or '—'}")
506
+ emit(f" {C.DIM}project {C.RESET}{info.project_path}")
507
+ emit(f" {C.DIM}cwd {C.RESET}{info.cwd or '—'}")
508
+ emit(f" {C.DIM}branch {C.RESET}{info.git_branch or '—'}")
509
+ emit(f" {C.DIM}models {C.RESET}{', '.join(sorted(info.models)) or '—'}")
510
+ emit(f" {C.DIM}msgs {C.RESET}{info.user_msgs} user, {info.assistant_msgs} assistant")
511
+ emit(f" {C.DIM}tokens {C.RESET}in {fmt_num(info.input_tokens)}, out {fmt_num(info.output_tokens)}, cache-read {fmt_num(info.cache_read)}")
512
+ span = (
513
+ f"{info.first_ts.astimezone().strftime('%Y-%m-%d %H:%M') if info.first_ts else '—'}"
514
+ f" → {info.last_ts.astimezone().strftime('%H:%M') if info.last_ts else '—'}"
515
+ f" ({fmt_duration(info.duration)})"
516
+ )
517
+ emit(f" {C.DIM}span {C.RESET}{span}")
518
+ emit(hr())
519
+
520
+ width = term_width()
521
+ wrap = textwrap.TextWrapper(
522
+ width=min(width, 100),
523
+ initial_indent=" ",
524
+ subsequent_indent=" ",
525
+ break_long_words=False,
526
+ replace_whitespace=False,
527
+ )
528
+
529
+ for rec in iter_records(path):
530
+ rtype = rec.get("type")
531
+ if rtype not in ("user", "assistant"):
532
+ continue
533
+ msg = rec.get("message") or {}
534
+ ts = parse_ts(rec.get("timestamp"))
535
+ ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else ""
536
+ if rtype == "user":
537
+ is_tr = _looks_like_tool_result(msg)
538
+ if is_tr and not show_tool_results:
539
+ continue
540
+ text = text_of_user_message(msg)
541
+ if not text.strip():
542
+ continue
543
+ label = "TOOL" if is_tr else "USER"
544
+ color = C.GREY if is_tr else C.GREEN
545
+ emit(f"\n{color}{C.BOLD}▌{label}{C.RESET} {C.GREY}{ts_s}{C.RESET}")
546
+ for para in text.split("\n"):
547
+ if not para.strip():
548
+ emit()
549
+ else:
550
+ for line in wrap.wrap(para) or [" "]:
551
+ emit(line)
552
+ else:
553
+ text, tools = text_of_assistant_message(msg)
554
+ thinking = thinking_of_assistant_message(msg) if show_thinking else ""
555
+ if not (text.strip() or tools or thinking):
556
+ continue
557
+ emit(f"\n{C.MAGENTA}{C.BOLD}▌CLAUDE{C.RESET} {C.GREY}{ts_s}{C.RESET}")
558
+ if thinking:
559
+ emit(f" {C.DIM}— thinking —{C.RESET}")
560
+ for para in thinking.split("\n"):
561
+ if not para.strip():
562
+ emit()
563
+ else:
564
+ for line in wrap.wrap(para):
565
+ emit(C.DIM + line + C.RESET)
566
+ emit()
567
+ if text.strip():
568
+ for para in text.split("\n"):
569
+ if not para.strip():
570
+ emit()
571
+ else:
572
+ for line in wrap.wrap(para) or [" "]:
573
+ emit(line)
574
+ for tu in tools:
575
+ name = tu.get("name", "?")
576
+ inp = tu.get("input") or {}
577
+ summary = _summarize_tool_input(name, inp)
578
+ emit(f" {C.CYAN}↳ {name}{C.RESET} {C.GREY}{summary}{C.RESET}")
579
+
580
+
581
+ def _render_markdown(
582
+ path: Path,
583
+ out,
584
+ include_thinking: bool = False,
585
+ include_tools: bool = True,
586
+ include_tool_results: bool = False,
587
+ ) -> None:
588
+ info = summarize_session(path)
589
+
590
+ def emit(s: str = "") -> None:
591
+ out.write(s + "\n")
592
+
593
+ emit(f"# {info.title or info.first_user_prompt or info.session_id}")
594
+ emit()
595
+ emit(f"- **Session**: `{info.session_id}`")
596
+ emit(f"- **Project**: `{info.project_path}`")
597
+ if info.git_branch:
598
+ emit(f"- **Branch**: `{info.git_branch}`")
599
+ if info.first_ts:
600
+ emit(f"- **Started**: {info.first_ts.astimezone().strftime('%Y-%m-%d %H:%M %Z')}")
601
+ if info.duration:
602
+ emit(f"- **Duration**: {fmt_duration(info.duration)}")
603
+ if info.models:
604
+ emit(f"- **Models**: {', '.join(sorted(info.models))}")
605
+ emit(f"- **Messages**: {info.user_msgs} user, {info.assistant_msgs} assistant")
606
+ emit(f"- **Tokens**: in {fmt_num(info.input_tokens)}, out {fmt_num(info.output_tokens)}")
607
+ emit()
608
+ emit("---")
609
+ emit()
610
+
611
+ for rec in iter_records(path):
612
+ rtype = rec.get("type")
613
+ if rtype not in ("user", "assistant"):
614
+ continue
615
+ msg = rec.get("message") or {}
616
+ ts = parse_ts(rec.get("timestamp"))
617
+ ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else ""
618
+ if rtype == "user":
619
+ is_tr = _looks_like_tool_result(msg)
620
+ if is_tr and not include_tool_results:
621
+ continue
622
+ text = text_of_user_message(msg).strip()
623
+ if not text:
624
+ continue
625
+ label = "Tool Result" if is_tr else "User"
626
+ emit(f"## {label} · {ts_s}")
627
+ emit()
628
+ emit(text)
629
+ emit()
630
+ else:
631
+ text, tools = text_of_assistant_message(msg)
632
+ thinking = thinking_of_assistant_message(msg) if include_thinking else ""
633
+ if not (text.strip() or (tools and include_tools) or thinking):
634
+ continue
635
+ emit(f"## Claude · {ts_s}")
636
+ emit()
637
+ if thinking:
638
+ emit("<details><summary>thinking</summary>")
639
+ emit()
640
+ emit(thinking.strip())
641
+ emit()
642
+ emit("</details>")
643
+ emit()
644
+ if text.strip():
645
+ emit(text.strip())
646
+ emit()
647
+ if include_tools and tools:
648
+ for tu in tools:
649
+ name = tu.get("name", "?")
650
+ inp = tu.get("input") or {}
651
+ summary = _summarize_tool_input(name, inp)
652
+ emit(f"> **{name}** — `{summary}`")
653
+ emit()
654
+
655
+
656
+ # ============================================================
657
+ # TF-IDF related sessions
658
+ # ============================================================
659
+
660
+ _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\-]{2,}")
661
+
662
+ _STOPWORDS = set("""
663
+ the and that have for not are was you with this but his they from she will would there
664
+ their what about which when make like time just know take into your some could them than
665
+ other only over also after first well way even may use any its our two more new because
666
+ here only most one all how can do does did has had been being were out off back down out
667
+ then them than these those who whom whose where why because while still also yet such
668
+ between very each through during itself himself herself should would could shall might
669
+ must let put per via etc cant dont didnt isnt arent wasnt werent hasnt havent hadnt
670
+ doesnt wouldnt couldnt shouldnt wont thing things lot get got going want need says said
671
+ think thought know knew see saw look looked work works worked made make makes way ways
672
+ ok okay yes yeah sure right thanks please great good bad really maybe probably
673
+ """.split())
674
+
675
+
676
+ def _session_text_for_related(path: Path, max_chars: int = 30000) -> str:
677
+ chunks: list[str] = []
678
+ char_count = 0
679
+ title = ""
680
+ for rec in iter_records(path):
681
+ rtype = rec.get("type")
682
+ if rtype == "ai-title":
683
+ title = rec.get("aiTitle") or ""
684
+ continue
685
+ if rtype not in ("user", "assistant"):
686
+ continue
687
+ msg = rec.get("message") or {}
688
+ if rtype == "user":
689
+ if _looks_like_tool_result(msg):
690
+ continue
691
+ text = text_of_user_message(msg)
692
+ else:
693
+ text, _ = text_of_assistant_message(msg)
694
+ if not text:
695
+ continue
696
+ chunks.append(text)
697
+ char_count += len(text)
698
+ if char_count >= max_chars:
699
+ break
700
+ body = "\n".join(chunks)
701
+ # Boost the title since it's the highest-signal text in the corpus
702
+ return (title + " ") * 5 + body
703
+
704
+
705
+ def _tokenize(text: str) -> list[str]:
706
+ return [t.lower() for t in _TOKEN_RE.findall(text) if t.lower() not in _STOPWORDS]
707
+
708
+
709
+ def _tf_vector(tokens: list[str]) -> dict[str, float]:
710
+ c = Counter(tokens)
711
+ total = sum(c.values()) or 1
712
+ return {t: n / total for t, n in c.items()}
713
+
714
+
715
+ def _cosine(a: dict[str, float], b: dict[str, float]) -> float:
716
+ if not a or not b:
717
+ return 0.0
718
+ common = a.keys() & b.keys()
719
+ if not common:
720
+ return 0.0
721
+ dot = sum(a[t] * b[t] for t in common)
722
+ na = math.sqrt(sum(v * v for v in a.values()))
723
+ nb = math.sqrt(sum(v * v for v in b.values()))
724
+ if na == 0 or nb == 0:
725
+ return 0.0
726
+ return dot / (na * nb)
727
+
728
+
729
+ def compute_related(
730
+ target: Path,
731
+ paths: list[Path],
732
+ top_n: int = 10,
733
+ ) -> list[tuple[Path, float]]:
734
+ """Top-N similar sessions by TF-IDF cosine, excluding target."""
735
+ tokens_by_path: dict[Path, list[str]] = {}
736
+ df: Counter[str] = Counter()
737
+ for p in paths:
738
+ toks = _tokenize(_session_text_for_related(p))
739
+ tokens_by_path[p] = toks
740
+ df.update(set(toks))
741
+ n_docs = len(paths)
742
+ idf = {t: math.log((n_docs + 1) / (n + 1)) + 1 for t, n in df.items()}
743
+
744
+ def vec(p: Path) -> dict[str, float]:
745
+ tf = _tf_vector(tokens_by_path[p])
746
+ return {t: w * idf.get(t, 1.0) for t, w in tf.items()}
747
+
748
+ target_vec = vec(target)
749
+ scores: list[tuple[Path, float]] = []
750
+ for p in paths:
751
+ if p == target:
752
+ continue
753
+ s = _cosine(target_vec, vec(p))
754
+ if s > 0:
755
+ scores.append((p, s))
756
+ scores.sort(key=lambda x: x[1], reverse=True)
757
+ return scores[:top_n]
758
+
759
+
760
+ # ============================================================
761
+ # commands
762
+ # ============================================================
763
+
764
+ def cmd_projects(args: argparse.Namespace) -> int:
765
+ by_project: dict[str, list[SessionInfo]] = defaultdict(list)
766
+ for f in iter_session_files():
767
+ info = summarize_session(f)
768
+ by_project[info.project_path].append(info)
769
+ if not by_project:
770
+ print(f"No projects found in {ARCHIVE_ROOT}", file=sys.stderr)
771
+ return 1
772
+
773
+ rows = []
774
+ for path, sessions in by_project.items():
775
+ last = max((s.last_ts for s in sessions if s.last_ts), default=None)
776
+ in_tok = sum(s.input_tokens for s in sessions)
777
+ out_tok = sum(s.output_tokens for s in sessions)
778
+ rows.append(
779
+ (last or datetime.min.replace(tzinfo=timezone.utc), path, len(sessions), in_tok, out_tok)
780
+ )
781
+ rows.sort(reverse=True)
782
+
783
+ if args.json:
784
+ print(json.dumps([
785
+ {
786
+ "project": p, "sessions": n,
787
+ "input_tokens": i, "output_tokens": o,
788
+ "last_ts": last.isoformat() if last and last.year > 1 else None,
789
+ }
790
+ for last, p, n, i, o in rows
791
+ ], indent=2))
792
+ return 0
793
+
794
+ width = term_width()
795
+ path_w = max(30, width - 50)
796
+ print(f"{C.BOLD}{'project':<{path_w}} {'sessions':>9} {'in':>8} {'out':>8} {'last':>12}{C.RESET}")
797
+ print(hr())
798
+ for last, path, n, in_t, out_t in rows:
799
+ print(
800
+ f"{C.CYAN}{short_path(path, path_w):<{path_w}}{C.RESET} "
801
+ f"{n:>9} {fmt_num(in_t):>8} {fmt_num(out_t):>8} "
802
+ f"{C.GREY}{fmt_ts(last):>12}{C.RESET}"
803
+ )
804
+ print(hr())
805
+ print(f"{C.DIM}{sum(len(s) for s in by_project.values())} sessions across {len(by_project)} projects{C.RESET}")
806
+ return 0
807
+
808
+
809
+ def _sort_sessions(sessions: list[SessionInfo], key: str) -> list[SessionInfo]:
810
+ if key == "recent":
811
+ return sessions
812
+ if key == "tokens":
813
+ return sorted(sessions, key=lambda s: s.input_tokens + s.output_tokens, reverse=True)
814
+ if key == "messages":
815
+ return sorted(sessions, key=lambda s: s.total_msgs, reverse=True)
816
+ if key == "duration":
817
+ return sorted(sessions, key=lambda s: s.duration or timedelta(0), reverse=True)
818
+ if key == "input":
819
+ return sorted(sessions, key=lambda s: s.input_tokens, reverse=True)
820
+ if key == "output":
821
+ return sorted(sessions, key=lambda s: s.output_tokens, reverse=True)
822
+ return sessions
823
+
824
+
825
+ def _resolve_since(args: argparse.Namespace) -> datetime | None:
826
+ if getattr(args, "since", None):
827
+ return parse_when(args.since)
828
+ if getattr(args, "days", None):
829
+ return datetime.now(timezone.utc) - timedelta(days=args.days)
830
+ return None
831
+
832
+
833
+ def cmd_list(args: argparse.Namespace) -> int:
834
+ since = _resolve_since(args)
835
+ until = parse_when(getattr(args, "until", None))
836
+ sessions = collect_sessions(project_filter=args.project, since=since, until=until)
837
+ sessions = _sort_sessions(sessions, args.sort)
838
+ if args.limit:
839
+ sessions = sessions[: args.limit]
840
+
841
+ if args.json:
842
+ print(json.dumps([_session_to_dict(s) for s in sessions], indent=2, default=str))
843
+ return 0
844
+
845
+ if not sessions:
846
+ print("No sessions match.", file=sys.stderr)
847
+ return 1
848
+
849
+ width = term_width()
850
+ id_w = 8
851
+ proj_w = 22
852
+ title_w = max(30, width - id_w - proj_w - 28)
853
+ print(f"{C.BOLD}{'id':<{id_w}} {'project':<{proj_w}} {'msgs':>6} {'last':>10} {'title':<{title_w}}{C.RESET}")
854
+ print(hr())
855
+ for s in sessions:
856
+ proj = Path(s.project_path).name or s.project_path
857
+ title = s.title or s.first_user_prompt or f"{C.GREY}(no title){C.RESET}"
858
+ print(
859
+ f"{C.YELLOW}{s.session_id[:id_w]}{C.RESET} "
860
+ f"{C.CYAN}{truncate(proj, proj_w):<{proj_w}}{C.RESET} "
861
+ f"{s.total_msgs:>6} "
862
+ f"{C.GREY}{fmt_ts(s.last_ts):>10}{C.RESET} "
863
+ f"{truncate(title, title_w)}"
864
+ )
865
+ print(hr())
866
+ print(f"{C.DIM}{len(sessions)} sessions{C.RESET}")
867
+ return 0
868
+
869
+
870
+ def cmd_last(args: argparse.Namespace) -> int:
871
+ sessions = collect_sessions(project_filter=args.project)
872
+ if not sessions:
873
+ print("No sessions.", file=sys.stderr)
874
+ return 1
875
+ args.session = sessions[0].session_id
876
+ return cmd_show(args)
877
+
878
+
879
+ def cmd_search(args: argparse.Namespace) -> int:
880
+ if not args.query:
881
+ print("usage: sift search <query>", file=sys.stderr)
882
+ return 2
883
+ pat_flags = 0 if args.case_sensitive else re.IGNORECASE
884
+ try:
885
+ pat = re.compile(args.query if args.regex else re.escape(args.query), pat_flags)
886
+ except re.error as e:
887
+ print(f"{C.RED}bad regex: {e}{C.RESET}", file=sys.stderr)
888
+ return 2
889
+
890
+ roles: set[str] = set()
891
+ if args.user:
892
+ roles.add("user")
893
+ if args.assistant:
894
+ roles.add("assistant")
895
+ if not roles:
896
+ roles = {"user", "assistant"}
897
+
898
+ since = _resolve_since(args)
899
+ total_hits = 0
900
+ sessions_with_hits = 0
901
+
902
+ for f in iter_session_files(args.project):
903
+ try:
904
+ with open(f, "rb") as fh:
905
+ blob = fh.read()
906
+ if not pat.search(blob.decode("utf-8", errors="replace")):
907
+ continue
908
+ except OSError:
909
+ continue
910
+
911
+ hits = list(_search_records(f, pat, roles, since, context=args.context))
912
+ if not hits:
913
+ continue
914
+ sessions_with_hits += 1
915
+ total_hits += len(hits)
916
+
917
+ if args.files_only:
918
+ print(f.stem)
919
+ if args.limit and sessions_with_hits >= args.limit:
920
+ break
921
+ continue
922
+
923
+ info = summarize_session(f)
924
+ header = f"{C.BOLD}{C.YELLOW}{info.session_id[:8]}{C.RESET} {C.CYAN}{info.project_path}{C.RESET}"
925
+ if info.title:
926
+ header += f" {C.DIM}{info.title}{C.RESET}"
927
+ print(header)
928
+ for h in hits:
929
+ ts_s = fmt_ts(h["ts"])
930
+ role_color = C.GREEN if h["role"] == "user" else C.MAGENTA
931
+ print(f" {role_color}{h['role']:>9}{C.RESET} {C.GREY}{ts_s}{C.RESET}")
932
+ for line in h["lines"]:
933
+ highlighted = pat.sub(
934
+ lambda m: f"{C.BOLD}{C.RED}{m.group(0)}{C.RESET}", line
935
+ )
936
+ print(f" {highlighted}")
937
+ print()
938
+
939
+ if args.limit and sessions_with_hits >= args.limit:
940
+ break
941
+
942
+ if not args.files_only:
943
+ print(f"{C.DIM}{total_hits} matches in {sessions_with_hits} sessions{C.RESET}")
944
+ return 0 if total_hits else 1
945
+
946
+
947
+ def _search_records(path, pat, roles, since, context):
948
+ for rec in iter_records(path):
949
+ rtype = rec.get("type")
950
+ if rtype not in roles:
951
+ continue
952
+ ts = parse_ts(rec.get("timestamp"))
953
+ if since and ts and ts < since:
954
+ continue
955
+ msg = rec.get("message") or {}
956
+ if rtype == "user":
957
+ if _looks_like_tool_result(msg):
958
+ continue
959
+ text = text_of_user_message(msg)
960
+ else:
961
+ text, _ = text_of_assistant_message(msg)
962
+ if not text:
963
+ continue
964
+ m = list(pat.finditer(text))
965
+ if not m:
966
+ continue
967
+ lines = text.splitlines() or [text]
968
+ marked: set[int] = set()
969
+ for match in m:
970
+ upto = text[: match.start()]
971
+ ln = upto.count("\n")
972
+ for i in range(max(0, ln - context), min(len(lines), ln + context + 1)):
973
+ marked.add(i)
974
+ ordered = sorted(marked)
975
+ out_lines: list[str] = []
976
+ prev = -2
977
+ for i in ordered:
978
+ if i > prev + 1 and out_lines:
979
+ out_lines.append(f"{C.GREY}…{C.RESET}")
980
+ out_lines.append(lines[i][:240])
981
+ prev = i
982
+ yield {"role": rtype, "ts": ts, "lines": out_lines}
983
+
984
+
985
+ def cmd_show(args: argparse.Namespace) -> int:
986
+ f = find_session(args.session)
987
+ if f is None:
988
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
989
+ return 1
990
+ pager = _maybe_pager(getattr(args, "no_pager", False))
991
+ out = pager.stdin if pager else sys.stdout
992
+ _render_session(
993
+ f, out,
994
+ show_thinking=getattr(args, "thinking", False),
995
+ show_tool_results=getattr(args, "tool_results", False),
996
+ )
997
+ if pager:
998
+ try:
999
+ pager.stdin.close()
1000
+ pager.wait()
1001
+ except Exception:
1002
+ pass
1003
+ return 0
1004
+
1005
+
1006
+ _CODE_EXT = {
1007
+ "python": ".py", "py": ".py",
1008
+ "typescript": ".ts", "ts": ".ts",
1009
+ "javascript": ".js", "js": ".js",
1010
+ "tsx": ".tsx", "jsx": ".jsx",
1011
+ "rust": ".rs", "rs": ".rs",
1012
+ "go": ".go",
1013
+ "swift": ".swift",
1014
+ "kotlin": ".kt", "kt": ".kt",
1015
+ "java": ".java",
1016
+ "c": ".c", "cpp": ".cpp", "c++": ".cpp",
1017
+ "ruby": ".rb", "rb": ".rb",
1018
+ "php": ".php",
1019
+ "bash": ".sh", "sh": ".sh", "shell": ".sh", "zsh": ".sh",
1020
+ "json": ".json", "yaml": ".yml", "yml": ".yml",
1021
+ "toml": ".toml",
1022
+ "markdown": ".md", "md": ".md",
1023
+ "sql": ".sql",
1024
+ "html": ".html", "css": ".css", "scss": ".scss",
1025
+ "dockerfile": ".Dockerfile",
1026
+ "diff": ".diff", "patch": ".patch",
1027
+ }
1028
+
1029
+
1030
+ def cmd_code(args: argparse.Namespace) -> int:
1031
+ f = find_session(args.session)
1032
+ if f is None:
1033
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1034
+ return 1
1035
+ fence = re.compile(r"```([a-zA-Z0-9_+\-]*)\n(.*?)```", re.DOTALL)
1036
+ blocks: list[tuple[str, str, str]] = []
1037
+ for rec in iter_records(f):
1038
+ rtype = rec.get("type")
1039
+ if rtype not in ("user", "assistant"):
1040
+ continue
1041
+ msg = rec.get("message") or {}
1042
+ if rtype == "user":
1043
+ if _looks_like_tool_result(msg):
1044
+ continue
1045
+ text = text_of_user_message(msg)
1046
+ else:
1047
+ text, _ = text_of_assistant_message(msg)
1048
+ for m in fence.finditer(text):
1049
+ lang = m.group(1) or ""
1050
+ code = m.group(2).rstrip()
1051
+ if args.lang and lang.lower() != args.lang.lower():
1052
+ continue
1053
+ blocks.append((lang, rtype, code))
1054
+
1055
+ if not blocks:
1056
+ print("No fenced code blocks found.", file=sys.stderr)
1057
+ return 1
1058
+
1059
+ if args.out_dir:
1060
+ outdir = Path(args.out_dir)
1061
+ outdir.mkdir(parents=True, exist_ok=True)
1062
+ for i, (lang, role, code) in enumerate(blocks, 1):
1063
+ ext = _CODE_EXT.get(lang.lower(), ".txt") if lang else ".txt"
1064
+ out = outdir / f"{i:03d}-{role}{ext}"
1065
+ out.write_text(code + "\n")
1066
+ print(f"Wrote {len(blocks)} blocks to {outdir}/")
1067
+ return 0
1068
+
1069
+ for i, (lang, role, code) in enumerate(blocks, 1):
1070
+ print(f"{C.BOLD}{C.YELLOW}[{i}]{C.RESET} {C.CYAN}{lang or 'text'}{C.RESET} {C.GREY}({role}){C.RESET}")
1071
+ print(code)
1072
+ print()
1073
+ return 0
1074
+
1075
+
1076
+ def cmd_stats(args: argparse.Namespace) -> int:
1077
+ since = _resolve_since(args)
1078
+ until = parse_when(getattr(args, "until", None))
1079
+ sessions = collect_sessions(project_filter=args.project, since=since, until=until)
1080
+ if not sessions:
1081
+ print("No sessions.", file=sys.stderr)
1082
+ return 1
1083
+
1084
+ n = len(sessions)
1085
+ tot_user = sum(s.user_msgs for s in sessions)
1086
+ tot_ass = sum(s.assistant_msgs for s in sessions)
1087
+ tot_in = sum(s.input_tokens for s in sessions)
1088
+ tot_out = sum(s.output_tokens for s in sessions)
1089
+ tot_cr = sum(s.cache_read for s in sessions)
1090
+ tot_cc = sum(s.cache_create for s in sessions)
1091
+ avg_msgs = (tot_user + tot_ass) // n if n else 0
1092
+ avg_out = tot_out // n if n else 0
1093
+
1094
+ by_model: Counter[str] = Counter()
1095
+ by_day: Counter[str] = Counter()
1096
+ by_hour: Counter[int] = Counter()
1097
+ by_project: Counter[str] = Counter()
1098
+
1099
+ longest_by_msgs = max(sessions, key=lambda s: s.total_msgs, default=None)
1100
+ longest_by_dur = max(
1101
+ (s for s in sessions if s.duration), key=lambda s: s.duration, default=None
1102
+ )
1103
+ biggest_out = max(sessions, key=lambda s: s.output_tokens, default=None)
1104
+
1105
+ for s in sessions:
1106
+ for m in s.models:
1107
+ by_model[m] += 1
1108
+ if s.last_ts:
1109
+ local = s.last_ts.astimezone()
1110
+ by_day[local.strftime("%Y-%m-%d")] += 1
1111
+ by_hour[local.hour] += 1
1112
+ by_project[s.project_path] += 1
1113
+
1114
+ if args.json:
1115
+ print(json.dumps({
1116
+ "sessions": n,
1117
+ "user_messages": tot_user,
1118
+ "assistant_messages": tot_ass,
1119
+ "input_tokens": tot_in,
1120
+ "output_tokens": tot_out,
1121
+ "cache_read": tot_cr,
1122
+ "cache_create": tot_cc,
1123
+ "avg_messages_per_session": avg_msgs,
1124
+ "avg_output_per_session": avg_out,
1125
+ "by_model": dict(by_model),
1126
+ "by_day": dict(by_day),
1127
+ "by_hour": dict(by_hour),
1128
+ "by_project": dict(by_project),
1129
+ "longest_by_msgs": longest_by_msgs.session_id if longest_by_msgs else None,
1130
+ "longest_by_duration": longest_by_dur.session_id if longest_by_dur else None,
1131
+ "biggest_output": biggest_out.session_id if biggest_out else None,
1132
+ }, indent=2))
1133
+ return 0
1134
+
1135
+ label = C.DIM
1136
+ print(f"{C.BOLD}Summary{C.RESET}")
1137
+ print(f" {label}sessions {C.RESET}{n}")
1138
+ print(f" {label}messages {C.RESET}{tot_user} user, {tot_ass} assistant (avg {avg_msgs}/session)")
1139
+ print(f" {label}input tok {C.RESET}{fmt_num(tot_in)}")
1140
+ print(f" {label}output tok {C.RESET}{fmt_num(tot_out)} (avg {fmt_num(avg_out)}/session)")
1141
+ print(f" {label}cache read {C.RESET}{fmt_num(tot_cr)}")
1142
+ print(f" {label}cache create {C.RESET}{fmt_num(tot_cc)}")
1143
+
1144
+ print()
1145
+ print(f"{C.BOLD}By model{C.RESET}")
1146
+ for m, c in by_model.most_common():
1147
+ print(f" {C.CYAN}{m:<28}{C.RESET} {c}")
1148
+
1149
+ print()
1150
+ print(f"{C.BOLD}Top projects{C.RESET}")
1151
+ for p, c in by_project.most_common(10):
1152
+ print(f" {C.CYAN}{short_path(p, 50):<50}{C.RESET} {c}")
1153
+
1154
+ if longest_by_msgs:
1155
+ print()
1156
+ print(f"{C.BOLD}Standouts{C.RESET}")
1157
+ print(
1158
+ f" {label}most messages {C.RESET}"
1159
+ f"{C.YELLOW}{longest_by_msgs.session_id[:8]}{C.RESET} "
1160
+ f"{longest_by_msgs.total_msgs} msgs "
1161
+ f"{C.DIM}{truncate(longest_by_msgs.title or longest_by_msgs.first_user_prompt, 50)}{C.RESET}"
1162
+ )
1163
+ if longest_by_dur:
1164
+ print(
1165
+ f" {label}longest duration {C.RESET}"
1166
+ f"{C.YELLOW}{longest_by_dur.session_id[:8]}{C.RESET} "
1167
+ f"{fmt_duration(longest_by_dur.duration)} "
1168
+ f"{C.DIM}{truncate(longest_by_dur.title or longest_by_dur.first_user_prompt, 50)}{C.RESET}"
1169
+ )
1170
+ if biggest_out:
1171
+ print(
1172
+ f" {label}biggest output {C.RESET}"
1173
+ f"{C.YELLOW}{biggest_out.session_id[:8]}{C.RESET} "
1174
+ f"{fmt_num(biggest_out.output_tokens)} tok "
1175
+ f"{C.DIM}{truncate(biggest_out.title or biggest_out.first_user_prompt, 50)}{C.RESET}"
1176
+ )
1177
+
1178
+ if by_hour:
1179
+ print()
1180
+ print(f"{C.BOLD}Hour of day{C.RESET}")
1181
+ max_h = max(by_hour.values()) or 1
1182
+ bar_chars = " ▁▂▃▄▅▆▇█"
1183
+ row = "".join(
1184
+ bar_chars[int((by_hour.get(h, 0) / max_h) * (len(bar_chars) - 1))]
1185
+ for h in range(24)
1186
+ )
1187
+ print(f" {C.GREEN}{row}{C.RESET}")
1188
+ print(f" {C.DIM}0 6 12 18 23{C.RESET}")
1189
+
1190
+ if args.year:
1191
+ _print_year_heatmap(by_day)
1192
+ else:
1193
+ _print_thirty_day_strip(by_day)
1194
+
1195
+ return 0
1196
+
1197
+
1198
+ def _print_thirty_day_strip(by_day: Counter[str]) -> None:
1199
+ print()
1200
+ print(f"{C.BOLD}Last 30 days{C.RESET}")
1201
+ today = datetime.now().date()
1202
+ days = [today - timedelta(days=i) for i in range(29, -1, -1)]
1203
+ max_c = max((by_day.get(d.strftime("%Y-%m-%d"), 0) for d in days), default=0) or 1
1204
+ bar_chars = " ▁▂▃▄▅▆▇█"
1205
+ row = "".join(
1206
+ bar_chars[int((by_day.get(d.strftime("%Y-%m-%d"), 0) / max_c) * (len(bar_chars) - 1))]
1207
+ for d in days
1208
+ )
1209
+ print(f" {C.GREEN}{row}{C.RESET}")
1210
+ print(f" {C.DIM}{days[0].strftime('%b %d')}{' ' * (30 - 12)}{days[-1].strftime('%b %d')}{C.RESET}")
1211
+
1212
+
1213
+ def _print_year_heatmap(by_day: Counter[str]) -> None:
1214
+ print()
1215
+ print(f"{C.BOLD}Last 52 weeks{C.RESET}")
1216
+ today = datetime.now().date()
1217
+ days_back = 7 * 52 + today.weekday()
1218
+ start = today - timedelta(days=days_back)
1219
+ weeks = (today - start).days // 7 + 1
1220
+ max_c = max(by_day.values()) if by_day else 1
1221
+ chars = [" ", "·", "▪", "■", "█"]
1222
+ grid = [[" " for _ in range(weeks)] for _ in range(7)]
1223
+ for w in range(weeks):
1224
+ for d in range(7):
1225
+ day = start + timedelta(days=w * 7 + d)
1226
+ if day > today:
1227
+ continue
1228
+ c = by_day.get(day.strftime("%Y-%m-%d"), 0)
1229
+ if c == 0:
1230
+ grid[d][w] = f"{C.GREY}·{C.RESET} "
1231
+ else:
1232
+ level = min(4, int(math.ceil((c / max_c) * 4)))
1233
+ grid[d][w] = f"{C.GREEN}{chars[level]}{C.RESET} "
1234
+ for label, row in zip(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], grid):
1235
+ print(f" {C.DIM}{label}{C.RESET} {''.join(row)}")
1236
+ print(f" {C.DIM}{start.strftime('%b %Y')}{' ' * max(0, weeks - 14)}{today.strftime('%b %Y')}{C.RESET}")
1237
+
1238
+
1239
+ def cmd_path(args: argparse.Namespace) -> int:
1240
+ f = find_session(args.session)
1241
+ if f is None:
1242
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1243
+ return 1
1244
+ print(f)
1245
+ return 0
1246
+
1247
+
1248
+ def cmd_open(args: argparse.Namespace) -> int:
1249
+ f = find_session(args.session)
1250
+ if f is None:
1251
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1252
+ return 1
1253
+ editor = os.environ.get("EDITOR") or "vi"
1254
+ return subprocess.call([editor, str(f)])
1255
+
1256
+
1257
+ def cmd_resume(args: argparse.Namespace) -> int:
1258
+ f = find_session(args.session)
1259
+ if f is None:
1260
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1261
+ return 1
1262
+ info = summarize_session(f)
1263
+ cwd = info.cwd or str(f.parent)
1264
+ cmd = ["claude", "--resume", info.session_id]
1265
+ if args.exec:
1266
+ if Path(cwd).exists():
1267
+ os.chdir(cwd)
1268
+ try:
1269
+ os.execvp(cmd[0], cmd)
1270
+ except FileNotFoundError:
1271
+ print(f"{C.RED}claude not found on PATH{C.RESET}", file=sys.stderr)
1272
+ return 1
1273
+ print(f"cd {cwd} && {' '.join(cmd)}")
1274
+ print(f"{C.DIM}(use --exec to run it now){C.RESET}", file=sys.stderr)
1275
+ return 0
1276
+
1277
+
1278
+ def cmd_files(args: argparse.Namespace) -> int:
1279
+ f = find_session(args.session)
1280
+ if f is None:
1281
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1282
+ return 1
1283
+ by_file: dict[str, Counter[str]] = defaultdict(Counter)
1284
+ for _ts, tu in iter_tool_uses(f):
1285
+ name = tu.get("name", "")
1286
+ inp = tu.get("input") or {}
1287
+ if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
1288
+ fp = inp.get("file_path")
1289
+ if not fp:
1290
+ continue
1291
+ by_file[fp][name] += 1
1292
+ if not by_file:
1293
+ print("No file operations.", file=sys.stderr)
1294
+ return 1
1295
+ sorted_files = sorted(by_file.items(), key=lambda kv: -sum(kv[1].values()))
1296
+ name_w = max(20, term_width() - 35)
1297
+ print(f"{C.BOLD}{'file':<{name_w}} {'ops':>6} breakdown{C.RESET}")
1298
+ print(hr())
1299
+ for fp, counts in sorted_files:
1300
+ total = sum(counts.values())
1301
+ breakdown = " ".join(f"{C.CYAN}{op}×{n}{C.RESET}" for op, n in counts.most_common())
1302
+ print(f"{short_path(fp, name_w):<{name_w}} {total:>6} {breakdown}")
1303
+ print(hr())
1304
+ print(
1305
+ f"{C.DIM}{len(by_file)} files touched, "
1306
+ f"{sum(sum(c.values()) for c in by_file.values())} operations{C.RESET}"
1307
+ )
1308
+ return 0
1309
+
1310
+
1311
+ def cmd_tools(args: argparse.Namespace) -> int:
1312
+ f = find_session(args.session)
1313
+ if f is None:
1314
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1315
+ return 1
1316
+ counts: Counter[str] = Counter()
1317
+ for _ts, tu in iter_tool_uses(f):
1318
+ counts[tu.get("name", "?")] += 1
1319
+ if not counts:
1320
+ print("No tool calls.", file=sys.stderr)
1321
+ return 1
1322
+ max_c = max(counts.values())
1323
+ name_w = max(len(n) for n in counts)
1324
+ bar_w = max(20, term_width() - name_w - 16)
1325
+ print(f"{C.BOLD}{'tool':<{name_w}} {'count':>6} bar{C.RESET}")
1326
+ print(hr())
1327
+ for name, c in counts.most_common():
1328
+ bar_len = max(1, int((c / max_c) * bar_w))
1329
+ bar = C.CYAN + "█" * bar_len + C.RESET
1330
+ print(f"{name:<{name_w}} {c:>6} {bar}")
1331
+ print(hr())
1332
+ print(f"{C.DIM}{sum(counts.values())} tool calls across {len(counts)} tools{C.RESET}")
1333
+ return 0
1334
+
1335
+
1336
+ def cmd_bash(args: argparse.Namespace) -> int:
1337
+ f = find_session(args.session)
1338
+ if f is None:
1339
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1340
+ return 1
1341
+ n = 0
1342
+ for ts, tu in iter_tool_uses(f):
1343
+ if tu.get("name") != "Bash":
1344
+ continue
1345
+ cmd = (tu.get("input") or {}).get("command", "")
1346
+ if not cmd:
1347
+ continue
1348
+ ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else " "
1349
+ if args.plain:
1350
+ print(cmd)
1351
+ else:
1352
+ print(f"{C.GREY}{ts_s}{C.RESET} {cmd}")
1353
+ n += 1
1354
+ if n == 0:
1355
+ print("No Bash commands.", file=sys.stderr)
1356
+ return 1
1357
+ return 0
1358
+
1359
+
1360
+ def cmd_links(args: argparse.Namespace) -> int:
1361
+ f = find_session(args.session)
1362
+ if f is None:
1363
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1364
+ return 1
1365
+ url_counts: Counter[str] = Counter()
1366
+ by_role: dict[str, Counter[str]] = defaultdict(Counter)
1367
+ for rec in iter_records(f):
1368
+ rtype = rec.get("type")
1369
+ if rtype not in ("user", "assistant"):
1370
+ continue
1371
+ msg = rec.get("message") or {}
1372
+ if rtype == "user":
1373
+ role = "tool" if _looks_like_tool_result(msg) else "user"
1374
+ text = text_of_user_message(msg)
1375
+ else:
1376
+ text, _ = text_of_assistant_message(msg)
1377
+ role = "assistant"
1378
+ for u in extract_urls(text):
1379
+ url_counts[u] += 1
1380
+ by_role[role][u] += 1
1381
+ if not url_counts:
1382
+ print("No URLs found.", file=sys.stderr)
1383
+ return 1
1384
+ if args.plain:
1385
+ for u in url_counts:
1386
+ print(u)
1387
+ return 0
1388
+ print(f"{C.BOLD}{'#':>4} url{C.RESET}")
1389
+ print(hr())
1390
+ for u, c in url_counts.most_common():
1391
+ primary = max(by_role.keys(), key=lambda r: by_role[r].get(u, 0))
1392
+ rcolor = {"user": C.GREEN, "assistant": C.MAGENTA, "tool": C.GREY}.get(primary, "")
1393
+ print(f"{C.DIM}{c:>4}{C.RESET} {rcolor}{u}{C.RESET}")
1394
+ print(hr())
1395
+ print(f"{C.DIM}{len(url_counts)} unique URLs, {sum(url_counts.values())} mentions{C.RESET}")
1396
+ return 0
1397
+
1398
+
1399
+ def cmd_export(args: argparse.Namespace) -> int:
1400
+ f = find_session(args.session)
1401
+ if f is None:
1402
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1403
+ return 1
1404
+ if args.output:
1405
+ with open(args.output, "w", encoding="utf-8") as out:
1406
+ _render_markdown(
1407
+ f, out,
1408
+ include_thinking=args.thinking,
1409
+ include_tools=not args.no_tools,
1410
+ include_tool_results=args.tool_results,
1411
+ )
1412
+ print(f"Wrote {args.output}")
1413
+ else:
1414
+ _render_markdown(
1415
+ f, sys.stdout,
1416
+ include_thinking=args.thinking,
1417
+ include_tools=not args.no_tools,
1418
+ include_tool_results=args.tool_results,
1419
+ )
1420
+ return 0
1421
+
1422
+
1423
+ def cmd_related(args: argparse.Namespace) -> int:
1424
+ f = find_session(args.session)
1425
+ if f is None:
1426
+ print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
1427
+ return 1
1428
+ paths = list(iter_session_files(args.project))
1429
+ if f not in paths:
1430
+ paths.append(f)
1431
+ if len(paths) < 2:
1432
+ print("Need at least 2 sessions to compare.", file=sys.stderr)
1433
+ return 1
1434
+ print(f"{C.DIM}Comparing against {len(paths) - 1} sessions…{C.RESET}", file=sys.stderr)
1435
+ results = compute_related(f, paths, top_n=args.limit)
1436
+ if not results:
1437
+ print("No related sessions found.", file=sys.stderr)
1438
+ return 1
1439
+ width = term_width()
1440
+ title_w = max(30, width - 30)
1441
+ print(f"{C.BOLD}{'score':>6} {'id':<10} {'title':<{title_w}}{C.RESET}")
1442
+ print(hr())
1443
+ for p, score in results:
1444
+ info = summarize_session(p)
1445
+ title = info.title or info.first_user_prompt or "(no title)"
1446
+ print(f"{score:>6.3f} {C.YELLOW}{info.session_id[:8]}{C.RESET} {truncate(title, title_w)}")
1447
+ return 0
1448
+
1449
+
1450
+ def cmd_pick(args: argparse.Namespace) -> int:
1451
+ since = parse_when(getattr(args, "since", None))
1452
+ sessions = collect_sessions(project_filter=args.project, since=since)
1453
+ if not sessions:
1454
+ print("No sessions.", file=sys.stderr)
1455
+ return 1
1456
+
1457
+ chosen_id: str | None = None
1458
+ if shutil.which("fzf") and sys.stdin.isatty():
1459
+ lines = []
1460
+ for s in sessions:
1461
+ proj = Path(s.project_path).name or s.project_path
1462
+ title = s.title or s.first_user_prompt or "(no title)"
1463
+ ts = fmt_ts(s.last_ts)
1464
+ lines.append(f"{s.session_id}\t{ts:>10} {proj:<22} {title}")
1465
+ try:
1466
+ proc = subprocess.run(
1467
+ [
1468
+ "fzf", "--with-nth=2..", "--delimiter=\t", "--ansi",
1469
+ "--prompt=session> ",
1470
+ "--header=enter to select, ctrl-c to cancel",
1471
+ ],
1472
+ input="\n".join(lines), text=True, capture_output=True,
1473
+ )
1474
+ except FileNotFoundError:
1475
+ proc = None
1476
+ if proc is not None:
1477
+ if proc.returncode != 0:
1478
+ return 130
1479
+ chosen_id = proc.stdout.split("\t", 1)[0].strip()
1480
+
1481
+ if chosen_id is None:
1482
+ for i, s in enumerate(sessions[:30], 1):
1483
+ proj = Path(s.project_path).name or s.project_path
1484
+ title = s.title or s.first_user_prompt or "(no title)"
1485
+ print(f"{i:3} {s.session_id[:8]} {C.CYAN}{proj:<22}{C.RESET} {truncate(title, 60)}")
1486
+ try:
1487
+ choice = input("pick #: ").strip()
1488
+ except (EOFError, KeyboardInterrupt):
1489
+ return 130
1490
+ try:
1491
+ idx = int(choice) - 1
1492
+ except ValueError:
1493
+ return 1
1494
+ if not (0 <= idx < len(sessions)):
1495
+ print("out of range", file=sys.stderr)
1496
+ return 1
1497
+ chosen_id = sessions[idx].session_id
1498
+
1499
+ action = args.action
1500
+ args.session = chosen_id
1501
+ if action == "show":
1502
+ args.thinking = False
1503
+ args.tool_results = False
1504
+ args.no_pager = False
1505
+ return cmd_show(args)
1506
+ if action == "path":
1507
+ return cmd_path(args)
1508
+ if action == "resume":
1509
+ args.exec = False
1510
+ return cmd_resume(args)
1511
+ if action == "files":
1512
+ return cmd_files(args)
1513
+ if action == "tools":
1514
+ return cmd_tools(args)
1515
+ print(chosen_id)
1516
+ return 0
1517
+
1518
+
1519
+ def cmd_prompts(args: argparse.Namespace) -> int:
1520
+ since = _resolve_since(args)
1521
+ sessions = collect_sessions(project_filter=args.project, since=since)
1522
+ if args.limit:
1523
+ sessions = sessions[: args.limit]
1524
+ if not sessions:
1525
+ print("No sessions.", file=sys.stderr)
1526
+ return 1
1527
+ for s in sessions:
1528
+ if not s.first_user_prompt:
1529
+ continue
1530
+ proj = Path(s.project_path).name or s.project_path
1531
+ print(
1532
+ f"{C.YELLOW}{s.session_id[:8]}{C.RESET} "
1533
+ f"{C.GREY}{fmt_ts(s.last_ts):>10}{C.RESET} "
1534
+ f"{C.CYAN}{proj}{C.RESET}"
1535
+ )
1536
+ print(f" {truncate(s.first_user_prompt, term_width() - 4)}")
1537
+ print()
1538
+ return 0
1539
+
1540
+
1541
+ # ============================================================
1542
+ # shell completion
1543
+ # ============================================================
1544
+
1545
+ _COMMANDS = [
1546
+ "projects", "list", "ls", "search", "grep", "show", "code", "stats",
1547
+ "path", "open", "last", "resume", "files", "tools", "bash", "links",
1548
+ "export", "related", "pick", "prompts", "completion",
1549
+ ]
1550
+
1551
+ _BASH_COMPLETION = r"""# sift bash completion
1552
+ _sift_completion() {
1553
+ local cur
1554
+ COMPREPLY=()
1555
+ cur="${COMP_WORDS[COMP_CWORD]}"
1556
+ if [[ $COMP_CWORD -eq 1 ]]; then
1557
+ COMPREPLY=( $(compgen -W "%COMMANDS%" -- "$cur") )
1558
+ return 0
1559
+ fi
1560
+ case "${COMP_WORDS[1]}" in
1561
+ show|code|path|open|resume|files|tools|bash|links|export|related)
1562
+ local ids
1563
+ ids=$(sift list --limit 50 --json 2>/dev/null \
1564
+ | python3 -c "import json,sys
1565
+ for s in json.load(sys.stdin):
1566
+ print(s['session_id'])" 2>/dev/null)
1567
+ COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
1568
+ ;;
1569
+ esac
1570
+ }
1571
+ complete -F _sift_completion sift
1572
+ """
1573
+
1574
+ _ZSH_COMPLETION = r"""#compdef sift
1575
+ _sift() {
1576
+ local -a commands
1577
+ commands=(%COMMANDS_QUOTED%)
1578
+ if (( CURRENT == 2 )); then
1579
+ _describe 'command' commands
1580
+ elif (( CURRENT >= 3 )); then
1581
+ case "$words[2]" in
1582
+ show|code|path|open|resume|files|tools|bash|links|export|related)
1583
+ local -a ids
1584
+ ids=( ${(f)"$(sift list --limit 50 --json 2>/dev/null \
1585
+ | python3 -c 'import json,sys
1586
+ [print(s["session_id"]) for s in json.load(sys.stdin)]' 2>/dev/null)"} )
1587
+ _describe 'session' ids
1588
+ ;;
1589
+ esac
1590
+ fi
1591
+ }
1592
+ _sift "$@"
1593
+ """
1594
+
1595
+ _FISH_COMPLETION = r"""# sift fish completion
1596
+ complete -c sift -f
1597
+ %COMPLETIONS%
1598
+ function __sift_sessions
1599
+ sift list --limit 50 --json 2>/dev/null | python3 -c "import json,sys
1600
+ [print(s['session_id']) for s in json.load(sys.stdin)]" 2>/dev/null
1601
+ end
1602
+ for cmd in show code path open resume files tools bash links export related
1603
+ complete -c sift -n "__fish_seen_subcommand_from $cmd" -a "(__sift_sessions)"
1604
+ end
1605
+ """
1606
+
1607
+
1608
+ def cmd_completion(args: argparse.Namespace) -> int:
1609
+ shell = args.shell
1610
+ if shell == "bash":
1611
+ print(_BASH_COMPLETION.replace("%COMMANDS%", " ".join(_COMMANDS)))
1612
+ elif shell == "zsh":
1613
+ quoted = " ".join(f"'{c}'" for c in _COMMANDS)
1614
+ print(_ZSH_COMPLETION.replace("%COMMANDS_QUOTED%", quoted))
1615
+ elif shell == "fish":
1616
+ comps = "\n".join(
1617
+ f"complete -c sift -n '__fish_use_subcommand' -a '{c}'"
1618
+ for c in _COMMANDS
1619
+ )
1620
+ print(_FISH_COMPLETION.replace("%COMPLETIONS%", comps))
1621
+ return 0
1622
+
1623
+
1624
+ # ============================================================
1625
+ # TUI (launched when `sift` is run with no arguments)
1626
+ # ============================================================
1627
+
1628
+ def run_tui() -> int:
1629
+ """Launch the interactive TUI if attached to a TTY, else print help."""
1630
+ if not (sys.stdout.isatty() and sys.stdin.isatty()):
1631
+ build_parser().print_help()
1632
+ return 0
1633
+ try:
1634
+ import curses
1635
+ import locale
1636
+ except ImportError:
1637
+ build_parser().print_help()
1638
+ return 0
1639
+ locale.setlocale(locale.LC_ALL, "")
1640
+ try:
1641
+ return curses.wrapper(lambda stdscr: _SiftTUI(stdscr).run())
1642
+ except KeyboardInterrupt:
1643
+ return 130
1644
+
1645
+
1646
+ class _SiftTUI:
1647
+ # color pair ids
1648
+ P_TITLE = 1
1649
+ P_DIM = 2
1650
+ P_YELLOW = 3
1651
+ P_CYAN = 4
1652
+ P_GREEN = 5
1653
+ P_MAGENTA = 6
1654
+ P_RED = 7
1655
+ P_SELECT = 8
1656
+
1657
+ def __init__(self, stdscr):
1658
+ import curses
1659
+ self.curses = curses
1660
+ self.stdscr = stdscr
1661
+ self.all_sessions: list[SessionInfo] = []
1662
+ self.filtered: list[SessionInfo] = []
1663
+ self.cursor = 0
1664
+ self.scroll = 0
1665
+ self.filter_text = ""
1666
+ self.mode = "list" # list | filter | search | help
1667
+ self.message = ""
1668
+ self.message_is_error = False
1669
+ self.message_until = 0.0
1670
+ self.search_active = False
1671
+ self.search_query = ""
1672
+ self.search_matches: set[str] = set()
1673
+ self.preview_cache: dict[str, dict] = {}
1674
+ self._setup_colors()
1675
+ self._load()
1676
+
1677
+ # ---------- setup ----------
1678
+
1679
+ def _setup_colors(self):
1680
+ c = self.curses
1681
+ c.start_color()
1682
+ try:
1683
+ c.use_default_colors()
1684
+ bg = -1
1685
+ except c.error:
1686
+ bg = c.COLOR_BLACK
1687
+ c.init_pair(self.P_TITLE, c.COLOR_CYAN, bg)
1688
+ c.init_pair(self.P_DIM, c.COLOR_WHITE, bg)
1689
+ c.init_pair(self.P_YELLOW, c.COLOR_YELLOW, bg)
1690
+ c.init_pair(self.P_CYAN, c.COLOR_CYAN, bg)
1691
+ c.init_pair(self.P_GREEN, c.COLOR_GREEN, bg)
1692
+ c.init_pair(self.P_MAGENTA, c.COLOR_MAGENTA, bg)
1693
+ c.init_pair(self.P_RED, c.COLOR_RED, bg)
1694
+ c.init_pair(self.P_SELECT, c.COLOR_BLACK, c.COLOR_CYAN)
1695
+
1696
+ def attr(self, pair_id: int, bold: bool = False, dim: bool = False) -> int:
1697
+ a = self.curses.color_pair(pair_id)
1698
+ if bold:
1699
+ a |= self.curses.A_BOLD
1700
+ if dim:
1701
+ a |= self.curses.A_DIM
1702
+ return a
1703
+
1704
+ def _load(self):
1705
+ self.all_sessions = collect_sessions()
1706
+ self._apply_filter()
1707
+
1708
+ def _apply_filter(self):
1709
+ q = self.filter_text.lower().strip()
1710
+ out = self.all_sessions
1711
+ if q:
1712
+ out = [
1713
+ s for s in out
1714
+ if q in (s.title or "").lower()
1715
+ or q in s.project_path.lower()
1716
+ or q in (s.first_user_prompt or "").lower()
1717
+ ]
1718
+ if self.search_active:
1719
+ out = [s for s in out if s.session_id in self.search_matches]
1720
+ self.filtered = out
1721
+ if self.cursor >= len(self.filtered):
1722
+ self.cursor = max(0, len(self.filtered) - 1)
1723
+ if self.scroll > self.cursor:
1724
+ self.scroll = self.cursor
1725
+
1726
+ # ---------- main loop ----------
1727
+
1728
+ def run(self) -> int:
1729
+ c = self.curses
1730
+ c.curs_set(0)
1731
+ self.stdscr.keypad(True)
1732
+ while True:
1733
+ try:
1734
+ self._draw()
1735
+ except c.error:
1736
+ pass
1737
+ key = self.stdscr.getch()
1738
+ if self.mode == "filter":
1739
+ self._handle_filter(key)
1740
+ elif self.mode == "search":
1741
+ self._handle_search(key)
1742
+ elif self.mode == "help":
1743
+ self.mode = "list"
1744
+ else:
1745
+ if not self._handle_list(key):
1746
+ return 0
1747
+
1748
+ # ---------- key handlers ----------
1749
+
1750
+ def _handle_list(self, key) -> bool:
1751
+ c = self.curses
1752
+ if key in (ord('q'), 27):
1753
+ return False
1754
+ if key in (c.KEY_DOWN, ord('j')):
1755
+ self._move(1)
1756
+ elif key in (c.KEY_UP, ord('k')):
1757
+ self._move(-1)
1758
+ elif key == ord('g'):
1759
+ self.cursor = 0
1760
+ self.scroll = 0
1761
+ elif key == ord('G'):
1762
+ self.cursor = max(0, len(self.filtered) - 1)
1763
+ elif key in (c.KEY_NPAGE, ord('d')):
1764
+ self._move(self._page_size())
1765
+ elif key in (c.KEY_PPAGE, ord('u')):
1766
+ self._move(-self._page_size())
1767
+ elif key == ord('/'):
1768
+ self.mode = "filter"
1769
+ elif key == ord('s'):
1770
+ self.mode = "search"
1771
+ self.search_query = ""
1772
+ elif key in (ord('\n'), c.KEY_ENTER, 10, 13):
1773
+ self._open_session()
1774
+ elif key == ord('e'):
1775
+ self._export_session()
1776
+ elif key == ord('r'):
1777
+ self._copy_resume()
1778
+ elif key == ord('c'):
1779
+ self._copy_id()
1780
+ elif key == ord('p'):
1781
+ self._copy_path()
1782
+ elif key == ord('R'):
1783
+ self._reload()
1784
+ elif key == ord('?'):
1785
+ self.mode = "help"
1786
+ return True
1787
+
1788
+ def _handle_filter(self, key):
1789
+ c = self.curses
1790
+ if key == 27: # ESC
1791
+ self.filter_text = ""
1792
+ self._apply_filter()
1793
+ self.mode = "list"
1794
+ return
1795
+ if key in (ord('\n'), c.KEY_ENTER, 10, 13):
1796
+ self.mode = "list"
1797
+ return
1798
+ if key in (c.KEY_BACKSPACE, 127, 8):
1799
+ self.filter_text = self.filter_text[:-1]
1800
+ self._apply_filter()
1801
+ return
1802
+ if 32 <= key < 127:
1803
+ self.filter_text += chr(key)
1804
+ self._apply_filter()
1805
+
1806
+ def _handle_search(self, key):
1807
+ c = self.curses
1808
+ if key == 27: # ESC
1809
+ self.search_active = False
1810
+ self.search_query = ""
1811
+ self.search_matches.clear()
1812
+ self._apply_filter()
1813
+ self.mode = "list"
1814
+ return
1815
+ if key in (ord('\n'), c.KEY_ENTER, 10, 13):
1816
+ self._do_content_search()
1817
+ self.mode = "list"
1818
+ return
1819
+ if key in (c.KEY_BACKSPACE, 127, 8):
1820
+ self.search_query = self.search_query[:-1]
1821
+ return
1822
+ if 32 <= key < 127:
1823
+ self.search_query += chr(key)
1824
+
1825
+ def _do_content_search(self):
1826
+ q = self.search_query.strip()
1827
+ if not q:
1828
+ self.search_active = False
1829
+ self.search_matches.clear()
1830
+ self._apply_filter()
1831
+ return
1832
+ try:
1833
+ pat = re.compile(re.escape(q), re.IGNORECASE)
1834
+ except re.error:
1835
+ return
1836
+ matches: set[str] = set()
1837
+ for s in self.all_sessions:
1838
+ try:
1839
+ with open(s.file, "rb") as fh:
1840
+ blob = fh.read()
1841
+ if pat.search(blob.decode("utf-8", errors="replace")):
1842
+ matches.add(s.session_id)
1843
+ except OSError:
1844
+ continue
1845
+ self.search_active = True
1846
+ self.search_matches = matches
1847
+ self._flash(f'{len(matches)} sessions match "{q}"')
1848
+ self._apply_filter()
1849
+
1850
+ # ---------- actions ----------
1851
+
1852
+ def _open_session(self):
1853
+ if not self.filtered:
1854
+ return
1855
+ s = self.filtered[self.cursor]
1856
+ c = self.curses
1857
+ c.endwin()
1858
+ sys.stdout.write("\033[2J\033[H")
1859
+ sys.stdout.flush()
1860
+ pager = _maybe_pager(False)
1861
+ out = pager.stdin if pager else sys.stdout
1862
+ try:
1863
+ _render_session(s.file, out)
1864
+ except SystemExit:
1865
+ pass
1866
+ if pager:
1867
+ try:
1868
+ pager.stdin.close()
1869
+ pager.wait()
1870
+ except Exception:
1871
+ pass
1872
+ self.stdscr.clear()
1873
+ self.stdscr.refresh()
1874
+
1875
+ def _export_session(self):
1876
+ if not self.filtered:
1877
+ return
1878
+ s = self.filtered[self.cursor]
1879
+ out_dir = Path.home() / "sift-exports"
1880
+ try:
1881
+ out_dir.mkdir(exist_ok=True)
1882
+ except OSError as e:
1883
+ self._flash(f"mkdir failed: {e}", error=True)
1884
+ return
1885
+ slug = re.sub(r"[^\w\-]+", "_", s.title or "untitled")[:50].strip("_") or "untitled"
1886
+ out_path = out_dir / f"{s.session_id[:8]}-{slug}.md"
1887
+ try:
1888
+ with open(out_path, "w", encoding="utf-8") as f:
1889
+ _render_markdown(s.file, f)
1890
+ self._flash(f"exported → {out_path}")
1891
+ except OSError as e:
1892
+ self._flash(f"export failed: {e}", error=True)
1893
+
1894
+ def _copy_resume(self):
1895
+ if not self.filtered:
1896
+ return
1897
+ s = self.filtered[self.cursor]
1898
+ cwd = s.cwd or str(s.file.parent)
1899
+ self._copy_to_clipboard(f"cd {cwd} && claude --resume {s.session_id}", "resume command")
1900
+
1901
+ def _copy_id(self):
1902
+ if not self.filtered:
1903
+ return
1904
+ self._copy_to_clipboard(self.filtered[self.cursor].session_id, "session id")
1905
+
1906
+ def _copy_path(self):
1907
+ if not self.filtered:
1908
+ return
1909
+ self._copy_to_clipboard(str(self.filtered[self.cursor].file), "session path")
1910
+
1911
+ def _copy_to_clipboard(self, text: str, label: str):
1912
+ for tool in ("pbcopy", "wl-copy", "xclip"):
1913
+ exe = shutil.which(tool)
1914
+ if not exe:
1915
+ continue
1916
+ try:
1917
+ args = [exe]
1918
+ if tool == "xclip":
1919
+ args = [exe, "-selection", "clipboard"]
1920
+ subprocess.run(args, input=text, text=True, check=True)
1921
+ self._flash(f"copied {label} to clipboard")
1922
+ return
1923
+ except subprocess.CalledProcessError:
1924
+ continue
1925
+ # No clipboard tool — show the text instead
1926
+ self._flash(text)
1927
+
1928
+ def _reload(self):
1929
+ self.preview_cache.clear()
1930
+ self._load()
1931
+ self._flash(f"reloaded ({len(self.all_sessions)} sessions)")
1932
+
1933
+ def _flash(self, msg: str, error: bool = False):
1934
+ import time
1935
+ self.message = msg
1936
+ self.message_is_error = error
1937
+ self.message_until = time.time() + 4
1938
+
1939
+ # ---------- navigation ----------
1940
+
1941
+ def _move(self, delta: int):
1942
+ if not self.filtered:
1943
+ return
1944
+ self.cursor = max(0, min(len(self.filtered) - 1, self.cursor + delta))
1945
+
1946
+ def _page_size(self) -> int:
1947
+ h, _ = self.stdscr.getmaxyx()
1948
+ return max(1, h - 8)
1949
+
1950
+ # ---------- preview data ----------
1951
+
1952
+ def _get_preview(self, s: SessionInfo) -> dict:
1953
+ if s.session_id in self.preview_cache:
1954
+ return self.preview_cache[s.session_id]
1955
+ tools: Counter[str] = Counter()
1956
+ files: Counter[str] = Counter()
1957
+ for _ts, tu in iter_tool_uses(s.file):
1958
+ name = tu.get("name", "?")
1959
+ tools[name] += 1
1960
+ if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
1961
+ fp = (tu.get("input") or {}).get("file_path")
1962
+ if fp:
1963
+ files[fp] += 1
1964
+ data = {"tools": tools, "files": files}
1965
+ self.preview_cache[s.session_id] = data
1966
+ return data
1967
+
1968
+ # ---------- drawing primitives ----------
1969
+
1970
+ def _addnstr(self, y: int, x: int, text: str, max_len: int = -1, attr: int = 0):
1971
+ h, w = self.stdscr.getmaxyx()
1972
+ if y < 0 or y >= h or x < 0 or x >= w:
1973
+ return
1974
+ available = w - x
1975
+ if y == h - 1:
1976
+ available -= 1 # avoid bottom-right cell
1977
+ if available <= 0:
1978
+ return
1979
+ n = available if max_len < 0 else min(available, max_len)
1980
+ try:
1981
+ self.stdscr.addnstr(y, x, text, n, attr)
1982
+ except self.curses.error:
1983
+ pass
1984
+
1985
+ def _hline(self, y: int, x: int, n: int, attr: int = 0):
1986
+ # `curses.hline` requires a single byte char; use addnstr for Unicode safety.
1987
+ self._addnstr(y, x, "─" * n, max_len=n, attr=attr)
1988
+
1989
+ # ---------- main draw ----------
1990
+
1991
+ def _draw(self):
1992
+ self.stdscr.erase()
1993
+ h, w = self.stdscr.getmaxyx()
1994
+ if h < 10 or w < 60:
1995
+ self._addnstr(0, 0, "terminal too small (need 60×10)", attr=self.attr(self.P_RED))
1996
+ self.stdscr.refresh()
1997
+ return
1998
+
1999
+ self._draw_header(0, w)
2000
+ self._draw_status_bar(1, w)
2001
+ self._hline(2, 0, w, self.attr(self.P_DIM, dim=True))
2002
+
2003
+ list_w = w // 2 if w >= 100 else w
2004
+ preview_w = w - list_w - 1
2005
+ list_h = h - 4
2006
+
2007
+ self._draw_list(3, 0, list_w, list_h)
2008
+ if preview_w >= 30:
2009
+ for y in range(3, h - 1):
2010
+ self._addnstr(y, list_w, "│", attr=self.attr(self.P_DIM, dim=True))
2011
+ self._draw_preview(3, list_w + 2, preview_w - 2, list_h)
2012
+
2013
+ self._draw_footer(h - 1, w)
2014
+
2015
+ if self.mode == "help":
2016
+ self._draw_help_overlay()
2017
+
2018
+ self.stdscr.refresh()
2019
+
2020
+ def _draw_header(self, y: int, w: int):
2021
+ self._addnstr(y, 1, "▰ sift", attr=self.attr(self.P_CYAN, bold=True))
2022
+ self._addnstr(y, 9, f"v{__version__}", attr=self.attr(self.P_DIM, dim=True))
2023
+ info = f" · {len(self.filtered)}/{len(self.all_sessions)} sessions"
2024
+ self._addnstr(y, 9 + len(__version__) + 1, info, attr=self.attr(self.P_DIM))
2025
+ hint = "? help q quit"
2026
+ self._addnstr(y, max(0, w - len(hint) - 2), hint, attr=self.attr(self.P_DIM, dim=True))
2027
+
2028
+ def _draw_status_bar(self, y: int, w: int):
2029
+ if self.mode == "filter":
2030
+ self._addnstr(y, 1, "filter: ", attr=self.attr(self.P_YELLOW, bold=True))
2031
+ self._addnstr(y, 9, self.filter_text + "▎",
2032
+ attr=self.attr(self.P_TITLE, bold=True))
2033
+ return
2034
+ if self.mode == "search":
2035
+ self._addnstr(y, 1, "search: ", attr=self.attr(self.P_MAGENTA, bold=True))
2036
+ self._addnstr(y, 9, self.search_query + "▎",
2037
+ attr=self.attr(self.P_TITLE, bold=True))
2038
+ hint = " (enter: search inside conversations · esc: cancel)"
2039
+ self._addnstr(y, 9 + len(self.search_query) + 2, hint,
2040
+ attr=self.attr(self.P_DIM, dim=True))
2041
+ return
2042
+ parts = []
2043
+ if self.filter_text:
2044
+ parts.append(f"filter: {self.filter_text}")
2045
+ if self.search_active:
2046
+ parts.append(f'content: "{self.search_query}" ({len(self.search_matches)})')
2047
+ if parts:
2048
+ self._addnstr(y, 1, " · ".join(parts), attr=self.attr(self.P_TITLE))
2049
+ else:
2050
+ self._addnstr(
2051
+ y, 1,
2052
+ "press / to filter titles & paths · s to search conversation contents",
2053
+ attr=self.attr(self.P_DIM, dim=True),
2054
+ )
2055
+
2056
+ def _draw_list(self, y: int, x: int, w: int, h: int):
2057
+ if not self.filtered:
2058
+ self._addnstr(y + 1, x + 2, "no sessions match",
2059
+ attr=self.attr(self.P_DIM, dim=True))
2060
+ return
2061
+ if self.cursor < self.scroll:
2062
+ self.scroll = self.cursor
2063
+ if self.cursor >= self.scroll + h:
2064
+ self.scroll = self.cursor - h + 1
2065
+ visible = self.filtered[self.scroll:self.scroll + h]
2066
+ for i, s in enumerate(visible):
2067
+ self._draw_list_row(y + i, x, w, s, self.scroll + i == self.cursor)
2068
+ # mini scrollbar on the right edge
2069
+ total = len(self.filtered)
2070
+ if total > h:
2071
+ top_frac = self.scroll / total
2072
+ bar_h = max(1, int(h * h / total))
2073
+ bar_y = y + int(top_frac * h)
2074
+ for i in range(bar_h):
2075
+ if bar_y + i < y + h:
2076
+ self._addnstr(bar_y + i, x + w - 1, "▐",
2077
+ attr=self.attr(self.P_CYAN))
2078
+
2079
+ def _draw_list_row(self, y: int, x: int, w: int, s: SessionInfo, is_sel: bool):
2080
+ marker = "▸ " if is_sel else " "
2081
+ sid = s.session_id[:7]
2082
+ proj = truncate(Path(s.project_path).name or s.project_path, 18)
2083
+ ts = fmt_ts(s.last_ts)
2084
+ title = s.title or s.first_user_prompt or "(no title)"
2085
+
2086
+ # column layout (offsets from row start):
2087
+ # 0 marker (2) | 2 sid (7) | 10 proj (18)
2088
+ # 30 msgs (5R) | 37 ts (10R) | 49 title
2089
+ if is_sel:
2090
+ row_attr = self.attr(self.P_SELECT, bold=True)
2091
+ self._addnstr(y, x, " " * w, attr=row_attr)
2092
+ self._addnstr(y, x, marker + sid, attr=row_attr)
2093
+ self._addnstr(y, x + 10, proj, attr=row_attr)
2094
+ self._addnstr(y, x + 30, f"{s.total_msgs:>5}", attr=row_attr)
2095
+ self._addnstr(y, x + 37, f"{ts:>10}", attr=row_attr)
2096
+ self._addnstr(y, x + 49, truncate(title, max(1, w - 50)), attr=row_attr)
2097
+ else:
2098
+ self._addnstr(y, x, marker, attr=self.attr(self.P_DIM, dim=True))
2099
+ self._addnstr(y, x + 2, sid, attr=self.attr(self.P_YELLOW))
2100
+ self._addnstr(y, x + 10, proj, attr=self.attr(self.P_CYAN))
2101
+ self._addnstr(y, x + 30, f"{s.total_msgs:>5}",
2102
+ attr=self.attr(self.P_DIM))
2103
+ self._addnstr(y, x + 37, f"{ts:>10}",
2104
+ attr=self.attr(self.P_DIM, dim=True))
2105
+ self._addnstr(y, x + 49, truncate(title, max(1, w - 50)))
2106
+
2107
+ def _draw_preview(self, y: int, x: int, w: int, h: int):
2108
+ if not self.filtered:
2109
+ return
2110
+ s = self.filtered[self.cursor]
2111
+ line = y
2112
+ end = y + h
2113
+
2114
+ def label_row(label: str, value: str, value_attr: int = 0):
2115
+ nonlocal line
2116
+ if line >= end:
2117
+ return
2118
+ self._addnstr(line, x, f"{label:<9}",
2119
+ attr=self.attr(self.P_DIM, dim=True))
2120
+ self._addnstr(line, x + 9, value, max_len=w - 9, attr=value_attr)
2121
+ line += 1
2122
+
2123
+ title = s.title or s.first_user_prompt or "(no title)"
2124
+ self._addnstr(line, x, title, max_len=w,
2125
+ attr=self.attr(self.P_CYAN, bold=True))
2126
+ line += 1
2127
+ self._addnstr(line, x, s.session_id, max_len=w,
2128
+ attr=self.attr(self.P_YELLOW, dim=True))
2129
+ line += 2
2130
+
2131
+ proj = s.project_path
2132
+ if len(proj) > w - 9:
2133
+ proj = "…" + proj[-(w - 10):]
2134
+ label_row("project", proj, value_attr=self.attr(self.P_CYAN))
2135
+ if s.git_branch:
2136
+ label_row("branch", s.git_branch)
2137
+ if s.first_ts:
2138
+ label_row("started",
2139
+ s.first_ts.astimezone().strftime("%Y-%m-%d %H:%M"))
2140
+ if s.duration:
2141
+ label_row("duration", fmt_duration(s.duration))
2142
+ if s.models:
2143
+ label_row("models", ", ".join(sorted(s.models)),
2144
+ value_attr=self.attr(self.P_MAGENTA))
2145
+ label_row("messages",
2146
+ f"{s.user_msgs} user, {s.assistant_msgs} assistant")
2147
+ label_row("tokens",
2148
+ f"in {fmt_num(s.input_tokens)} out {fmt_num(s.output_tokens)}")
2149
+ line += 1
2150
+
2151
+ if s.first_user_prompt and line < end - 2:
2152
+ self._addnstr(line, x, "first prompt",
2153
+ attr=self.attr(self.P_DIM, dim=True))
2154
+ line += 1
2155
+ for chunk in textwrap.wrap(s.first_user_prompt, max(10, w)):
2156
+ if line >= end - 1:
2157
+ break
2158
+ self._addnstr(line, x, chunk, max_len=w)
2159
+ line += 1
2160
+ line += 1
2161
+
2162
+ if line >= end - 2:
2163
+ return
2164
+ preview = self._get_preview(s)
2165
+ tools = preview["tools"]
2166
+ files = preview["files"]
2167
+ if tools:
2168
+ self._addnstr(line, x, "top tools",
2169
+ attr=self.attr(self.P_DIM, dim=True))
2170
+ line += 1
2171
+ max_t = max(tools.values())
2172
+ bar_w = max(6, w - 22)
2173
+ for name, c in tools.most_common(5):
2174
+ if line >= end - 1:
2175
+ break
2176
+ bar_len = max(1, int((c / max_t) * bar_w))
2177
+ self._addnstr(line, x, f" {name:<12}",
2178
+ attr=self.attr(self.P_CYAN))
2179
+ self._addnstr(line, x + 14, "█" * bar_len,
2180
+ max_len=bar_w,
2181
+ attr=self.attr(self.P_CYAN, dim=True))
2182
+ self._addnstr(line, x + 14 + bar_len + 1, str(c),
2183
+ attr=self.attr(self.P_DIM, dim=True))
2184
+ line += 1
2185
+ line += 1
2186
+ if files and line < end - 1:
2187
+ self._addnstr(line, x, "top files",
2188
+ attr=self.attr(self.P_DIM, dim=True))
2189
+ line += 1
2190
+ for fp, c in files.most_common(4):
2191
+ if line >= end:
2192
+ break
2193
+ sp = short_path(fp, w - 6)
2194
+ self._addnstr(line, x, f" {sp}",
2195
+ attr=self.attr(self.P_DIM))
2196
+ self._addnstr(line, x + len(sp) + 3, f"×{c}",
2197
+ attr=self.attr(self.P_DIM, dim=True))
2198
+ line += 1
2199
+
2200
+ def _draw_footer(self, y: int, w: int):
2201
+ import time
2202
+ if self.message and time.time() < self.message_until:
2203
+ attr = (self.attr(self.P_RED, bold=True)
2204
+ if self.message_is_error
2205
+ else self.attr(self.P_GREEN, bold=True))
2206
+ self._addnstr(y, 1, self.message, attr=attr)
2207
+ return
2208
+ keys = "↑↓ nav / filter s search ⏎ show e export r resume c copy id ? help q quit"
2209
+ self._addnstr(y, 1, keys, attr=self.attr(self.P_DIM, dim=True))
2210
+
2211
+ def _draw_help_overlay(self):
2212
+ h, w = self.stdscr.getmaxyx()
2213
+ lines = [
2214
+ ("navigation", True),
2215
+ (" ↑ / ↓ k / j move cursor", False),
2216
+ (" g / G top / bottom", False),
2217
+ (" PgUp / PgDn u / d page", False),
2218
+ ("", False),
2219
+ ("filter & search", True),
2220
+ (" / filter list (live, on titles & paths)", False),
2221
+ (" s search inside conversation contents", False),
2222
+ (" esc clear the current filter or search", False),
2223
+ ("", False),
2224
+ ("actions", True),
2225
+ (" enter render the selected session", False),
2226
+ (" e export session to ~/sift-exports/<id>.md", False),
2227
+ (" r copy `cd … && claude --resume …` to clipboard", False),
2228
+ (" c copy session id to clipboard", False),
2229
+ (" p copy session JSONL path to clipboard", False),
2230
+ (" R reload the archive from disk", False),
2231
+ ("", False),
2232
+ (" ? this help", False),
2233
+ (" q quit", False),
2234
+ ]
2235
+ box_w = min(70, w - 4)
2236
+ box_h = min(len(lines) + 5, h - 2)
2237
+ by = (h - box_h) // 2
2238
+ bx = (w - box_w) // 2
2239
+ # top border
2240
+ title = " keybindings "
2241
+ top = "╭" + title + "─" * (box_w - len(title) - 2) + "╮"
2242
+ self._addnstr(by, bx, top, attr=self.attr(self.P_CYAN, bold=True))
2243
+ # body
2244
+ for i in range(box_h - 2):
2245
+ self._addnstr(by + 1 + i, bx, "│", attr=self.attr(self.P_CYAN))
2246
+ self._addnstr(by + 1 + i, bx + 1, " " * (box_w - 2))
2247
+ self._addnstr(by + 1 + i, bx + box_w - 1, "│",
2248
+ attr=self.attr(self.P_CYAN))
2249
+ # bottom border
2250
+ bottom = "╰" + "─" * (box_w - 2) + "╯"
2251
+ self._addnstr(by + box_h - 1, bx, bottom,
2252
+ attr=self.attr(self.P_CYAN, bold=True))
2253
+ # content
2254
+ for i, (text, is_section) in enumerate(lines[:box_h - 4]):
2255
+ if is_section:
2256
+ self._addnstr(by + 2 + i, bx + 2, text,
2257
+ max_len=box_w - 4,
2258
+ attr=self.attr(self.P_YELLOW, bold=True))
2259
+ else:
2260
+ self._addnstr(by + 2 + i, bx + 2, text, max_len=box_w - 4)
2261
+ hint = "press any key to close"
2262
+ self._addnstr(by + box_h - 2, bx + (box_w - len(hint)) // 2,
2263
+ hint, attr=self.attr(self.P_DIM, dim=True))
2264
+
2265
+
2266
+ # ============================================================
2267
+ # argparse
2268
+ # ============================================================
2269
+
2270
+ def _sub(sub, name: str, *, help: str, aliases=None, epilog: str | None = None):
2271
+ return sub.add_parser(
2272
+ name,
2273
+ help=help,
2274
+ aliases=aliases or [],
2275
+ epilog=epilog,
2276
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2277
+ )
2278
+
2279
+
2280
+ def build_parser() -> argparse.ArgumentParser:
2281
+ p = argparse.ArgumentParser(
2282
+ prog="sift",
2283
+ description="Mine your Claude Code conversation archive.",
2284
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2285
+ epilog=textwrap.dedent("""\
2286
+ archive root: $SIFT_ARCHIVE (default: ~/.claude/projects)
2287
+
2288
+ running `sift` with no arguments opens the interactive TUI.
2289
+
2290
+ quick start
2291
+ sift interactive TUI (this is the default)
2292
+ sift projects summary per project
2293
+ sift list --days 7 recent sessions
2294
+ sift last render your most recent session
2295
+ sift pick one-shot picker (uses fzf if available)
2296
+ sift search "prompt caching" full-text search across every session
2297
+ sift related <id> find sessions similar to one you know
2298
+ sift stats --year 52-week activity heatmap
2299
+
2300
+ time filters accept '7d', '2w', '12h', or ISO dates (e.g. '2026-05-01').
2301
+ session IDs accept unique prefixes — `sift show 003f1707` works if unique.
2302
+ """),
2303
+ )
2304
+ p.add_argument("--version", action="version", version=f"sift {__version__}")
2305
+ sub = p.add_subparsers(dest="cmd", metavar="<command>")
2306
+
2307
+ sp = _sub(sub, "projects", help="list projects with session counts")
2308
+ sp.add_argument("--json", action="store_true")
2309
+ sp.set_defaults(func=cmd_projects)
2310
+
2311
+ sp = _sub(sub, "list", aliases=["ls"], help="list sessions, newest first")
2312
+ sp.add_argument("-p", "--project", help="filter by substring of project path")
2313
+ sp.add_argument("--days", type=int, help="sessions touched in the last N days")
2314
+ sp.add_argument("--since", help="lower time bound (e.g. 7d, 2w, 2026-05-01)")
2315
+ sp.add_argument("--until", help="upper time bound")
2316
+ sp.add_argument("--sort", choices=["recent", "tokens", "messages", "duration", "input", "output"],
2317
+ default="recent", help="sort key (default: recent)")
2318
+ sp.add_argument("--limit", type=int, default=40, help="max rows (default 40; 0 = no limit)")
2319
+ sp.add_argument("--json", action="store_true")
2320
+ sp.set_defaults(func=cmd_list)
2321
+
2322
+ sp = _sub(sub, "last", help="render the most recent session")
2323
+ sp.add_argument("-p", "--project", help="filter by substring of project path")
2324
+ sp.add_argument("--thinking", action="store_true")
2325
+ sp.add_argument("--tool-results", action="store_true")
2326
+ sp.add_argument("--no-pager", action="store_true")
2327
+ sp.set_defaults(func=cmd_last)
2328
+
2329
+ sp = _sub(sub, "search", aliases=["grep"], help="full-text search across sessions")
2330
+ sp.add_argument("query")
2331
+ sp.add_argument("-p", "--project")
2332
+ sp.add_argument("--days", type=int)
2333
+ sp.add_argument("--since", help="lower time bound")
2334
+ sp.add_argument("--regex", action="store_true")
2335
+ sp.add_argument("--case-sensitive", action="store_true")
2336
+ sp.add_argument("--user", action="store_true", help="only user messages")
2337
+ sp.add_argument("--assistant", action="store_true", help="only assistant messages")
2338
+ sp.add_argument("-C", "--context", type=int, default=1, help="lines of context")
2339
+ sp.add_argument("--limit", type=int, default=0, help="stop after N matching sessions")
2340
+ sp.add_argument("-l", "--files-only", action="store_true",
2341
+ help="print only session IDs of matching sessions")
2342
+ sp.set_defaults(func=cmd_search)
2343
+
2344
+ sp = _sub(sub, "show", help="render a session readably")
2345
+ sp.add_argument("session")
2346
+ sp.add_argument("--thinking", action="store_true", help="include thinking blocks")
2347
+ sp.add_argument("--tool-results", action="store_true", help="include tool-result user turns")
2348
+ sp.add_argument("--no-pager", action="store_true")
2349
+ sp.set_defaults(func=cmd_show)
2350
+
2351
+ sp = _sub(sub, "code", help="extract fenced code blocks")
2352
+ sp.add_argument("session")
2353
+ sp.add_argument("--lang", help="only blocks with this language tag")
2354
+ sp.add_argument("--out-dir", help="write each block to a file in this dir")
2355
+ sp.set_defaults(func=cmd_code)
2356
+
2357
+ sp = _sub(sub, "stats", help="usage summary + activity charts")
2358
+ sp.add_argument("-p", "--project")
2359
+ sp.add_argument("--days", type=int)
2360
+ sp.add_argument("--since")
2361
+ sp.add_argument("--until")
2362
+ sp.add_argument("--year", action="store_true",
2363
+ help="show 52-week heatmap instead of 30-day strip")
2364
+ sp.add_argument("--json", action="store_true")
2365
+ sp.set_defaults(func=cmd_stats)
2366
+
2367
+ sp = _sub(sub, "path", help="print the JSONL file path")
2368
+ sp.add_argument("session")
2369
+ sp.set_defaults(func=cmd_path)
2370
+
2371
+ sp = _sub(sub, "open", help="open the JSONL in $EDITOR")
2372
+ sp.add_argument("session")
2373
+ sp.set_defaults(func=cmd_open)
2374
+
2375
+ sp = _sub(sub, "resume", help="print the `claude --resume` command for a session")
2376
+ sp.add_argument("session")
2377
+ sp.add_argument("--exec", action="store_true",
2378
+ help="cd into the session's cwd and exec claude")
2379
+ sp.set_defaults(func=cmd_resume)
2380
+
2381
+ sp = _sub(sub, "files", help="files touched by tool calls in a session")
2382
+ sp.add_argument("session")
2383
+ sp.set_defaults(func=cmd_files)
2384
+
2385
+ sp = _sub(sub, "tools", help="tool usage breakdown for a session")
2386
+ sp.add_argument("session")
2387
+ sp.set_defaults(func=cmd_tools)
2388
+
2389
+ sp = _sub(sub, "bash", help="list Bash commands from a session")
2390
+ sp.add_argument("session")
2391
+ sp.add_argument("--plain", action="store_true",
2392
+ help="commands only, no timestamps")
2393
+ sp.set_defaults(func=cmd_bash)
2394
+
2395
+ sp = _sub(sub, "links", help="extract URLs from a session")
2396
+ sp.add_argument("session")
2397
+ sp.add_argument("--plain", action="store_true")
2398
+ sp.set_defaults(func=cmd_links)
2399
+
2400
+ sp = _sub(sub, "export", help="export a session as clean markdown")
2401
+ sp.add_argument("session")
2402
+ sp.add_argument("-o", "--output", help="write to file instead of stdout")
2403
+ sp.add_argument("--thinking", action="store_true",
2404
+ help="include collapsible thinking blocks")
2405
+ sp.add_argument("--no-tools", action="store_true",
2406
+ help="omit tool-call summaries")
2407
+ sp.add_argument("--tool-results", action="store_true",
2408
+ help="include tool-result messages")
2409
+ sp.set_defaults(func=cmd_export)
2410
+
2411
+ sp = _sub(sub, "related",
2412
+ help="find sessions similar to a given one (TF-IDF)")
2413
+ sp.add_argument("session")
2414
+ sp.add_argument("-p", "--project", help="restrict comparison to one project")
2415
+ sp.add_argument("--limit", type=int, default=10)
2416
+ sp.set_defaults(func=cmd_related)
2417
+
2418
+ sp = _sub(sub, "pick",
2419
+ help="interactive session picker (uses fzf if installed)")
2420
+ sp.add_argument("-p", "--project")
2421
+ sp.add_argument("--since")
2422
+ sp.add_argument("--action",
2423
+ choices=["show", "path", "resume", "files", "tools", "id"],
2424
+ default="show",
2425
+ help="what to do after picking (default: show)")
2426
+ sp.set_defaults(func=cmd_pick)
2427
+
2428
+ sp = _sub(sub, "prompts",
2429
+ help="first user prompts across recent sessions")
2430
+ sp.add_argument("-p", "--project")
2431
+ sp.add_argument("--days", type=int)
2432
+ sp.add_argument("--since")
2433
+ sp.add_argument("--limit", type=int, default=20)
2434
+ sp.set_defaults(func=cmd_prompts)
2435
+
2436
+ sp = _sub(sub, "completion",
2437
+ help="generate shell completion (bash/zsh/fish)")
2438
+ sp.add_argument("shell", choices=["bash", "zsh", "fish"])
2439
+ sp.set_defaults(func=cmd_completion)
2440
+
2441
+ return p
2442
+
2443
+
2444
+ def main(argv: list[str] | None = None) -> int:
2445
+ parser = build_parser()
2446
+ args = parser.parse_args(argv)
2447
+ if not getattr(args, "cmd", None):
2448
+ # No subcommand: launch the TUI if interactive, else print help.
2449
+ return run_tui()
2450
+ try:
2451
+ return args.func(args)
2452
+ except BrokenPipeError:
2453
+ return 0
2454
+ except KeyboardInterrupt:
2455
+ return 130
2456
+
2457
+
2458
+ if __name__ == "__main__":
2459
+ sys.exit(main())