session-ls 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- session_ls/__init__.py +240 -0
- session_ls/__main__.py +6 -0
- session_ls-0.1.0.data/data/share/man/man1/session-ls.1 +150 -0
- session_ls-0.1.0.dist-info/METADATA +145 -0
- session_ls-0.1.0.dist-info/RECORD +9 -0
- session_ls-0.1.0.dist-info/WHEEL +5 -0
- session_ls-0.1.0.dist-info/entry_points.txt +2 -0
- session_ls-0.1.0.dist-info/licenses/LICENSE +21 -0
- session_ls-0.1.0.dist-info/top_level.txt +1 -0
session_ls/__init__.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""session-ls - list and search session history across coding agents.
|
|
3
|
+
|
|
4
|
+
Default: list every session (newest first). With a keyword: search session
|
|
5
|
+
titles (first real user message). Add --full to grep full content (slow).
|
|
6
|
+
|
|
7
|
+
Add a new agent by appending one entry to REGISTRY:
|
|
8
|
+
(name, glob, meta-parser, user-text-extractor).
|
|
9
|
+
- meta-parser(f, head) -> (cwd, started_iso) or None (started may be None
|
|
10
|
+
if the format has no timestamp; file mtime is used instead)
|
|
11
|
+
- user-text-extractor(line) -> first real user message text or ''
|
|
12
|
+
('' = keep scanning; drives the early-exit read)
|
|
13
|
+
"""
|
|
14
|
+
import argparse, glob, json, os, re, shutil, signal, subprocess
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
18
|
+
HOME = os.path.expanduser("~")
|
|
19
|
+
CACHE = os.path.join(HOME, ".cache", "session_ls_cache.json")
|
|
20
|
+
|
|
21
|
+
def _injected(t):
|
|
22
|
+
"""True if this user message is injected context, not the user's own text."""
|
|
23
|
+
t = t.lstrip()
|
|
24
|
+
return t.startswith("<") or "AGENTS.md instructions" in t
|
|
25
|
+
|
|
26
|
+
# ---- user-text extractors: line -> first real user text or '' --------------
|
|
27
|
+
|
|
28
|
+
def _pi_user(line):
|
|
29
|
+
m = json.loads(line).get("message", {}) or {}
|
|
30
|
+
if m.get("role") != "user":
|
|
31
|
+
return ""
|
|
32
|
+
t = " ".join(p.get("text", "") for p in m.get("content", [])
|
|
33
|
+
if p.get("type") == "text").strip()
|
|
34
|
+
return t if t and not _injected(t) else ""
|
|
35
|
+
|
|
36
|
+
def _codex_user(line):
|
|
37
|
+
p = json.loads(line).get("payload", {}) or {}
|
|
38
|
+
if p.get("role") != "user":
|
|
39
|
+
return ""
|
|
40
|
+
t = " ".join(c.get("text", "") for c in p.get("content", [])
|
|
41
|
+
if c.get("type") in ("input_text", "text")).strip()
|
|
42
|
+
return t if t and not _injected(t) else ""
|
|
43
|
+
|
|
44
|
+
def _claude_user(line):
|
|
45
|
+
d = json.loads(line)
|
|
46
|
+
if d.get("type") != "user":
|
|
47
|
+
return ""
|
|
48
|
+
c = d.get("message", {}).get("content")
|
|
49
|
+
t = c if isinstance(c, str) else " ".join(
|
|
50
|
+
b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
|
|
51
|
+
return t.strip()
|
|
52
|
+
|
|
53
|
+
def _cursor_user(line):
|
|
54
|
+
d = json.loads(line)
|
|
55
|
+
if d.get("role") != "user":
|
|
56
|
+
return ""
|
|
57
|
+
t = " ".join(b.get("text", "") for b in d.get("message", {}).get("content", [])
|
|
58
|
+
if isinstance(b, dict) and b.get("type") == "text").strip()
|
|
59
|
+
# strip the <timestamp>...</timestamp> / <user_query> wrappers
|
|
60
|
+
return re.sub(r"^<timestamp>.*?</timestamp>\s*", "", t).replace(
|
|
61
|
+
"<user_query>", "").replace("</user_query>", "").strip()
|
|
62
|
+
|
|
63
|
+
# ---- meta-parsers: (f, head) -> (cwd, started_iso) or None ------------------
|
|
64
|
+
|
|
65
|
+
def _pi(f, head):
|
|
66
|
+
h = json.loads(head[0])
|
|
67
|
+
if h.get("type") != "session":
|
|
68
|
+
return None
|
|
69
|
+
return h.get("cwd"), h["timestamp"]
|
|
70
|
+
|
|
71
|
+
def _codex(f, head):
|
|
72
|
+
h = json.loads(head[0])
|
|
73
|
+
p = h.get("payload", {}) if h.get("type") == "session_meta" else None
|
|
74
|
+
if not p:
|
|
75
|
+
return None
|
|
76
|
+
return p.get("cwd"), p.get("timestamp") or h.get("timestamp")
|
|
77
|
+
|
|
78
|
+
def _claude(f, head):
|
|
79
|
+
for line in head:
|
|
80
|
+
d = json.loads(line)
|
|
81
|
+
if d.get("type") == "user":
|
|
82
|
+
return d.get("cwd"), d.get("timestamp")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _cursor(f, head):
|
|
86
|
+
# ~/.cursor/projects/<cwd-dir>/agent-transcripts/<id>/<id>.jsonl
|
|
87
|
+
name = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(f))))
|
|
88
|
+
if not name:
|
|
89
|
+
return "", None # path too shallow (e.g. a fixture); cwd unknown
|
|
90
|
+
# ponytail: '-'->'/' decode is best-effort; dirs with '-' in a segment
|
|
91
|
+
# decode wrong. Only affects cwd display, harmless for search.
|
|
92
|
+
cwd = "/" + name.replace("-", "/") if not name[0].isdigit() else name
|
|
93
|
+
return cwd, None # no timestamps in cursor transcripts; use mtime
|
|
94
|
+
|
|
95
|
+
REGISTRY = [
|
|
96
|
+
# (name, glob, meta-parser, user-text-extractor)
|
|
97
|
+
("pi", os.path.join(HOME, ".pi/agent/sessions/*/*.jsonl"), _pi, _pi_user),
|
|
98
|
+
("codex", os.path.join(HOME, ".codex/sessions/*/*/*/rollout-*.jsonl"),
|
|
99
|
+
_codex, _codex_user),
|
|
100
|
+
("codex", os.path.join(HOME, ".codex/archived_sessions/rollout-*.jsonl"),
|
|
101
|
+
_codex, _codex_user),
|
|
102
|
+
("claude", os.path.join(HOME, ".claude/projects/*/*.jsonl"), _claude, _claude_user),
|
|
103
|
+
("cursor", os.path.join(HOME, ".cursor/projects/*/agent-transcripts/*/*.jsonl"),
|
|
104
|
+
_cursor, _cursor_user),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def collect():
|
|
108
|
+
files = []
|
|
109
|
+
for name, pattern, parse, user_text in REGISTRY:
|
|
110
|
+
for f in glob.glob(pattern):
|
|
111
|
+
if name == "cursor":
|
|
112
|
+
# keep only the main transcript, skip subagents/
|
|
113
|
+
if os.path.basename(f)[:-6] != os.path.basename(os.path.dirname(f)):
|
|
114
|
+
continue
|
|
115
|
+
files.append((name, parse, user_text, f))
|
|
116
|
+
return files
|
|
117
|
+
|
|
118
|
+
def _load_cache():
|
|
119
|
+
try:
|
|
120
|
+
with open(CACHE, encoding="utf-8") as f:
|
|
121
|
+
return json.load(f)
|
|
122
|
+
except Exception:
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
def _save_cache(cache):
|
|
126
|
+
os.makedirs(os.path.dirname(CACHE), exist_ok=True)
|
|
127
|
+
tmp = CACHE + ".tmp"
|
|
128
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
129
|
+
json.dump(cache, f, ensure_ascii=False)
|
|
130
|
+
os.replace(tmp, CACHE)
|
|
131
|
+
|
|
132
|
+
def parse_all(files):
|
|
133
|
+
cache = _load_cache()
|
|
134
|
+
new_cache, dirty, rows = {}, False, []
|
|
135
|
+
for name, parse, user_text, f in files:
|
|
136
|
+
try:
|
|
137
|
+
st = os.stat(f)
|
|
138
|
+
sig = [st.st_size, st.st_mtime]
|
|
139
|
+
c = cache.get(f)
|
|
140
|
+
if c and c["sig"] == sig: # unchanged: reuse cached metadata
|
|
141
|
+
new_cache[f] = c
|
|
142
|
+
rows.append({k: v for k, v in c.items() if k != "sig"})
|
|
143
|
+
continue
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
with open(f, encoding="utf-8", errors="replace") as fh:
|
|
148
|
+
head, title = [], ""
|
|
149
|
+
for _ in range(2000):
|
|
150
|
+
line = fh.readline().strip()
|
|
151
|
+
if not line:
|
|
152
|
+
break
|
|
153
|
+
head.append(line)
|
|
154
|
+
title = user_text(line)
|
|
155
|
+
if title:
|
|
156
|
+
break # title found, stop reading
|
|
157
|
+
except Exception:
|
|
158
|
+
continue
|
|
159
|
+
r = parse(f, head) if head else None
|
|
160
|
+
if not r:
|
|
161
|
+
continue
|
|
162
|
+
cwd, started = r
|
|
163
|
+
if not title:
|
|
164
|
+
for line in head[1:]:
|
|
165
|
+
title = user_text(line)
|
|
166
|
+
if title:
|
|
167
|
+
break
|
|
168
|
+
mtime = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
|
|
169
|
+
row = {"agent": name, "cwd": cwd or "", "started": started or mtime,
|
|
170
|
+
"last": mtime, "title": title, "file": f}
|
|
171
|
+
new_cache[f] = {**row, "sig": sig}
|
|
172
|
+
dirty = True
|
|
173
|
+
rows.append(row)
|
|
174
|
+
if dirty:
|
|
175
|
+
_save_cache(new_cache)
|
|
176
|
+
return rows
|
|
177
|
+
|
|
178
|
+
def main():
|
|
179
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
180
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
181
|
+
ap.add_argument("keyword", nargs="?", default="",
|
|
182
|
+
help="search session titles (first user message)")
|
|
183
|
+
ap.add_argument("-f", "--full", action="store_true",
|
|
184
|
+
help="grep full session content (slow)")
|
|
185
|
+
ap.add_argument("-a", "--agent", help="only this agent")
|
|
186
|
+
ap.add_argument("-c", "--cwd", help="only sessions under this cwd substring")
|
|
187
|
+
ap.add_argument("--since", metavar="DATE", help="started on/after (YYYY-MM-DD)")
|
|
188
|
+
ap.add_argument("--until", metavar="DATE", help="last active on/before (YYYY-MM-DD)")
|
|
189
|
+
ap.add_argument("-n", "--limit", type=int, help="show only N newest")
|
|
190
|
+
ap.add_argument("-l", "--list", action="store_true", help="print file paths only")
|
|
191
|
+
ap.add_argument("--json", action="store_true", help="print rows as JSON lines")
|
|
192
|
+
args = ap.parse_args()
|
|
193
|
+
|
|
194
|
+
files = collect()
|
|
195
|
+
|
|
196
|
+
if args.full and args.keyword:
|
|
197
|
+
# ripgrep if available (much faster over GBs), else plain grep
|
|
198
|
+
rg = shutil.which("rg")
|
|
199
|
+
cmd = [rg, "-l", "-i", args.keyword, *[f for _, _, _, f in files]] if rg \
|
|
200
|
+
else ["grep", "-l", "-i", args.keyword, *[f for _, _, _, f in files]]
|
|
201
|
+
out = subprocess.run(cmd, capture_output=True, text=True).stdout
|
|
202
|
+
keep = set(out.splitlines())
|
|
203
|
+
files = [x for x in files if x[3] in keep]
|
|
204
|
+
|
|
205
|
+
rows = parse_all(files)
|
|
206
|
+
|
|
207
|
+
kw = args.keyword.lower()
|
|
208
|
+
if kw and not args.full:
|
|
209
|
+
rows = [r for r in rows if kw in r["title"].lower()]
|
|
210
|
+
if args.agent:
|
|
211
|
+
rows = [r for r in rows if r["agent"] == args.agent]
|
|
212
|
+
if args.cwd:
|
|
213
|
+
rows = [r for r in rows if args.cwd in r["cwd"]]
|
|
214
|
+
if args.since:
|
|
215
|
+
rows = [r for r in rows if r["started"][:10] >= args.since]
|
|
216
|
+
if args.until:
|
|
217
|
+
rows = [r for r in rows if r["last"][:10] <= args.until]
|
|
218
|
+
|
|
219
|
+
rows.sort(key=lambda r: r["last"], reverse=True)
|
|
220
|
+
if args.limit:
|
|
221
|
+
rows = rows[:args.limit]
|
|
222
|
+
|
|
223
|
+
if args.json:
|
|
224
|
+
for r in rows:
|
|
225
|
+
print(json.dumps(r, ensure_ascii=False))
|
|
226
|
+
elif args.list:
|
|
227
|
+
for r in rows:
|
|
228
|
+
print(r["file"])
|
|
229
|
+
else:
|
|
230
|
+
print(f"{'AGENT':<7} {'STARTED':<20} {'LAST':<20} "
|
|
231
|
+
f"{'CWD':<42} TITLE")
|
|
232
|
+
print("-" * 130)
|
|
233
|
+
for r in rows:
|
|
234
|
+
cwd = r["cwd"] or r["file"]
|
|
235
|
+
print(f"{r['agent']:<7} {r['started'][:19]:<20} {r['last'][:19]:<20} "
|
|
236
|
+
f"{cwd[:42]:<42} {r['title'][:60]}")
|
|
237
|
+
print(f"\n{len(rows)} sessions")
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
session_ls/__main__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
.TH SESSION-LS 1 "August 2026" "session-ls 0.1.0" "User Commands"
|
|
2
|
+
.SH NAME
|
|
3
|
+
session-ls \- list and search session history across coding agents
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B session-ls
|
|
6
|
+
[\fIKEYWORD\fR] [\fIOPTIONS\fR]
|
|
7
|
+
.SH DESCRIPTION
|
|
8
|
+
.B session-ls
|
|
9
|
+
scans the local session stores of coding agents (pi, codex, claude,
|
|
10
|
+
cursor) and lists every session, newest first. Each line shows the
|
|
11
|
+
agent, session start time, last activity, the working directory, and
|
|
12
|
+
the first real user message (the session "title").
|
|
13
|
+
|
|
14
|
+
Given a
|
|
15
|
+
.IR KEYWORD ,
|
|
16
|
+
only sessions whose title contains it (case-insensitive substring
|
|
17
|
+
match) are shown. With
|
|
18
|
+
.B \-f
|
|
19
|
+
the keyword is instead matched against the full session content.
|
|
20
|
+
|
|
21
|
+
.B session-ls
|
|
22
|
+
is a plain local command-line tool. It keeps no index and does no
|
|
23
|
+
semantic processing; matching is literal text comparison. Session
|
|
24
|
+
files are read directly from the agent stores. Parsed metadata is
|
|
25
|
+
cached in
|
|
26
|
+
\fI~/.cache/session_ls_cache.json\fR ,
|
|
27
|
+
keyed by file size and modification time, so repeated runs over
|
|
28
|
+
gigabytes of session logs are fast; the cache is rebuilt automatically
|
|
29
|
+
whenever a file changes.
|
|
30
|
+
.SH OPTIONS
|
|
31
|
+
.TP
|
|
32
|
+
.B KEYWORD
|
|
33
|
+
Search term. Matched case-insensitively against session titles (or,
|
|
34
|
+
with
|
|
35
|
+
.BR \-f ,
|
|
36
|
+
against full session content). Multiple words must be quoted; they are
|
|
37
|
+
matched as one phrase.
|
|
38
|
+
.TP
|
|
39
|
+
.B \-f, \-\-full
|
|
40
|
+
Search the full session content instead of titles only. Uses ripgrep
|
|
41
|
+
if available, otherwise plain grep; this can take a few seconds over
|
|
42
|
+
large session stores.
|
|
43
|
+
.TP
|
|
44
|
+
.B \-a, \-\-agent \fIAGENT\fR
|
|
45
|
+
Only show sessions from this agent. Valid values: pi, codex, claude,
|
|
46
|
+
cursor.
|
|
47
|
+
.TP
|
|
48
|
+
.B \-c, \-\-cwd \fISUBSTRING\fR
|
|
49
|
+
Only show sessions whose working directory contains SUBSTRING.
|
|
50
|
+
.TP
|
|
51
|
+
.B \-\-since \fIDATE\fR
|
|
52
|
+
Only show sessions started on or after DATE (YYYY\-MM\-DD).
|
|
53
|
+
.TP
|
|
54
|
+
.B \-\-until \fIDATE\fR
|
|
55
|
+
Only show sessions last active on or before DATE (YYYY\-MM\-DD).
|
|
56
|
+
.TP
|
|
57
|
+
.B \-n, \-\-limit \fIN\fR
|
|
58
|
+
Show only the N most recently active sessions.
|
|
59
|
+
.TP
|
|
60
|
+
.B \-l, \-\-list
|
|
61
|
+
Print only the session file paths, one per line (useful for piping to
|
|
62
|
+
other commands).
|
|
63
|
+
.TP
|
|
64
|
+
.B \-\-json
|
|
65
|
+
Print matching sessions as JSON Lines, one object per line, with keys:
|
|
66
|
+
agent, cwd, started, last, title, file.
|
|
67
|
+
.TP
|
|
68
|
+
.B \-h, \-\-help
|
|
69
|
+
Show usage and exit.
|
|
70
|
+
.SH EXIT STATUS
|
|
71
|
+
.B session-ls
|
|
72
|
+
returns 0 on success. No session files found is not an error.
|
|
73
|
+
.SH FILES
|
|
74
|
+
.TP
|
|
75
|
+
.I ~/.pi/agent/sessions/*/*.jsonl
|
|
76
|
+
pi session store, one directory per working directory.
|
|
77
|
+
.TP
|
|
78
|
+
.I ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
79
|
+
codex session store, grouped by date.
|
|
80
|
+
.TP
|
|
81
|
+
.I ~/.codex/archived_sessions/rollout-*.jsonl
|
|
82
|
+
codex archived sessions.
|
|
83
|
+
.TP
|
|
84
|
+
.I ~/.claude/projects/*/*.jsonl
|
|
85
|
+
claude code session store, one directory per project.
|
|
86
|
+
.TP
|
|
87
|
+
.I ~/.cursor/projects/*/agent-transcripts/*/*.jsonl
|
|
88
|
+
cursor agent transcripts (subagent transcripts are ignored).
|
|
89
|
+
.TP
|
|
90
|
+
.I ~/.cache/session_ls_cache.json
|
|
91
|
+
metadata cache. Safe to delete; it is rebuilt on the next run.
|
|
92
|
+
.SH ENVIRONMENT
|
|
93
|
+
.TP
|
|
94
|
+
.B HOME
|
|
95
|
+
Determines where agent stores and the cache are looked up.
|
|
96
|
+
.SH EXAMPLES
|
|
97
|
+
List the ten most recent sessions:
|
|
98
|
+
.PP
|
|
99
|
+
.RS
|
|
100
|
+
.nf
|
|
101
|
+
session-ls -n 10
|
|
102
|
+
.fi
|
|
103
|
+
.RE
|
|
104
|
+
.PP
|
|
105
|
+
Find sessions whose first user message mentions a LAN (title search):
|
|
106
|
+
.PP
|
|
107
|
+
.RS
|
|
108
|
+
.nf
|
|
109
|
+
session-ls websocket
|
|
110
|
+
.fi
|
|
111
|
+
.RE
|
|
112
|
+
.PP
|
|
113
|
+
Search the full content of all sessions for a phrase:
|
|
114
|
+
.PP
|
|
115
|
+
.RS
|
|
116
|
+
.nf
|
|
117
|
+
session-ls "immich 2283" -f
|
|
118
|
+
.fi
|
|
119
|
+
.RE
|
|
120
|
+
.PP
|
|
121
|
+
Find pi sessions in a project started this month, as JSON:
|
|
122
|
+
.PP
|
|
123
|
+
.RS
|
|
124
|
+
.nf
|
|
125
|
+
session-ls -a pi -c nemo -f --since 2026-08-01 --json
|
|
126
|
+
.fi
|
|
127
|
+
.RE
|
|
128
|
+
.PP
|
|
129
|
+
List file paths of matching sessions and show their first lines:
|
|
130
|
+
.PP
|
|
131
|
+
.RS
|
|
132
|
+
.nf
|
|
133
|
+
session-ls websocket -l | xargs head -1
|
|
134
|
+
.fi
|
|
135
|
+
.RE
|
|
136
|
+
.SH NOTES
|
|
137
|
+
The title of a session is the first real user message. Sessions whose
|
|
138
|
+
log starts with injected context (for example codex
|
|
139
|
+
<recommended_plugins> or AGENTS.md instructions blocks) skip that
|
|
140
|
+
context and use the first genuine user message instead; if a session
|
|
141
|
+
contains only injected context, the first user message is shown anyway.
|
|
142
|
+
Cursor transcripts carry no timestamps, so their start time equals the
|
|
143
|
+
file modification time. The cursor working directory is decoded from
|
|
144
|
+
the project directory name (dashes become slashes) and may be imprecise
|
|
145
|
+
for paths containing dashes; this affects display only, never search.
|
|
146
|
+
.SH SEE ALSO
|
|
147
|
+
.BR rg (1),
|
|
148
|
+
.BR grep (1)
|
|
149
|
+
.SH AUTHOR
|
|
150
|
+
The session-ls project
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: session-ls
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: List and search session history across coding agents (pi, codex, claude, cursor)
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 4ier
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Repository, https://github.com/4ier/session-ls
|
|
28
|
+
Project-URL: Issues, https://github.com/4ier/session-ls/issues
|
|
29
|
+
Keywords: agent,sessions,search,pi,codex,claude,cursor
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: MacOS
|
|
34
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Topic :: Utilities
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# session-ls
|
|
43
|
+
|
|
44
|
+
List and search session history across all coding agents on your machine:
|
|
45
|
+
pi, codex, claude, cursor.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ session-ls -n 3
|
|
49
|
+
AGENT STARTED LAST CWD TITLE
|
|
50
|
+
codex 2026-07-29T00:03:17 2026-07-29T00:21:15 /home/alice/projects/website fix the login redirect loop
|
|
51
|
+
pi 2026-07-17T14:42:45 2026-07-18T08:45:13 /home/alice/projects/backend tune the postgres connection pool
|
|
52
|
+
claude 2026-06-15T03:08:57 2026-06-15T03:31:55 /home/alice/dotfiles migrate to starship prompt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Sessions are read directly from each agent's local store, newest first. The
|
|
56
|
+
title of a session is its first real user message (injected context such as
|
|
57
|
+
codex `<recommended_plugins>` or `AGENTS.md` instructions is skipped).
|
|
58
|
+
|
|
59
|
+
## Design
|
|
60
|
+
|
|
61
|
+
- **Fast.** Metadata is cached in `~/.cache/session_ls_cache.json`, keyed by
|
|
62
|
+
file size + mtime; unchanged files are never re-read. Listing ~1000
|
|
63
|
+
sessions takes milliseconds. Full-text search uses `ripgrep` when
|
|
64
|
+
available (fallback: `grep`).
|
|
65
|
+
- **Plain search, no semantics.** No index, no embeddings, no network.
|
|
66
|
+
Matching is literal substring comparison. Decide what's relevant
|
|
67
|
+
yourself - or hand the file paths to an LLM.
|
|
68
|
+
- **Lightweight, zero dependencies.** Pure stdlib, one module.
|
|
69
|
+
- **Extensible.** Adding another agent is one `REGISTRY` entry plus two
|
|
70
|
+
small functions (see below).
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install . # from a checkout
|
|
76
|
+
pipx install . # recommended: isolated environment
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires Python >= 3.9. A man page (`session-ls(1)`) is installed alongside;
|
|
80
|
+
on macOS venvs, point `MANPATH` at the venv's `share/man` to see it.
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
session-ls [KEYWORD] [OPTIONS]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Option | Meaning |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `KEYWORD` | search titles (first user message), case-insensitive substring |
|
|
91
|
+
| `-f, --full` | search full session content instead of titles (slow, uses rg/grep) |
|
|
92
|
+
| `-a, --agent` | only this agent: `pi`, `codex`, `claude`, `cursor` |
|
|
93
|
+
| `-c, --cwd` | only sessions under a cwd substring |
|
|
94
|
+
| `--since DATE` | started on/after (YYYY-MM-DD) |
|
|
95
|
+
| `--until DATE` | last active on/before (YYYY-MM-DD) |
|
|
96
|
+
| `-n, --limit N` | show only the N newest |
|
|
97
|
+
| `-l, --list` | print file paths only (for piping) |
|
|
98
|
+
| `--json` | JSON Lines output (keys: agent, cwd, started, last, title, file) |
|
|
99
|
+
|
|
100
|
+
### Examples
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
session-ls -n 10 # ten most recent sessions
|
|
104
|
+
session-ls websocket # title search
|
|
105
|
+
session-ls "immich 2283" -f # full-content search
|
|
106
|
+
session-ls -a pi -c nemo --since 2026-08-01 # filters combine
|
|
107
|
+
session-ls websocket -l | xargs head -1 # inspect raw matches
|
|
108
|
+
session-ls websocket --json | jq -r .file # feed paths to other tools
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Supported agents
|
|
112
|
+
|
|
113
|
+
| Agent | Store | Timestamps |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| pi | `~/.pi/agent/sessions/<encoded-cwd>/*.jsonl` | in file |
|
|
116
|
+
| codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` (+ `archived_sessions/`) | in file |
|
|
117
|
+
| claude | `~/.claude/projects/<encoded-cwd>/*.jsonl` | in file |
|
|
118
|
+
| cursor | `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl` | file mtime (none in file) |
|
|
119
|
+
|
|
120
|
+
## Adding an agent
|
|
121
|
+
|
|
122
|
+
Append an entry to `REGISTRY` in `src/session_ls/__init__.py`:
|
|
123
|
+
|
|
124
|
+
1. a glob of session files
|
|
125
|
+
2. `meta_parser(f, head) -> (cwd, started_iso) | None`
|
|
126
|
+
3. `user_text(line) -> first real user text | ''` (drives the early-exit read)
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
REGISTRY = [
|
|
130
|
+
# (name, glob, meta-parser, user-text-extractor)
|
|
131
|
+
("myagent", os.path.join(HOME, ".myagent/sessions/*.jsonl"),
|
|
132
|
+
_myagent_meta, _myagent_user),
|
|
133
|
+
]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python -m pytest tests/ # plain asserts, also runnable via pytest
|
|
140
|
+
python -m session_ls ... # run from a checkout
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
session_ls/__init__.py,sha256=XbLVNLla-VrE6MHVIqj0R7yXlzhvMav5Ows0DO4QO6g,9225
|
|
2
|
+
session_ls/__main__.py,sha256=cnpZbFHpPCgpmujpXbXsrftEyMgJ82tyfyrhjryrYFw,89
|
|
3
|
+
session_ls-0.1.0.data/data/share/man/man1/session-ls.1,sha256=HLszUlPWfIj7gkkXh1hvMu1ZS9LMk1qDxQsNsGPyGfY,4255
|
|
4
|
+
session_ls-0.1.0.dist-info/licenses/LICENSE,sha256=cYZ3LVkc_-w5u7NQqO4QjWZ6Z9daosrMOOrb5IOXdeI,1061
|
|
5
|
+
session_ls-0.1.0.dist-info/METADATA,sha256=5V07kV-YypVv81h1R3FbgX1o4s8_i9wYaKYXwWqkFI8,5741
|
|
6
|
+
session_ls-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
session_ls-0.1.0.dist-info/entry_points.txt,sha256=c3-N-I3_s9kzXBiSKfHMTBr4KgwcPfI3g1sTsHgfpj0,47
|
|
8
|
+
session_ls-0.1.0.dist-info/top_level.txt,sha256=StBVKOP2cfg1Diwy5KCoxUg_ZPshgQrWLLxdkgkpDz8,11
|
|
9
|
+
session_ls-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4ier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
session_ls
|