Claude Skill

session-search

Find context from past Claude Code (CLI) and Claude Cowork (desktop) sessions on this Mac. Use when the user wants to recall something they did before but can't find it , phrasings like "where did I work on X", "find that session where I…", "when did I last do Y", "pull up the co

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download techwolf-ai-ai-first-toolkit-plugins_session-tools_skills_session-search-2ee7841.zip · 10 KB
Part of techwolf-ai/ai-first-toolkit — 20 skills

Install

skills CLI npx skills add https://github.com/techwolf-ai/ai-first-toolkit/tree/main/plugins/session-tools/skills/session-search
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install techwolf-ai-ai-first-toolkit@llmmart
Git git clone https://github.com/techwolf-ai/ai-first-toolkit.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole techwolf-ai/ai-first-toolkit collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

session-search

Platforms: Claude Code / Cowork and Codex. find_sessions.py and show_session.py detect the host (via the platform stamp install.sh writes, or AI_FIRST_PLATFORM) and route to the right store: Claude Code (~/.claude/projects) + Cowork transcripts, or Codex rollouts (~/.codex/sessions/**/rollout-*.jsonl). Both support list, time/cwd/title filters, and full-text --grep. Antigravity is not supported: its IDE conversations are AEAD-encrypted at rest (~/.gemini/antigravity/conversations/*.pb) and its unencrypted CLI store carries no parseable turn content, so the skill prints a clear "not available" message and exits.

Two scripts in scripts/ locate past sessions and dump their content:

  • find_sessions.py , discover + filter sessions (metadata + optional full-text grep).
  • show_session.py , print a single session's conversation (user/assistant turns) in readable form.

Both read directly from disk , no API calls, no auth. They resolve paths from $HOME so they work for any macOS user:

  • Claude Code CLI: ~/.claude/projects/*/\*.jsonl
  • Claude Cowork: ~/Library/Application Support/Claude/local-agent-mode-sessions/*/*/local_*/audit.jsonl (sibling local_*.json holds title/metadata)

How to use

Start narrow, widen if needed. Prefer --grep for content recall; use metadata filters to scope.

Step 1 , find candidate sessions

scripts/find_sessions.py --grep "recruitee logo api"      # content search
scripts/find_sessions.py --title "rippling"              # title substring
scripts/find_sessions.py --cwd "Recruitee"               # Code sessions under matching cwd
scripts/find_sessions.py --since 2026-03-01 --until 2026-03-31
scripts/find_sessions.py --kind cowork -n 30             # 30 most recent Cowork

Combine freely. Output is a table by default; add --json for structured piping.

--grep PATTERN is a regex (case-insensitive). It searches message text inside every transcript and prints 1-2 matching snippets per session along with the session row. Use this when the user remembers a phrase or topic, not a title.

Step 2 , pull full context from a specific session

scripts/show_session.py <path-from-step-1>       # full conversation
scripts/show_session.py <path> --grep "logo"     # only turns mentioning "logo" (with ±1 surrounding turn)
scripts/show_session.py <path> --tail 20         # last 20 turns

The script strips tool calls/results and renders user + assistant text only. Output is markdown-ish; pipe to a pager if long.

Filter reference

find_sessions.py flags:

  • --kind {code,cowork,all} , default all
  • --since YYYY-MM-DD, --until YYYY-MM-DD , by last-activity mtime
  • --title TEXT , substring, case-insensitive (Cowork uses the stored title; Code uses the first user message)
  • --cwd TEXT , substring match against the working directory recorded in Code transcripts
  • --grep PATTERN , regex across transcript body
  • -n, --limit N , cap results (default 50, 0 = all)
  • --snippets N , how many matching lines to show per session under --grep (default 2)
  • --json , machine-readable output

Tips

  • If the user's description is vague, run --grep with 2–3 alternative phrasings in parallel before narrowing.
  • For Code sessions the "title" is a best-effort extraction of the first user message; for Cowork it comes from the .json sidecar written by the desktop app.
  • A session can have many transcripts (resumes, forks). find_sessions.py lists each transcript separately; that's usually what you want , the most recent one is the live thread.
  • Transcripts can be large (several MB). When dumping content to the user, prefer show_session.py --grep or --tail over the full file.
Files (ai-first-toolkit)
  • scripts
    • codex_sessions.py 8.8 KB
      """Codex session adapter.
      
      Codex (OpenAI) stores each session as a JSONL "rollout" at
      `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`. Lines are wrapper objects
      `{type, timestamp, payload}`. The types we use:
      
        session_meta              -> payload.cwd, payload.timestamp, payload.model_provider
        turn_context              -> payload.model (the concrete model id)
        event_msg/user_message    -> payload.message|text (first one = session title)
        event_msg/agent_message   -> payload.message|text
        response_item/message     -> payload.role + payload.content ([{type,text}] or str)
      
      This adapter exposes the same shape session-search uses for Claude transcripts
      (cwd, title, plus a (role, text) iterator for grep), so routing is a drop-in.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      from typing import Iterator
      
      CODEX_ROOT = Path.home() / ".codex" / "sessions"
      
      
      def _content_text(content) -> str:
          if isinstance(content, str):
              return content
          if isinstance(content, list):
              parts = []
              for c in content:
                  if isinstance(c, dict):
                      t = c.get("text") or c.get("content")
                      if isinstance(t, str) and t:
                          parts.append(t)
              return "\n".join(parts)
          return ""
      
      
      def iter_turns(path: Path) -> Iterator[tuple[str, str]]:
          """Yield (role, text) for the conversational turns in a rollout.
      
          Codex records each turn twice: a clean `event_msg` (user_message /
          agent_message) and a lower-level `response_item/message` that also carries
          system/developer preamble. We use the clean channel and fall back to
          response_item only if a rollout has no event_msg turns at all.
          """
          event_turns: list[tuple[str, str]] = []
          item_turns: list[tuple[str, str]] = []
          try:
              with path.open(encoding="utf-8", errors="replace") as f:
                  for line in f:
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      p = e.get("payload") or {}
                      if not isinstance(p, dict):
                          continue
                      typ, ptyp = e.get("type"), p.get("type")
                      if typ == "event_msg" and ptyp == "user_message":
                          txt = p.get("message") or p.get("text") or ""
                          if isinstance(txt, str) and txt:
                              event_turns.append(("user", txt))
                      elif typ == "event_msg" and ptyp == "agent_message":
                          txt = p.get("message") or p.get("text") or ""
                          if isinstance(txt, str) and txt:
                              event_turns.append(("assistant", txt))
                      elif typ == "response_item" and ptyp == "message":
                          txt = _content_text(p.get("content"))
                          if txt and p.get("role") in ("user", "assistant"):
                              item_turns.append((p.get("role"), txt))
          except OSError:
              return
          yield from (event_turns or item_turns)
      
      
      def peek(path: Path) -> tuple[str, str]:
          """Return (cwd, first_user_line) for a rollout, reading only the head."""
          cwd = ""
          first_user = ""
          try:
              with path.open(encoding="utf-8", errors="replace") as f:
                  for i, line in enumerate(f):
                      if i > 120 and cwd and first_user:
                          break
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      p = e.get("payload") or {}
                      if not isinstance(p, dict):
                          continue
                      if not cwd and e.get("type") == "session_meta":
                          cwd = p.get("cwd", "") or ""
                      if (not first_user and e.get("type") == "event_msg"
                              and p.get("type") == "user_message"):
                          txt = p.get("message") or p.get("text") or ""
                          if isinstance(txt, str) and txt.strip():
                              first_user = txt.strip().splitlines()[0]
          except OSError:
              pass
          return cwd, first_user
      
      
      def iter_sessions() -> Iterator[dict]:
          """Yield session records shaped like session-search's Claude records."""
          if not CODEX_ROOT.is_dir():
              return
          for jsonl in CODEX_ROOT.glob("*/*/*/rollout-*.jsonl"):
              try:
                  st = jsonl.stat()
              except OSError:
                  continue
              cwd, title = peek(jsonl)
              yield {
                  "kind": "codex",
                  "mtime": st.st_mtime,
                  "size": st.st_size,
                  "title": title,
                  "cwd": cwd,
                  "path": str(jsonl),
              }
      
      
      def _usage_from_last(lt: dict) -> dict:
          """Map a Codex last_token_usage record to the Anthropic-style usage dict the
          token-doctor pipeline expects (uncached input + cache_read split; OpenAI has
          no separate cache-write metric).
          """
          inp = int(lt.get("input_tokens") or 0)
          cached = int(lt.get("cached_input_tokens") or 0)
          out = int(lt.get("output_tokens") or 0) + int(lt.get("reasoning_output_tokens") or 0)
          return {
              "input": max(inp - cached, 0),
              "output": out,
              "cache_read": cached,
              "cache_creation": 0,
          }
      
      
      def session_meta(path: Path) -> dict:
          """Return {cwd, model, start_ts} for a rollout."""
          cwd, model, start_ts = "", "", ""
          try:
              with path.open(encoding="utf-8", errors="replace") as f:
                  for i, line in enumerate(f):
                      if i > 200 and cwd and model:
                          break
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      p = e.get("payload") or {}
                      if not isinstance(p, dict):
                          continue
                      if e.get("type") == "session_meta":
                          cwd = cwd or p.get("cwd", "") or ""
                          start_ts = start_ts or p.get("timestamp", "") or e.get("timestamp", "")
                      elif e.get("type") == "turn_context" and p.get("model"):
                          model = model or p["model"]
          except OSError:
              pass
          return {"cwd": cwd, "model": model, "start_ts": start_ts}
      
      
      def codex_turns(path: Path, model_hint: str = "") -> list[dict]:
          """Parse a rollout into turn dicts matching the token-doctor/task-profile
          shape: {role, ts, text, tool_calls, [model, usage]}.
      
          Usage is attached from each `token_count` event's per-response
          `last_token_usage` to the assistant turn it belongs to.
          """
          model = model_hint
          turns: list[dict] = []
          pending_tools: list[str] = []
          try:
              with path.open(encoding="utf-8", errors="replace") as f:
                  for line in f:
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      p = e.get("payload") or {}
                      if not isinstance(p, dict):
                          continue
                      typ, ptyp = e.get("type"), p.get("type")
                      ts = e.get("timestamp")
                      if typ == "turn_context" and p.get("model"):
                          model = p["model"]
                      elif typ == "event_msg" and ptyp == "user_message":
                          txt = p.get("message") or p.get("text") or ""
                          if isinstance(txt, str) and txt:
                              turns.append({"role": "user", "ts": ts, "text": txt, "tool_calls": []})
                      elif typ == "event_msg" and ptyp == "agent_message":
                          txt = p.get("message") or p.get("text") or ""
                          turns.append({"role": "assistant", "ts": ts,
                                        "text": txt if isinstance(txt, str) else "",
                                        "tool_calls": pending_tools})
                          pending_tools = []
                      elif typ == "response_item" and ptyp == "function_call":
                          name = p.get("name") or p.get("tool_name")
                          if name:
                              pending_tools.append(name)
                      elif typ == "event_msg" and ptyp == "token_count":
                          lt = (p.get("info") or {}).get("last_token_usage") or {}
                          if not lt:
                              continue
                          target = next((t for t in reversed(turns)
                                         if t["role"] == "assistant" and "usage" not in t), None)
                          if target is None:
                              target = {"role": "assistant", "ts": ts, "text": "", "tool_calls": []}
                              turns.append(target)
                          if pending_tools:
                              target["tool_calls"] = target.get("tool_calls", []) + pending_tools
                              pending_tools = []
                          target["model"] = model or "gpt-5"
                          target["usage"] = _usage_from_last(lt)
          except OSError:
              return []
          return turns
      
    • find_sessions.py 8.9 KB
      #!/usr/bin/env python3
      """Find past Claude Code (CLI) and Claude Cowork (desktop) sessions on this Mac.
      
      Reads transcripts directly from disk. Works for any user , paths come from $HOME.
      See ../SKILL.md for when to use this.
      """
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from datetime import datetime
      from pathlib import Path
      from typing import Iterator
      
      sys.path.insert(0, str(Path(__file__).parent))
      import codex_sessions  # noqa: E402
      from host_platform import CLAUDE, CODEX, degrade, detect_platform  # noqa: E402
      
      HOME = Path.home()
      CODE_ROOT = HOME / ".claude" / "projects"
      COWORK_ROOT = HOME / "Library" / "Application Support" / "Claude" / "local-agent-mode-sessions"
      
      
      def _extract_text(entry: dict) -> str:
          """Pull user/assistant text out of a transcript entry. Skip tool calls/results/system noise."""
          t = entry.get("type")
          if t not in ("user", "assistant"):
              return ""
          msg = entry.get("message") or {}
          content = msg.get("content")
          if isinstance(content, str):
              return content
          if isinstance(content, list):
              parts = []
              for c in content:
                  if isinstance(c, dict) and c.get("type") == "text":
                      txt = c.get("text") or ""
                      if txt:
                          parts.append(txt)
              return "\n".join(parts)
          return ""
      
      
      def _peek_code(jsonl: Path) -> tuple[str, str]:
          """Return (cwd, first_user_line) for a Claude Code transcript."""
          cwd = ""
          first_user = ""
          try:
              with jsonl.open(encoding="utf-8", errors="replace") as f:
                  for i, line in enumerate(f):
                      if i > 80 and cwd and first_user:
                          break
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      if not cwd and isinstance(e.get("cwd"), str):
                          cwd = e["cwd"]
                      if not first_user and e.get("type") == "user":
                          txt = _extract_text(e)
                          if txt:
                              first_user = txt.strip().splitlines()[0]
          except OSError:
              pass
          return cwd, first_user
      
      
      def _cowork_title(audit: Path) -> str:
          session_dir = audit.parent
          meta_file = session_dir.parent / f"{session_dir.name}.json"
          if not meta_file.is_file():
              return ""
          try:
              meta = json.loads(meta_file.read_text(encoding="utf-8", errors="replace"))
          except (json.JSONDecodeError, OSError):
              return ""
          return (meta.get("title") or meta.get("name") or (meta.get("initialMessage") or "")[:120] or "").strip()
      
      
      def iter_sessions(kind: str) -> Iterator[dict]:
          if kind in ("code", "all") and CODE_ROOT.is_dir():
              for project_dir in CODE_ROOT.iterdir():
                  if not project_dir.is_dir():
                      continue
                  for jsonl in project_dir.glob("*.jsonl"):
                      try:
                          st = jsonl.stat()
                      except OSError:
                          continue
                      cwd, title = _peek_code(jsonl)
                      yield {
                          "kind": "code",
                          "mtime": st.st_mtime,
                          "size": st.st_size,
                          "title": title,
                          "cwd": cwd,
                          "path": str(jsonl),
                      }
          if kind in ("cowork", "all") and COWORK_ROOT.is_dir():
              for audit in COWORK_ROOT.rglob("local_*/audit.jsonl"):
                  if "skills-plugin" in audit.parts:
                      continue
                  try:
                      st = audit.stat()
                  except OSError:
                      continue
                  yield {
                      "kind": "cowork",
                      "mtime": st.st_mtime,
                      "size": st.st_size,
                      "title": _cowork_title(audit),
                      "cwd": "",
                      "path": str(audit),
                  }
      
      
      def grep_session(path: Path, pattern: re.Pattern, max_snippets: int) -> list[str]:
          """Return up to max_snippets short excerpts where pattern matched."""
          snippets: list[str] = []
          try:
              with path.open(encoding="utf-8", errors="replace") as f:
                  for line in f:
                      if len(snippets) >= max_snippets:
                          break
                      try:
                          e = json.loads(line)
                      except json.JSONDecodeError:
                          continue
                      txt = _extract_text(e)
                      if not txt:
                          continue
                      for m in pattern.finditer(txt):
                          start = max(0, m.start() - 60)
                          end = min(len(txt), m.end() + 60)
                          snip = txt[start:end].replace("\n", " ").strip()
                          role = e.get("type", "?")
                          snippets.append(f"[{role}] …{snip}…")
                          if len(snippets) >= max_snippets:
                              break
          except OSError:
              pass
          return snippets
      
      
      def grep_codex(path: Path, pattern: re.Pattern, max_snippets: int) -> list[str]:
          """grep_session equivalent for a Codex rollout (uses the codex adapter)."""
          snippets: list[str] = []
          for role, txt in codex_sessions.iter_turns(path):
              if len(snippets) >= max_snippets:
                  break
              for m in pattern.finditer(txt):
                  start = max(0, m.start() - 60)
                  end = min(len(txt), m.end() + 60)
                  snip = txt[start:end].replace("\n", " ").strip()
                  snippets.append(f"[{role}] …{snip}…")
                  if len(snippets) >= max_snippets:
                      break
          return snippets
      
      
      def fmt_size(b: float) -> str:
          for u in ("B", "K", "M", "G"):
              if b < 1024:
                  return f"{b:.0f}{u}"
              b /= 1024
          return f"{b:.0f}T"
      
      
      def parse_date(s: str) -> float:
          return datetime.strptime(s, "%Y-%m-%d").timestamp()
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
          p.add_argument("--kind", choices=("code", "cowork", "all"), default="all")
          p.add_argument("--since", help="YYYY-MM-DD (inclusive)")
          p.add_argument("--until", help="YYYY-MM-DD (inclusive)")
          p.add_argument("--title", help="substring, case-insensitive")
          p.add_argument("--cwd", help="substring match against code session cwd")
          p.add_argument("--grep", help="regex searched against transcript body (case-insensitive)")
          p.add_argument("--snippets", type=int, default=2, help="matching snippets per session (default 2)")
          p.add_argument("-n", "--limit", type=int, default=50, help="max sessions to output (0 = all)")
          p.add_argument("--json", action="store_true")
          args = p.parse_args()
      
          # Route by host platform. Claude Code reads its own transcripts; Codex reads
          # ~/.codex/sessions rollouts; Antigravity has no parseable local store.
          platform = detect_platform()
          if platform not in (CLAUDE, CODEX):
              degrade("session-search", platform)
      
          since_ts = parse_date(args.since) if args.since else None
          until_ts = parse_date(args.until) + 86400 if args.until else None
          title_q = args.title.lower() if args.title else None
          cwd_q = args.cwd.lower() if args.cwd else None
          grep_re = re.compile(args.grep, re.IGNORECASE) if args.grep else None
      
          if platform == CODEX:
              sessions = list(codex_sessions.iter_sessions())
              grep_fn = grep_codex
          else:
              sessions = list(iter_sessions(args.kind))
              grep_fn = grep_session
          sessions.sort(key=lambda s: s["mtime"], reverse=True)
      
          results = []
          for s in sessions:
              if since_ts is not None and s["mtime"] < since_ts:
                  continue
              if until_ts is not None and s["mtime"] > until_ts:
                  continue
              if title_q and title_q not in (s["title"] or "").lower():
                  continue
              if cwd_q and cwd_q not in (s["cwd"] or "").lower():
                  continue
              if grep_re is not None:
                  snips = grep_fn(Path(s["path"]), grep_re, args.snippets)
                  if not snips:
                      continue
                  s = dict(s, snippets=snips)
              results.append(s)
              if args.limit > 0 and len(results) >= args.limit:
                  break
      
          if args.json:
              json.dump(results, sys.stdout, indent=2, default=str)
              sys.stdout.write("\n")
              return 0
      
          if not results:
              print("(no matching sessions)")
              return 0
      
          print(f"{'WHEN':<17} {'KIND':<7} {'SIZE':>6}  LABEL")
          print("-" * 100)
          for s in results:
              ts = datetime.fromtimestamp(s["mtime"]).strftime("%Y-%m-%d %H:%M")
              if s["kind"] in ("code", "codex"):
                  label = s["title"] or "(no user message yet)"
                  if s["cwd"]:
                      label = f"{label}  ·  {s['cwd']}"
              else:
                  label = s["title"] or "(untitled cowork session)"
              print(f"{ts:<17} {s['kind']:<7} {fmt_size(s['size']):>6}  {label[:120]}")
              print(f"{'':<17} path: {s['path']}")
              for snip in s.get("snippets", []):
                  print(f"{'':<17}   {snip[:180]}")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • host_platform.py 3.8 KB
      """Host-platform detection for the ai-adoption skills.
      
      One mechanism, shared by every script that reads agent session history. An
      identical copy ships in each skill's scripts/ dir so it travels with the script
      under all install shapes (Claude Code native, Codex flat, Antigravity nested).
      
      Resolution order (first hit wins):
        1. AI_FIRST_PLATFORM env var, if set to a known platform (explicit override).
        2. The "platform" field stamped into .techwolf-plugin.json by install.sh.
           The installer knows the target IDE (--ide codex|antigravity), so this is
           deterministic for Codex/Antigravity installs.
        3. Fallback: if ~/.claude exists, assume Claude Code. Claude Code uses the
           native plugin system and never runs install.sh, so it is never stamped.
        4. Default: "claude".
      
      Per-platform session-data reality (see each skill's SKILL.md):
        - claude       Claude Code (~/.claude/projects) + Cowork transcripts. Full.
        - codex        ~/.codex/sessions/**/rollout-*.jsonl, plaintext JSONL with
                       cwd, model, token usage, and turns. Parseable (session-search
                       routes here).
        - antigravity  IDE conversations are AEAD-encrypted at rest
                       (~/.gemini/antigravity/conversations/*.pb); the unencrypted CLI
                       store (~/.gemini/antigravity-cli/conversations/*.db) carries no
                       parseable turn/token content. No honest analysis path -> degrade.
      """
      from __future__ import annotations
      
      import json
      import os
      from pathlib import Path
      
      CLAUDE = "claude"
      CODEX = "codex"
      ANTIGRAVITY = "antigravity"
      _VALID = {CLAUDE, CODEX, ANTIGRAVITY}
      
      
      def _from_stamp() -> str | None:
          # scripts/ -> skill root holds .techwolf-plugin.json (written by install.sh).
          here = Path(__file__).resolve()
          for d in (here.parent, here.parent.parent, here.parent.parent.parent):
              stamp = d / ".techwolf-plugin.json"
              if not stamp.is_file():
                  continue
              try:
                  platform = json.loads(stamp.read_text(encoding="utf-8")).get("platform")
              except (json.JSONDecodeError, OSError):
                  return None
              if isinstance(platform, str) and platform.lower() in _VALID:
                  return platform.lower()
              return None
          return None
      
      
      def detect_platform() -> str:
          env = os.environ.get("AI_FIRST_PLATFORM", "").strip().lower()
          if env in _VALID:
              return env
          stamped = _from_stamp()
          if stamped:
              return stamped
          return CLAUDE
      
      
      _DEGRADE = {
          CODEX: (
              "this analysis is not available on Codex yet.\n"
              "  Codex sessions (~/.codex/sessions) are parseable, but this skill does not\n"
              "  read them yet. Run it under Claude Code. (session-search already supports\n"
              "  Codex.)"
          ),
          ANTIGRAVITY: (
              "session analysis is not available on Antigravity.\n"
              "  Antigravity stores IDE conversations encrypted at rest\n"
              "  (~/.gemini/antigravity/conversations/*.pb, AEAD), and its unencrypted CLI\n"
              "  store carries no parseable turn/token content. There is no honest local\n"
              "  data path to analyse. Run this skill under Claude Code."
          ),
      }
      
      
      def degrade(skill: str, platform: str | None = None) -> None:
          """Print a clear, platform-specific 'not available' message and exit 0.
      
          Degrading is not an error: the skill simply isn't available on this host.
          """
          platform = platform or detect_platform()
          print(f"{skill}: {_DEGRADE.get(platform, f'not available on {platform}.')}")
          raise SystemExit(0)
      
      
      def require_claude(skill: str) -> str:
          """Return the platform if Claude; otherwise degrade. For skills that only
          support Claude transcripts today (token-doctor, task-profile)."""
          platform = detect_platform()
          if platform == CLAUDE:
              return platform
          degrade(skill, platform)
      
    • show_session.py 3.8 KB
      #!/usr/bin/env python3
      """Print a single Claude Code / Cowork or Codex session as readable turns.
      
      Takes the transcript path reported by find_sessions.py (a .jsonl file). Codex
      rollouts (~/.codex/sessions/.../rollout-*.jsonl) are detected by path and read
      through the Codex adapter.
      See ../SKILL.md for when to use this.
      """
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from datetime import datetime
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      import codex_sessions  # noqa: E402
      
      
      def _is_codex(path: Path) -> bool:
          parts = path.parts
          return ".codex" in parts and "sessions" in parts or path.name.startswith("rollout-")
      
      
      def _extract_text(entry: dict) -> str:
          t = entry.get("type")
          if t not in ("user", "assistant"):
              return ""
          msg = entry.get("message") or {}
          content = msg.get("content")
          if isinstance(content, str):
              return content
          if isinstance(content, list):
              parts = []
              for c in content:
                  if isinstance(c, dict) and c.get("type") == "text":
                      txt = c.get("text") or ""
                      if txt:
                          parts.append(txt)
              return "\n".join(parts)
          return ""
      
      
      def iter_turns(path: Path):
          with path.open(encoding="utf-8", errors="replace") as f:
              for line in f:
                  try:
                      e = json.loads(line)
                  except json.JSONDecodeError:
                      continue
                  txt = _extract_text(e)
                  if not txt.strip():
                      continue
                  ts = e.get("timestamp") or ""
                  yield {"role": e.get("type"), "ts": ts, "text": txt}
      
      
      def fmt_ts(ts: str) -> str:
          if not ts:
              return ""
          try:
              return datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
          except ValueError:
              return ts
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
          p.add_argument("path", help="transcript .jsonl path (from find_sessions.py)")
          p.add_argument("--grep", help="only print turns matching regex (plus --context surrounding turns)")
          p.add_argument("--context", type=int, default=1, help="surrounding turns when using --grep (default 1)")
          p.add_argument("--tail", type=int, help="print only the last N turns")
          p.add_argument("--head", type=int, help="print only the first N turns")
          p.add_argument("--max-chars", type=int, default=4000, help="truncate each turn to N chars (default 4000)")
          args = p.parse_args()
      
          path = Path(args.path)
          if not path.is_file():
              print(f"not found: {path}", file=sys.stderr)
              return 1
      
          if _is_codex(path):
              turns = [{"role": role, "ts": "", "text": txt}
                       for role, txt in codex_sessions.iter_turns(path) if txt.strip()]
          else:
              turns = list(iter_turns(path))
      
          if args.grep:
              pat = re.compile(args.grep, re.IGNORECASE)
              keep = set()
              for i, t in enumerate(turns):
                  if pat.search(t["text"]):
                      for j in range(max(0, i - args.context), min(len(turns), i + args.context + 1)):
                          keep.add(j)
              turns = [t for i, t in enumerate(turns) if i in keep]
      
          if args.head:
              turns = turns[: args.head]
          if args.tail:
              turns = turns[-args.tail :]
      
          if not turns:
              print("(no matching turns)")
              return 0
      
          print(f"# {path}")
          print(f"# {len(turns)} turns\n")
          for t in turns:
              header = f"## {t['role']}"
              ts = fmt_ts(t["ts"])
              if ts:
                  header += f"  ·  {ts}"
              print(header)
              body = t["text"]
              if len(body) > args.max_chars:
                  body = body[: args.max_chars] + f"\n… [truncated at {args.max_chars} chars]"
              print(body)
              print()
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • SKILL.md 4.2 KB
    ---
    name: session-search
    description: Find context from past Claude Code (CLI) and Claude Cowork (desktop) sessions on this Mac. Use when the user wants to recall something they did before but can't find it , phrasings like "where did I work on X", "find that session where I…", "when did I last do Y", "pull up the conversation about Z", "that time I built/tried/discussed …". Searches by kind (code/cowork), time range, title, working directory, or free-text content across all transcripts.
    ---
    
    # session-search
    
    > **Platforms: Claude Code / Cowork and Codex.** `find_sessions.py` and `show_session.py` detect the host (via the `platform` stamp `install.sh` writes, or `AI_FIRST_PLATFORM`) and route to the right store: Claude Code (`~/.claude/projects`) + Cowork transcripts, or Codex rollouts (`~/.codex/sessions/**/rollout-*.jsonl`). Both support list, time/cwd/title filters, and full-text `--grep`. **Antigravity is not supported**: its IDE conversations are AEAD-encrypted at rest (`~/.gemini/antigravity/conversations/*.pb`) and its unencrypted CLI store carries no parseable turn content, so the skill prints a clear "not available" message and exits.
    
    Two scripts in `scripts/` locate past sessions and dump their content:
    
    - `find_sessions.py` , discover + filter sessions (metadata + optional full-text grep).
    - `show_session.py` , print a single session's conversation (user/assistant turns) in readable form.
    
    Both read directly from disk , no API calls, no auth. They resolve paths from `$HOME` so they work for any macOS user:
    
    - Claude Code CLI: `~/.claude/projects/*/\*.jsonl`
    - Claude Cowork: `~/Library/Application Support/Claude/local-agent-mode-sessions/*/*/local_*/audit.jsonl` (sibling `local_*.json` holds title/metadata)
    
    ## How to use
    
    Start narrow, widen if needed. Prefer `--grep` for content recall; use metadata filters to scope.
    
    ### Step 1 , find candidate sessions
    
    ```bash
    scripts/find_sessions.py --grep "recruitee logo api"      # content search
    scripts/find_sessions.py --title "rippling"              # title substring
    scripts/find_sessions.py --cwd "Recruitee"               # Code sessions under matching cwd
    scripts/find_sessions.py --since 2026-03-01 --until 2026-03-31
    scripts/find_sessions.py --kind cowork -n 30             # 30 most recent Cowork
    ```
    
    Combine freely. Output is a table by default; add `--json` for structured piping.
    
    `--grep PATTERN` is a regex (case-insensitive). It searches message text inside every transcript and prints 1-2 matching snippets per session along with the session row. Use this when the user remembers a phrase or topic, not a title.
    
    ### Step 2 , pull full context from a specific session
    
    ```bash
    scripts/show_session.py <path-from-step-1>       # full conversation
    scripts/show_session.py <path> --grep "logo"     # only turns mentioning "logo" (with ±1 surrounding turn)
    scripts/show_session.py <path> --tail 20         # last 20 turns
    ```
    
    The script strips tool calls/results and renders user + assistant text only. Output is markdown-ish; pipe to a pager if long.
    
    ## Filter reference
    
    `find_sessions.py` flags:
    
    - `--kind {code,cowork,all}` , default `all`
    - `--since YYYY-MM-DD`, `--until YYYY-MM-DD` , by last-activity mtime
    - `--title TEXT` , substring, case-insensitive (Cowork uses the stored title; Code uses the first user message)
    - `--cwd TEXT` , substring match against the working directory recorded in Code transcripts
    - `--grep PATTERN` , regex across transcript body
    - `-n, --limit N` , cap results (default 50, `0` = all)
    - `--snippets N` , how many matching lines to show per session under `--grep` (default 2)
    - `--json` , machine-readable output
    
    ## Tips
    
    - If the user's description is vague, run `--grep` with 2–3 alternative phrasings in parallel before narrowing.
    - For Code sessions the "title" is a best-effort extraction of the first user message; for Cowork it comes from the `.json` sidecar written by the desktop app.
    - A session can have many transcripts (resumes, forks). `find_sessions.py` lists each transcript separately; that's usually what you want , the most recent one is the live thread.
    - Transcripts can be large (several MB). When dumping content to the user, prefer `show_session.py --grep` or `--tail` over the full file.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related