Skills › Compound Engineering › ce-compound › discover-sessions.sh
discover-sessions.sh
264 lines · skills/ce-compound/scripts/session-history/discover-sessions.sh at 3449230
1#!/usr/bin/env bash
2# Discover session files across Claude Code, Codex, Cursor, Pi, and oh-my-pi (omp).
3#
4# Usage: discover-sessions.sh <repo-name> <days> [--cwd /abs/repo/root] [--platform claude|codex|cursor|pi|omp]
5#
6# Outputs one file path per line. Safe in both bash and zsh (all globs guarded).
7# Pass output to extract-metadata.py:
8# python3 extract-metadata.py --cwd-filter <repo-name> $(bash discover-sessions.sh <repo-name> 7)
9#
10# Arguments:
11# repo-name Folder name of the repo (e.g., "my-repo"). Used for directory matching.
12# days Scan window in days (e.g., 7). Files older than this are skipped.
13# --cwd Absolute repo root. Used for exact Pi encoded-CWD discovery
14# and the omp raw-bucket probe. Claude listing is unfiltered;
15# extract-metadata.py --cwd-filter matches recorded cwd.
16# --platform Restrict to a single platform. Omit to search all.
18set -euo pipefail
20REPO_NAME="${1:?Usage: discover-sessions.sh <repo-name> <days> [--cwd /abs/repo/root] [--platform claude|codex|cursor|pi|omp]}"
21DAYS="${2:?Usage: discover-sessions.sh <repo-name> <days> [--cwd /abs/repo/root] [--platform claude|codex|cursor|pi|omp]}"
22PLATFORM="all"
23REPO_CWD=""
25# Parse optional --platform flag
26shift 2
27while [ $# -gt 0 ]; do
28 case "$1" in
29 --cwd) REPO_CWD="$2"; shift 2 ;;
30 --platform) PLATFORM="$2"; shift 2 ;;
31 *) shift ;;
32 esac
33done
35encode_pi_cwd() {
36 local cwd="${1%/}"
37 local encoded="${cwd//\//-}"
38 encoded="${encoded#-}"
39 printf -- "--%s--" "$encoded"
40}
42# --- Claude Code ---
43# List every recent jsonl under <config-dir>/projects. Folder names are an
44# undocumented encoder of session CWD; do not invert them. Repo attribution
45# is the recorded `cwd` field, applied by extract-metadata.py --cwd-filter.
46# CLAUDE_CONFIG_DIR relocates the whole config tree (official); unset -> ~/.claude.
47discover_claude() {
48 local base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
49 [ -d "$base" ] || return 0
50 find "$base" -mindepth 2 -maxdepth 2 -type f -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
51}
53# --- Codex ---
54discover_codex() {
55 local codex_home="${CODEX_HOME:-$HOME/.codex}"
56 for base in "$codex_home/sessions" "$HOME/.agents/sessions"; do
57 [ -d "$base" ] || continue
59 # Use mtime-based discovery (consistent with Claude/Cursor) so that
60 # sessions started before the scan window but still active within it
61 # are not missed.
62 find "$base" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
63 done
64}
66# --- Cursor ---
67discover_cursor() {
68 local base="$HOME/.cursor/projects"
69 [ -d "$base" ] || return 0
71 for dir in "$base"/*"$REPO_NAME"*/; do
72 [ -d "$dir" ] || continue
73 local transcripts="$dir/agent-transcripts"
74 [ -d "$transcripts" ] || continue
75 find "$transcripts" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
76 done
77}
79# --- Pi ---
80discover_pi() {
81 local agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
82 local base="${PI_CODING_AGENT_SESSION_DIR:-$agent_dir/sessions}"
83 [ -d "$base" ] || return 0
85 # Pi's explicit session-dir override stores session files directly in the
86 # supplied directory. The cwd filter later reads each header and keeps only
87 # sessions for the active repo.
88 if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then
89 find "$base" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
90 if [ -z "$REPO_CWD" ]; then
91 for dir in "$base"/*"$REPO_NAME"*/; do
92 [ -d "$dir" ] || continue
93 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
94 done
95 fi
96 return 0
97 fi
99 # Pi stores sessions under --<absolute-cwd-with-slashes-as-hyphens>--.
100 # When the caller supplies an exact repo root, probe only that encoded
101 # directory so sibling repos like my-repo-old never enter the pipeline.
102 if [ -n "$REPO_CWD" ]; then
103 local dir="$base/$(encode_pi_cwd "$REPO_CWD")"
104 [ -d "$dir" ] || return 0
105 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
106 return 0
107 fi
109 # Fallback for direct script use without --cwd.
110 for dir in "$base"/*"$REPO_NAME"*/; do
111 [ -d "$dir" ] || continue
112 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
113 done
114}
116# --- oh-my-pi (omp) ---
117# Encode omp's raw bucket name for a cwd: home-relative "-<rel>",
118# tmp-relative "-tmp-<rel>", and otherwise "--<abs>--", with path separators
119# and ":" encoded as "-" (session-paths.ts getDefaultSessionDirName /
120# encodeLegacyAbsoluteSessionDirName). This raw scheme predates the hashed
121# scheme and is current again since omp 17.2.9 (#7646 restored it and removed
122# automatic migration), so buckets in the wild use both shapes. Canonicalize
123# with physical paths so symlinked cwds resolve to the same bucket, mirroring
124# omp's resolveEquivalentPath. Prints nothing when the cwd cannot be resolved.
125encode_omp_raw_cwd() {
126 local cwd canon_home canon_tmp rel
127 cwd="$(cd "$1" 2>/dev/null && pwd -P)" || return 0
128 canon_home="$(cd "$HOME" 2>/dev/null && pwd -P)" || canon_home="$HOME"
129 case "$cwd" in
130 "$canon_home")
131 printf -- '-'
132 ;;
133 "$canon_home"/*)
134 rel="$(printf '%s' "${cwd#"$canon_home"/}" | sed 's/[/\\:]/-/g')"
135 printf -- '-%s' "$rel"
136 ;;
137 *)
138 canon_tmp="$(cd "${TMPDIR:-/tmp}" 2>/dev/null && pwd -P)" || canon_tmp=""
139 case "$cwd" in
140 "$canon_tmp")
141 printf -- '-tmp'
142 ;;
143 "$canon_tmp"/*)
144 rel="$(printf '%s' "${cwd#"$canon_tmp"/}" | sed 's/[/\\:]/-/g')"
145 printf -- '-tmp-%s' "$rel"
146 ;;
147 *)
148 rel="$(printf '%s' "${cwd#/}" | sed 's/[/\\:]/-/g')"
149 printf -- '--%s--' "$rel"
150 ;;
151 esac
152 ;;
153 esac
154}
156discover_omp() {
157 local config_dir="${PI_CONFIG_DIR:-.omp}"
159 # omp's explicit session-dir override stores session files directly in the
160 # supplied directory (flat), mirroring Pi's override branch. The cwd filter
161 # later reads each header and keeps only sessions for the active repo.
162 if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then
163 local base="$PI_CODING_AGENT_SESSION_DIR"
164 [ -d "$base" ] || return 0
165 find "$base" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
166 if [ -z "$REPO_CWD" ]; then
167 for dir in "$base"/*"$REPO_NAME"*/; do
168 [ -d "$dir" ] || continue
169 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
170 done
171 fi
172 return 0
173 fi
175 # omp has two bucket-naming schemes in the wild, and both keep the raw
176 # repo basename (spaces and all) inside the bucket name:
177 # - raw: "-<home-rel>", "-tmp-<tmp-rel>", "--<abs>--" (legacy relative to
178 # the hashed scheme; restored as current in omp 17.2.9, #7646)
179 # - hashed: "<scope>-<sanitized-basename>-<sha256-of-canonical-cwd>"
180 # (intermediate releases; basename runs of [^a-zA-Z0-9._-] collapse to
181 # "-", edge dashes stripped, capped at the last 80 chars, empty falls
182 # back to "project" — session-paths.ts getDefaultSessionDirName)
183 # Scan basename-matching buckets in the default-profile sessions root and
184 # in every named-profile root; exact repo attribution comes from the
185 # downstream header `cwd` filter (extract-metadata.py --cwd-filter reads
186 # the type:"session" header). Glob the sanitized form so repos whose
187 # basename contains characters the hashed scheme normalizes (e.g. spaces)
188 # still match, and glob the raw form so raw-scheme buckets whose basename
189 # sanitizes differently (e.g. "my repo" in "--Users-test-Code-my repo--")
190 # are found too. When --cwd is supplied, also probe the exact raw bucket
191 # name: it catches buckets the basename globs miss when the bucket's path
192 # segments no longer resemble the repo name as typed.
193 local sanitized
194 sanitized="$(printf '%s' "$REPO_NAME" | sed -E 's/[^a-zA-Z0-9._-]+/-/g; s/^-+//; s/-+$//' | tail -c 80)"
195 [ -n "$sanitized" ] || sanitized="project"
196 local agent_dir="${PI_CODING_AGENT_DIR:-$HOME/$config_dir/agent}"
197 # omp getSessionsDir() flattens the agent/ prefix under XDG: ~/.omp/agent/sessions
198 # becomes $XDG_DATA_HOME/omp/sessions (and $XDG_DATA_HOME/omp/profiles/<name>/sessions).
199 # Only linux/darwin, only when XDG_DATA_HOME is set and the omp app root exists,
200 # and only when PI_CODING_AGENT_DIR did not already take over the agent root.
201 local xdg_omp=""
202 if [ -z "${PI_CODING_AGENT_DIR:-}" ] && [ -n "${XDG_DATA_HOME:-}" ]; then
203 case "$(uname -s)" in
204 Linux|Darwin)
205 if [ -d "${XDG_DATA_HOME%/}/omp" ]; then
206 xdg_omp="${XDG_DATA_HOME%/}/omp"
207 fi
208 ;;
209 esac
210 fi
211 {
212 local root dir encoded
213 if [ -n "$REPO_CWD" ]; then
214 encoded="$(encode_omp_raw_cwd "$REPO_CWD")"
215 if [ -n "$encoded" ]; then
216 for root in "$agent_dir/sessions" \
217 "$HOME/$config_dir"/profiles/*/agent/sessions \
218 ${xdg_omp:+"$xdg_omp/sessions"} \
219 ${xdg_omp:+"$xdg_omp"/profiles/*/sessions}; do
220 [ -d "$root/$encoded" ] || continue
221 find "$root/$encoded" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
222 done
223 fi
224 fi
225 for root in "$agent_dir/sessions" \
226 "$HOME/$config_dir"/profiles/*/agent/sessions \
227 ${xdg_omp:+"$xdg_omp/sessions"} \
228 ${xdg_omp:+"$xdg_omp"/profiles/*/sessions}; do
229 [ -d "$root" ] || continue
230 for dir in "$root"/*"$sanitized"*/; do
231 [ -d "$dir" ] || continue
232 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
233 done
234 if [ "$REPO_NAME" != "$sanitized" ]; then
235 for dir in "$root"/*"$REPO_NAME"*/; do
236 [ -d "$dir" ] || continue
237 find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
238 done
239 fi
240 done
241 # The probe and the globs can hit the same bucket; emit each path once.
242 } | awk '!seen[$0]++'
243}
245# --- Dispatch ---
246case "$PLATFORM" in
247 claude) discover_claude ;;
248 codex) discover_codex ;;
249 cursor) discover_cursor ;;
250 pi) discover_pi ;;
251 omp) discover_omp ;;
252 all)
253 # Pi and omp share the PI_CODING_AGENT_SESSION_DIR override: when it
254 # is set, both discover functions emit the same flat-dir files, and
255 # the downstream xargs call does not deduplicate. Emit each path once;
256 # platform attribution is unaffected because extract-metadata.py
257 # detects the file shape (title slot => omp, otherwise pi).
258 { discover_claude; discover_codex; discover_cursor; discover_pi; discover_omp; } | awk '!seen[$0]++'
259 ;;
260 *)
261 echo "Unknown platform: $PLATFORM" >&2
262 exit 1
263 ;;
264esac