agent-sessions-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_sessions/__init__.py +3 -0
- agent_sessions/__main__.py +3 -0
- agent_sessions/adapters/__init__.py +136 -0
- agent_sessions/adapters/base.py +108 -0
- agent_sessions/adapters/claude_code.py +300 -0
- agent_sessions/adapters/codex.py +360 -0
- agent_sessions/adapters/gemini.py +176 -0
- agent_sessions/adapters/opencode.py +231 -0
- agent_sessions/cli.py +858 -0
- agent_sessions/core.py +748 -0
- agent_sessions/model.py +119 -0
- agent_sessions/templates/gitattributes +2 -0
- agent_sessions/templates/sessions-README.md +31 -0
- agent_sessions/templates/summary.md +31 -0
- agent_sessions_cli-0.2.0.dist-info/METADATA +141 -0
- agent_sessions_cli-0.2.0.dist-info/RECORD +19 -0
- agent_sessions_cli-0.2.0.dist-info/WHEEL +4 -0
- agent_sessions_cli-0.2.0.dist-info/entry_points.txt +2 -0
- agent_sessions_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
agent_sessions/core.py
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""Shared machinery: git plumbing, redaction, rendering, storage, frontmatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from collections import Counter, OrderedDict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .model import Event, Session, STUB_KINDS, SENSITIVE_PROBE_KINDS, ToolCall
|
|
18
|
+
|
|
19
|
+
TOOL_VERSION = __version__
|
|
20
|
+
SESSIONS_REL = ".claude/sessions"
|
|
21
|
+
|
|
22
|
+
SUMMARY_MAX_LINES = 120
|
|
23
|
+
SUMMARY_MAX_BYTES = 6 * 1024
|
|
24
|
+
TRANSCRIPT_MAX_BYTES = 1024 * 1024
|
|
25
|
+
RESULT_MAX_LINES = 30
|
|
26
|
+
RESULT_MAX_BYTES = 2048
|
|
27
|
+
FULL_RESULT_MAX_BYTES = 20 * 1024
|
|
28
|
+
INPUT_MAX_BYTES = 2048
|
|
29
|
+
OUTLINE_MAX_PROMPTS = 25
|
|
30
|
+
|
|
31
|
+
GIT_ENV = {"GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never", "GIT_OPTIONAL_LOCKS": "0"}
|
|
32
|
+
GIT_TIMEOUT = 30
|
|
33
|
+
PUSH_TIMEOUT = 60
|
|
34
|
+
|
|
35
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
36
|
+
TEMPLATES = PACKAGE_DIR / "templates"
|
|
37
|
+
|
|
38
|
+
# Agents we recognise but cannot export yet. locate/doctor name them when present.
|
|
39
|
+
UNSUPPORTED = OrderedDict([
|
|
40
|
+
("copilot-cli", ("GitHub Copilot CLI", ["~/.copilot/session-state"])),
|
|
41
|
+
("cursor", ("Cursor", ["~/.cursor/projects", "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb",
|
|
42
|
+
"~/.config/Cursor/User/globalStorage/state.vscdb"])),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionsError(Exception):
|
|
47
|
+
"""User-facing failure. The message becomes REASON."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- report
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Report:
|
|
54
|
+
"""Collects lines. STATUS is printed first and last so truncated output still shows it."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.lines: List[str] = []
|
|
58
|
+
|
|
59
|
+
def add(self, *parts: object) -> None:
|
|
60
|
+
self.lines.append(" ".join(str(p) for p in parts))
|
|
61
|
+
|
|
62
|
+
def kv(self, key: str, value: object) -> None:
|
|
63
|
+
self.lines.append(f"{key}: {value}")
|
|
64
|
+
|
|
65
|
+
def blank(self) -> None:
|
|
66
|
+
self.lines.append("")
|
|
67
|
+
|
|
68
|
+
def finish(self, ok: bool, reason: str = "") -> str:
|
|
69
|
+
status = "STATUS: ok" if ok else "STATUS: error"
|
|
70
|
+
out = [status]
|
|
71
|
+
if reason:
|
|
72
|
+
out.append(f"REASON: {reason}")
|
|
73
|
+
out.extend(self.lines)
|
|
74
|
+
out.append(status)
|
|
75
|
+
if reason:
|
|
76
|
+
out.append(f"REASON: {reason}")
|
|
77
|
+
return "\n".join(out) + "\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------------------- git
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GitResult:
|
|
84
|
+
def __init__(self, code: int, out: str, err: str) -> None:
|
|
85
|
+
self.code, self.out, self.err = code, out, err
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def ok(self) -> bool:
|
|
89
|
+
return self.code == 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_git(args: List[str], cwd: str, timeout: int = GIT_TIMEOUT,
|
|
93
|
+
stdin: Optional[str] = None, env_extra: Optional[Dict[str, str]] = None) -> GitResult:
|
|
94
|
+
env = dict(os.environ)
|
|
95
|
+
env.update(GIT_ENV)
|
|
96
|
+
if env_extra:
|
|
97
|
+
env.update(env_extra)
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True,
|
|
100
|
+
encoding="utf-8", errors="replace", timeout=timeout, env=env, input=stdin)
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
return GitResult(124, "", f"git {' '.join(args[:2])} timed out after {timeout}s")
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
raise SessionsError("git is not on PATH")
|
|
105
|
+
return GitResult(proc.returncode, proc.stdout, proc.stderr)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def git_out(args: List[str], cwd: str, default: str = "") -> str:
|
|
109
|
+
r = run_git(args, cwd)
|
|
110
|
+
return r.out.strip() if r.ok else default
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def repo_root(project_dir: str) -> str:
|
|
114
|
+
r = run_git(["rev-parse", "--show-toplevel"], project_dir)
|
|
115
|
+
if not r.ok:
|
|
116
|
+
raise SessionsError(f"{project_dir} is not inside a git repository")
|
|
117
|
+
return os.path.normpath(r.out.strip())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def git_dir(root: str) -> str:
|
|
121
|
+
return os.path.normpath(os.path.join(root, git_out(["rev-parse", "--git-dir"], root, ".git")))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def current_branch(root: str) -> Optional[str]:
|
|
125
|
+
r = run_git(["symbolic-ref", "-q", "--short", "HEAD"], root)
|
|
126
|
+
return r.out.strip() if r.ok and r.out.strip() else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def head_exists(root: str) -> bool:
|
|
130
|
+
return run_git(["rev-parse", "-q", "--verify", "HEAD"], root).ok
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def in_progress_operation(root: str) -> Optional[str]:
|
|
134
|
+
gd = git_dir(root)
|
|
135
|
+
for marker in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"):
|
|
136
|
+
if os.path.exists(os.path.join(gd, marker)):
|
|
137
|
+
return marker
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def upstream_of(root: str, branch: str) -> Optional[str]:
|
|
142
|
+
r = run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", f"{branch}@{{upstream}}"], root)
|
|
143
|
+
return r.out.strip() if r.ok and r.out.strip() else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def default_remote_branch(root: str, remote: str = "origin") -> Optional[str]:
|
|
147
|
+
r = run_git(["symbolic-ref", "-q", "--short", f"refs/remotes/{remote}/HEAD"], root)
|
|
148
|
+
if r.ok and r.out.strip():
|
|
149
|
+
return r.out.strip().split("/", 1)[1]
|
|
150
|
+
r = run_git(["ls-remote", "--symref", remote, "HEAD"], root, timeout=GIT_TIMEOUT)
|
|
151
|
+
if r.ok:
|
|
152
|
+
m = re.search(r"ref: refs/heads/(\S+)\s+HEAD", r.out)
|
|
153
|
+
if m:
|
|
154
|
+
return m.group(1)
|
|
155
|
+
for cand in ("main", "master"):
|
|
156
|
+
if run_git(["rev-parse", "-q", "--verify", f"refs/remotes/{remote}/{cand}"], root).ok:
|
|
157
|
+
return cand
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def path_is_ignored(root: str, rel: str) -> Optional[str]:
|
|
162
|
+
"""Return 'source:line:pattern' if rel is ignored, else None."""
|
|
163
|
+
r = run_git(["check-ignore", "-v", "--no-index", "--", rel], root)
|
|
164
|
+
if r.code == 0 and r.out.strip():
|
|
165
|
+
match = r.out.strip().split("\t")[0]
|
|
166
|
+
pattern = match.split(":", 2)[-1]
|
|
167
|
+
if pattern.startswith("!"):
|
|
168
|
+
return None
|
|
169
|
+
return match
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def handle_from_name(name: str) -> str:
|
|
174
|
+
h = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
175
|
+
return h or "unknown"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def slugify(text: str, limit: int = 40) -> str:
|
|
179
|
+
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
180
|
+
s = s[:limit].rstrip("-")
|
|
181
|
+
return s or "session"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def home_to_tilde(text: str) -> str:
|
|
185
|
+
home = str(Path.home())
|
|
186
|
+
if home and home != "/" and home in text:
|
|
187
|
+
text = text.replace(home, "~")
|
|
188
|
+
return text
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def under(path: Optional[str], root: str) -> bool:
|
|
192
|
+
if not path:
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
return os.path.realpath(path).startswith(os.path.realpath(root))
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def rel_to_root(path: str, root: Optional[str]) -> str:
|
|
201
|
+
p = os.path.normpath(path)
|
|
202
|
+
if root:
|
|
203
|
+
try:
|
|
204
|
+
rel = os.path.relpath(p, root)
|
|
205
|
+
if not rel.startswith(".."):
|
|
206
|
+
return rel.replace(os.sep, "/")
|
|
207
|
+
except ValueError:
|
|
208
|
+
pass
|
|
209
|
+
return home_to_tilde(p)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------- reading helpers
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def read_jsonl(path: str) -> Tuple[List[dict], int]:
|
|
216
|
+
"""Read JSONL tolerantly. A truncated final line (file is live) is skipped."""
|
|
217
|
+
records: List[dict] = []
|
|
218
|
+
bad = 0
|
|
219
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
220
|
+
for line in fh:
|
|
221
|
+
line = line.strip()
|
|
222
|
+
if not line:
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
obj = json.loads(line)
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
bad += 1
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(obj, dict):
|
|
230
|
+
records.append(obj)
|
|
231
|
+
return records, bad
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def first_json_line(path: str) -> Optional[dict]:
|
|
235
|
+
try:
|
|
236
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
237
|
+
for _ in range(5):
|
|
238
|
+
line = fh.readline()
|
|
239
|
+
if not line:
|
|
240
|
+
return None
|
|
241
|
+
line = line.strip()
|
|
242
|
+
if not line:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
obj = json.loads(line)
|
|
246
|
+
if isinstance(obj, dict):
|
|
247
|
+
return obj
|
|
248
|
+
except json.JSONDecodeError:
|
|
249
|
+
continue
|
|
250
|
+
except OSError:
|
|
251
|
+
return None
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def parse_ts(ts: object) -> Optional[dt.datetime]:
|
|
256
|
+
if isinstance(ts, (int, float)):
|
|
257
|
+
try:
|
|
258
|
+
secs = ts / 1000.0 if ts > 1e11 else float(ts)
|
|
259
|
+
return dt.datetime.fromtimestamp(secs, tz=dt.timezone.utc)
|
|
260
|
+
except (OverflowError, OSError, ValueError):
|
|
261
|
+
return None
|
|
262
|
+
if not isinstance(ts, str) or not ts:
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
return dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
266
|
+
except ValueError:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def version_tuple(v: object) -> Optional[Tuple[int, ...]]:
|
|
271
|
+
if not isinstance(v, str):
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
return tuple(int(x) for x in re.split(r"[.-]", v)[:3] if x.isdigit())
|
|
275
|
+
except ValueError:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def mtime(path: str) -> Optional[dt.datetime]:
|
|
280
|
+
try:
|
|
281
|
+
return dt.datetime.fromtimestamp(os.path.getmtime(path), tz=dt.timezone.utc)
|
|
282
|
+
except OSError:
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --------------------------------------------------------------------------- redaction
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Pattern:
|
|
290
|
+
def __init__(self, name: str, regex: str, level: str, group: Optional[int] = None,
|
|
291
|
+
context: Optional[str] = None, flags: int = 0) -> None:
|
|
292
|
+
self.name = name
|
|
293
|
+
self.regex = re.compile(regex, flags)
|
|
294
|
+
self.level = level
|
|
295
|
+
self.group = group
|
|
296
|
+
self.context = re.compile(context, re.I) if context else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
PATTERNS: List[Pattern] = [
|
|
300
|
+
Pattern("aws-access-key", r"\bAKIA[0-9A-Z]{16}\b", "high"),
|
|
301
|
+
Pattern("github-token", r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b", "high"),
|
|
302
|
+
Pattern("slack-token", r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b", "high"),
|
|
303
|
+
Pattern("sk-api-key", r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b", "high"),
|
|
304
|
+
Pattern("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b", "high"),
|
|
305
|
+
Pattern("private-key", r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*", "high"),
|
|
306
|
+
Pattern("url-userinfo", r"(?<=://)[^/\s:@]{1,64}:[^/\s@]{1,256}(?=@)", "high"),
|
|
307
|
+
Pattern("entra-client-secret", r"\b[A-Za-z0-9_~.-]{3}8Q~[A-Za-z0-9_~.-]{30,40}\b", "high"),
|
|
308
|
+
Pattern("azure-account-key", r"(AccountKey=)([A-Za-z0-9+/]{80,90}={0,2})", "high", group=2, flags=re.I),
|
|
309
|
+
Pattern("shared-access-key", r"(SharedAccessKey=|SharedAccessSignature=)([^;\s\"']{8,})", "high", group=2, flags=re.I),
|
|
310
|
+
Pattern("sas-signature", r"([?&;]sig=)([A-Za-z0-9%+/=_-]{20,})", "high", group=2, flags=re.I),
|
|
311
|
+
Pattern("azure-devops-pat", r"\b(AZURE_DEVOPS_EXT_PAT|System\.AccessToken|SYSTEM_ACCESSTOKEN)(\s*[:=]\s*[\"']?)([A-Za-z0-9]{20,})", "high", group=3, flags=re.I),
|
|
312
|
+
Pattern("oauth-basic-token", r"([A-Za-z0-9_-]{20,})(:x-oauth-basic)", "high", group=1),
|
|
313
|
+
Pattern("bearer-token", r"\b(Bearer\s+)([A-Za-z0-9._~+/=-]{16,})", "high", group=2),
|
|
314
|
+
Pattern("password-kv", r"\b(password|passwd|pwd)(\s*[:=]\s*[\"']?)([^\s\"';&,]{8,})", "review", group=3, flags=re.I),
|
|
315
|
+
Pattern("secret-kv",
|
|
316
|
+
r"\b(client[_-]?secret|azure[_-]?client[_-]?secret|api[_-]?key|apikey|secret[_-]?key|access[_-]?token"
|
|
317
|
+
r"|auth[_-]?token|refresh[_-]?token|token)(\s*[:=]\s*[\"']?)([^\s\"';&,]{8,})", "review", group=3, flags=re.I),
|
|
318
|
+
Pattern("cli-secret-flag", r"(--client-secret|--password|--pat|--token)(\s+|=)([\"']?)([^\s\"']{8,})", "review", group=4),
|
|
319
|
+
Pattern("sqlcmd-password", r"(\s-P\s*)([\"']?)([^\s\"']{6,})", "review", group=3, context=r"\b(sqlcmd|bcp|mssql-cli)\b"),
|
|
320
|
+
Pattern("az-login-password", r"(\s-p\s+)([\"']?)([^\s\"']{8,})", "review", group=3, context=r"--service-principal|az\s+login"),
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
PEM_BLOCK_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----")
|
|
324
|
+
PLACEHOLDER_RE = re.compile(r"^(<|\$\{|\$\(|\{\{|\*\*\*|your[-_]|xxx|\[REDACTED|example|changeme|placeholder|redacted)", re.I)
|
|
325
|
+
GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
326
|
+
SENSITIVE_FILE_RE = re.compile(
|
|
327
|
+
r"(^|[\\/])(\.env(\.[^\\/]*)?|local\.settings\.json|appsettings(\.[^\\/]*)?\.json|secrets?\.json|\.netrc|\.pypirc"
|
|
328
|
+
r"|\.databrickscfg|id_(rsa|dsa|ecdsa|ed25519)|credentials|\.git-credentials)$"
|
|
329
|
+
r"|\.(pfx|pem|key|p12|publishsettings|tfvars|tfstate|kdbx)$|[\\/]\.azure[\\/]",
|
|
330
|
+
re.I,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def is_placeholder(value: str) -> bool:
|
|
335
|
+
v = value.strip("\"'")
|
|
336
|
+
return bool(PLACEHOLDER_RE.match(v)) or bool(GUID_RE.match(v)) or "os.environ" in v or "getenv" in v
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class Redactor:
|
|
340
|
+
def __init__(self) -> None:
|
|
341
|
+
self.hits: Counter = Counter()
|
|
342
|
+
self.review: List[Tuple[str, int, str]] = []
|
|
343
|
+
|
|
344
|
+
def redact(self, text: str, label: str = "") -> str:
|
|
345
|
+
text = PEM_BLOCK_RE.sub(lambda m: self._count("private-key", "high", label, 0) or "[REDACTED:private-key]", text)
|
|
346
|
+
lines = text.split("\n")
|
|
347
|
+
for i, line in enumerate(lines):
|
|
348
|
+
if not line:
|
|
349
|
+
continue
|
|
350
|
+
for p in PATTERNS:
|
|
351
|
+
if p.context and not p.context.search(line):
|
|
352
|
+
continue
|
|
353
|
+
line = p.regex.sub(lambda m, p=p, n=i + 1: self._replace(m, p, label, n), line)
|
|
354
|
+
lines[i] = line
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
def _count(self, name: str, level: str, label: str, line: int) -> None:
|
|
358
|
+
self.hits[name] += 1
|
|
359
|
+
if level == "review":
|
|
360
|
+
self.review.append((label, line, name))
|
|
361
|
+
|
|
362
|
+
def _replace(self, m: "re.Match[str]", p: Pattern, label: str, line: int) -> str:
|
|
363
|
+
whole = m.group(0)
|
|
364
|
+
if p.group:
|
|
365
|
+
value = m.group(p.group)
|
|
366
|
+
if value is None or is_placeholder(value):
|
|
367
|
+
return whole
|
|
368
|
+
self._count(p.name, p.level, label, line)
|
|
369
|
+
start = m.start(p.group) - m.start(0)
|
|
370
|
+
end = m.end(p.group) - m.start(0)
|
|
371
|
+
return whole[:start] + f"[REDACTED:{p.name}]" + whole[end:]
|
|
372
|
+
if is_placeholder(whole):
|
|
373
|
+
return whole
|
|
374
|
+
self._count(p.name, p.level, label, line)
|
|
375
|
+
return f"[REDACTED:{p.name}]"
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def residual_high(text: str) -> List[str]:
|
|
379
|
+
found = []
|
|
380
|
+
if PEM_BLOCK_RE.search(text):
|
|
381
|
+
found.append("private-key")
|
|
382
|
+
for p in PATTERNS:
|
|
383
|
+
if p.level != "high":
|
|
384
|
+
continue
|
|
385
|
+
for m in p.regex.finditer(text):
|
|
386
|
+
value = m.group(p.group) if p.group else m.group(0)
|
|
387
|
+
if value and not is_placeholder(value) and "[REDACTED:" not in m.group(0):
|
|
388
|
+
found.append(p.name)
|
|
389
|
+
break
|
|
390
|
+
return found
|
|
391
|
+
|
|
392
|
+
def high_count(self) -> int:
|
|
393
|
+
high = {p.name for p in PATTERNS if p.level == "high"} | {"private-key"}
|
|
394
|
+
return sum(v for k, v in self.hits.items() if k in high)
|
|
395
|
+
|
|
396
|
+
def review_count(self) -> int:
|
|
397
|
+
rev = {p.name for p in PATTERNS if p.level == "review"}
|
|
398
|
+
return sum(v for k, v in self.hits.items() if k in rev)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def redact_value(value: object, redactor: Redactor) -> object:
|
|
402
|
+
if isinstance(value, str):
|
|
403
|
+
return redactor.redact(value, "transcript")
|
|
404
|
+
if isinstance(value, dict):
|
|
405
|
+
return {k: redact_value(v, redactor) for k, v in value.items()}
|
|
406
|
+
if isinstance(value, list):
|
|
407
|
+
return [redact_value(v, redactor) for v in value]
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def mentions_sensitive_file(text: str) -> Optional[str]:
|
|
412
|
+
for tok in re.split(r"[\s\"'`;|&<>()]+", text):
|
|
413
|
+
tok = tok.strip()
|
|
414
|
+
if tok and SENSITIVE_FILE_RE.search(tok):
|
|
415
|
+
return tok
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def flatten_strings(value: object, limit: int = 200) -> List[str]:
|
|
420
|
+
out: List[str] = []
|
|
421
|
+
|
|
422
|
+
def walk(v: object) -> None:
|
|
423
|
+
if len(out) >= limit:
|
|
424
|
+
return
|
|
425
|
+
if isinstance(v, str):
|
|
426
|
+
out.append(v)
|
|
427
|
+
elif isinstance(v, dict):
|
|
428
|
+
for x in v.values():
|
|
429
|
+
walk(x)
|
|
430
|
+
elif isinstance(v, list):
|
|
431
|
+
for x in v:
|
|
432
|
+
walk(x)
|
|
433
|
+
|
|
434
|
+
walk(value)
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# --------------------------------------------------------------------------- rendering
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def fence(text: str, lang: str = "") -> str:
|
|
442
|
+
ticks = "```"
|
|
443
|
+
while ticks in text:
|
|
444
|
+
ticks += "`"
|
|
445
|
+
return f"{ticks}{lang}\n{text.rstrip()}\n{ticks}"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def truncate(text: str, max_lines: int, max_bytes: int) -> Tuple[str, int]:
|
|
449
|
+
lines = text.split("\n")
|
|
450
|
+
omitted = 0
|
|
451
|
+
if len(lines) > max_lines:
|
|
452
|
+
omitted = len(lines) - max_lines
|
|
453
|
+
lines = lines[:max_lines]
|
|
454
|
+
out = "\n".join(lines)
|
|
455
|
+
if len(out.encode("utf-8", "replace")) > max_bytes:
|
|
456
|
+
out = out.encode("utf-8", "replace")[:max_bytes].decode("utf-8", "ignore")
|
|
457
|
+
omitted = max(omitted, 1)
|
|
458
|
+
return out, omitted
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
PERSISTED_PATH_RE = re.compile(r"(Full output saved to:\s*)(\S+)")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def scrub_persisted(text: str) -> str:
|
|
465
|
+
return PERSISTED_PATH_RE.sub(r"\1[local path]", text)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def render_input(kind: str, inp: object, withheld: bool) -> str:
|
|
469
|
+
if withheld:
|
|
470
|
+
return "_input withheld: references a sensitive file_"
|
|
471
|
+
if kind == "shell":
|
|
472
|
+
if isinstance(inp, dict):
|
|
473
|
+
cmd = inp.get("command") or inp.get("cmd") or inp.get("commands")
|
|
474
|
+
if isinstance(cmd, list):
|
|
475
|
+
cmd = "\n".join(str(c) for c in cmd)
|
|
476
|
+
cmd = str(cmd) if cmd else json.dumps(inp, ensure_ascii=False)
|
|
477
|
+
else:
|
|
478
|
+
cmd = str(inp or "")
|
|
479
|
+
cmd, omitted = truncate(cmd, 40, INPUT_MAX_BYTES)
|
|
480
|
+
return fence(cmd, "bash") + (f"\n_… {omitted} more lines_" if omitted else "")
|
|
481
|
+
if kind == "write":
|
|
482
|
+
content = str((inp or {}).get("content") or "") if isinstance(inp, dict) else str(inp or "")
|
|
483
|
+
snippet, omitted = truncate(content, 20, INPUT_MAX_BYTES)
|
|
484
|
+
return fence(snippet) + (f"\n_… {omitted} more lines_" if omitted else "")
|
|
485
|
+
if kind == "edit":
|
|
486
|
+
if isinstance(inp, dict):
|
|
487
|
+
if isinstance(inp.get("patch"), str):
|
|
488
|
+
patch, omitted = truncate(inp["patch"], 40, INPUT_MAX_BYTES)
|
|
489
|
+
return fence(patch, "diff") + (f"\n_… {omitted} more lines_" if omitted else "")
|
|
490
|
+
edits = inp.get("edits") if isinstance(inp.get("edits"), list) else [inp]
|
|
491
|
+
parts = []
|
|
492
|
+
for e in edits[:5]:
|
|
493
|
+
if not isinstance(e, dict):
|
|
494
|
+
continue
|
|
495
|
+
old, _ = truncate(str(e.get("old_string") or e.get("oldString") or e.get("old_str") or ""), 12, 1024)
|
|
496
|
+
new, _ = truncate(str(e.get("new_string") or e.get("newString") or e.get("new_str") or ""), 12, 1024)
|
|
497
|
+
if old or new:
|
|
498
|
+
parts.append("- old:\n" + fence(old) + "\n- new:\n" + fence(new))
|
|
499
|
+
if parts:
|
|
500
|
+
return "\n".join(parts)
|
|
501
|
+
text, omitted = truncate(str(inp or ""), 30, INPUT_MAX_BYTES)
|
|
502
|
+
return fence(text) + (f"\n_… truncated_" if omitted else "")
|
|
503
|
+
if kind == "agent":
|
|
504
|
+
prompt = str((inp or {}).get("prompt") or "") if isinstance(inp, dict) else str(inp or "")
|
|
505
|
+
prompt, omitted = truncate(prompt, 15, INPUT_MAX_BYTES)
|
|
506
|
+
return fence(prompt) + (f"\n_… {omitted} more lines_" if omitted else "")
|
|
507
|
+
if kind == "ask":
|
|
508
|
+
q = json.dumps(inp, ensure_ascii=False, indent=2) if isinstance(inp, (dict, list)) else str(inp or "")
|
|
509
|
+
q, _ = truncate(q, 20, INPUT_MAX_BYTES)
|
|
510
|
+
return fence(q)
|
|
511
|
+
try:
|
|
512
|
+
dumped = json.dumps(inp, indent=2, ensure_ascii=False) if isinstance(inp, (dict, list)) else str(inp or "")
|
|
513
|
+
except (TypeError, ValueError):
|
|
514
|
+
dumped = str(inp)
|
|
515
|
+
dumped, omitted = truncate(dumped, 30, INPUT_MAX_BYTES)
|
|
516
|
+
return fence(dumped, "json" if isinstance(inp, (dict, list)) else "") + (f"\n_… truncated_" if omitted else "")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def render_result(kind: str, text: str, is_error: bool, mode: str, withheld: Optional[str]) -> str:
|
|
520
|
+
"""mode: 'policy' (default), 'stub' (everything stubbed), 'full' (opt-in)."""
|
|
521
|
+
if withheld:
|
|
522
|
+
return f"_result withheld: reads a sensitive file ({withheld})_"
|
|
523
|
+
text = scrub_persisted(text)
|
|
524
|
+
nlines = text.count("\n") + (1 if text else 0)
|
|
525
|
+
nbytes = len(text.encode("utf-8", "replace"))
|
|
526
|
+
prefix = "**Error.** " if is_error else ""
|
|
527
|
+
if not text.strip():
|
|
528
|
+
return prefix + "_no output_"
|
|
529
|
+
if mode == "stub" or (mode == "policy" and kind in STUB_KINDS):
|
|
530
|
+
return f"{prefix}_result omitted: {nlines} lines, {nbytes} bytes_"
|
|
531
|
+
if mode == "full":
|
|
532
|
+
body, omitted = truncate(text, 10_000, FULL_RESULT_MAX_BYTES)
|
|
533
|
+
else:
|
|
534
|
+
body, omitted = truncate(text, RESULT_MAX_LINES, RESULT_MAX_BYTES)
|
|
535
|
+
out = prefix + fence(body)
|
|
536
|
+
if omitted:
|
|
537
|
+
out += f"\n_… {omitted} more lines omitted ({nlines} lines, {nbytes} bytes total)_"
|
|
538
|
+
return out
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def render_tool(tc: ToolCall, redactor: Redactor, result_mode: str) -> Tuple[str, Optional[str]]:
|
|
542
|
+
withheld = None
|
|
543
|
+
if tc.kind in SENSITIVE_PROBE_KINDS:
|
|
544
|
+
withheld = mentions_sensitive_file(" ".join([tc.label] + tc.paths + flatten_strings(tc.input)))
|
|
545
|
+
inp = redact_value(tc.input, redactor)
|
|
546
|
+
# redact the full label first, truncate last: cutting first could split a secret.
|
|
547
|
+
# The label repeats the input, so count its hits with a throwaway redactor.
|
|
548
|
+
label = Redactor().redact(home_to_tilde(tc.label), "transcript").replace("\n", " ")
|
|
549
|
+
if len(label) > 120:
|
|
550
|
+
label = label[:117] + "…"
|
|
551
|
+
body = [f"<details>\n<summary>🔧 {label}</summary>\n", home_to_tilde(render_input(tc.kind, inp, bool(withheld)))]
|
|
552
|
+
if tc.output or tc.is_error:
|
|
553
|
+
out = redactor.redact(home_to_tilde(tc.output), "transcript")
|
|
554
|
+
body.append("\n**Result**\n\n" + redactor.redact(render_result(tc.kind, out, tc.is_error, result_mode, withheld), "transcript"))
|
|
555
|
+
elif tc.pending:
|
|
556
|
+
body.append("\n_call still running when the transcript was exported_")
|
|
557
|
+
else:
|
|
558
|
+
body.append("\n_no result recorded_")
|
|
559
|
+
body.append("\n</details>\n")
|
|
560
|
+
return "\n".join(body), withheld
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def render_session(sess: Session, redactor: Redactor, result_mode: str = "policy",
|
|
564
|
+
include_thinking: bool = False, title: str = "") -> Tuple[List[str], dict]:
|
|
565
|
+
"""Return (segments, stats). Segments can be split across files."""
|
|
566
|
+
events = sess.events
|
|
567
|
+
last_push = -1
|
|
568
|
+
for i, e in enumerate(events):
|
|
569
|
+
if e.is_push_invocation:
|
|
570
|
+
last_push = i
|
|
571
|
+
if last_push >= 0:
|
|
572
|
+
events = events[:last_push]
|
|
573
|
+
stats: Counter = Counter()
|
|
574
|
+
stats["stopped_at_push"] = 1 if last_push >= 0 else 0
|
|
575
|
+
|
|
576
|
+
segments: List[str] = []
|
|
577
|
+
last_role = None
|
|
578
|
+
last_date = None
|
|
579
|
+
header = [f"# {title or sess.title or 'Coding agent session'}", ""]
|
|
580
|
+
header.append(f"_Session `{sess.session_id}` · rendered by agent-sessions {TOOL_VERSION} from {sess.agent}"
|
|
581
|
+
f"{' ' + sess.agent_version if sess.agent_version else ''} · tool results "
|
|
582
|
+
f"{'included' if result_mode == 'full' else 'truncated or omitted'}; thinking {'included' if include_thinking else 'omitted'}._")
|
|
583
|
+
header.append("")
|
|
584
|
+
segments.append("\n".join(header))
|
|
585
|
+
|
|
586
|
+
def emit(text: str) -> None:
|
|
587
|
+
segments.append(text.rstrip() + "\n")
|
|
588
|
+
|
|
589
|
+
def role_header(role: str, ts: Optional[dt.datetime]) -> str:
|
|
590
|
+
nonlocal last_role, last_date
|
|
591
|
+
out = []
|
|
592
|
+
if ts and ts.date() != last_date:
|
|
593
|
+
last_date = ts.date()
|
|
594
|
+
out.append(f"\n## {last_date.isoformat()} (UTC)\n")
|
|
595
|
+
last_role = None
|
|
596
|
+
if role != last_role:
|
|
597
|
+
last_role = role
|
|
598
|
+
stamp = f" · {ts.strftime('%H:%M')}" if ts else ""
|
|
599
|
+
out.append(f"\n### {role}{stamp}\n")
|
|
600
|
+
return "\n".join(out)
|
|
601
|
+
|
|
602
|
+
for e in events:
|
|
603
|
+
if e.meta.get("hidden"):
|
|
604
|
+
continue # bookkeeping events (subagent edits) feed files_touched only
|
|
605
|
+
if e.kind == "compaction":
|
|
606
|
+
pre, post = e.meta.get("pre_tokens"), e.meta.get("post_tokens")
|
|
607
|
+
detail = f" ({pre} → {post} tokens)" if pre and post else ""
|
|
608
|
+
emit(f"\n> **Context compacted here**{detail}. Everything above was summarised for the model.\n")
|
|
609
|
+
if e.text.strip():
|
|
610
|
+
body = redactor.redact(home_to_tilde(e.text), "transcript")
|
|
611
|
+
body, _ = truncate(body, 400, 16 * 1024)
|
|
612
|
+
emit("<details>\n<summary>Compaction summary given to the model</summary>\n\n" + body + "\n\n</details>\n")
|
|
613
|
+
stats["compaction_summaries"] += 1
|
|
614
|
+
last_role = None
|
|
615
|
+
stats["compactions"] += 1
|
|
616
|
+
elif e.kind == "note":
|
|
617
|
+
if e.text.strip():
|
|
618
|
+
emit(f"\n> _{redactor.redact(home_to_tilde(e.text), 'transcript').strip()}_\n")
|
|
619
|
+
stats["notes"] += 1
|
|
620
|
+
elif e.kind == "user":
|
|
621
|
+
if e.is_push_invocation:
|
|
622
|
+
continue
|
|
623
|
+
if e.command:
|
|
624
|
+
emit(role_header("User", e.ts) + f"\n> Ran `/{e.command.lstrip('/')}`\n")
|
|
625
|
+
stats["commands"] += 1
|
|
626
|
+
elif e.text.strip():
|
|
627
|
+
emit(role_header("User", e.ts) + "\n" + redactor.redact(home_to_tilde(e.text), "transcript") + "\n")
|
|
628
|
+
stats["user_messages"] += 1
|
|
629
|
+
elif e.kind == "assistant":
|
|
630
|
+
if e.text.strip():
|
|
631
|
+
who = "Assistant" + (f" ({e.meta['agent_name']})" if e.meta.get("agent_name") else "")
|
|
632
|
+
emit(role_header(who, e.ts) + "\n" + redactor.redact(home_to_tilde(e.text), "transcript") + "\n")
|
|
633
|
+
stats["assistant_messages"] += 1
|
|
634
|
+
elif e.kind == "thinking":
|
|
635
|
+
stats["thinking_blocks"] += 1
|
|
636
|
+
if include_thinking and e.text.strip():
|
|
637
|
+
think = redactor.redact(home_to_tilde(e.text), "transcript")
|
|
638
|
+
think, _ = truncate(think, 80, 6 * 1024)
|
|
639
|
+
emit(role_header("Assistant", e.ts) + "\n<details>\n<summary>Thinking</summary>\n\n" + think + "\n\n</details>\n")
|
|
640
|
+
elif e.kind == "tool" and e.tool:
|
|
641
|
+
body, withheld = render_tool(e.tool, redactor, result_mode)
|
|
642
|
+
emit(role_header("Assistant", e.ts) + "\n" + body)
|
|
643
|
+
stats["tool_calls"] += 1
|
|
644
|
+
if withheld:
|
|
645
|
+
stats["withheld_results"] += 1
|
|
646
|
+
else:
|
|
647
|
+
stats[f"ignored:{e.kind}"] += 1
|
|
648
|
+
|
|
649
|
+
stats["messages"] = stats["user_messages"] + stats["assistant_messages"]
|
|
650
|
+
return segments, dict(stats)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# --------------------------------------------------------------------------- frontmatter
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def parse_frontmatter(text: str) -> Tuple["OrderedDict[str, object]", str]:
|
|
657
|
+
fm: "OrderedDict[str, object]" = OrderedDict()
|
|
658
|
+
if not text.startswith("---"):
|
|
659
|
+
return fm, text
|
|
660
|
+
lines = text.split("\n")
|
|
661
|
+
end = None
|
|
662
|
+
for i in range(1, len(lines)):
|
|
663
|
+
if lines[i].strip() == "---":
|
|
664
|
+
end = i
|
|
665
|
+
break
|
|
666
|
+
if end is None:
|
|
667
|
+
return fm, text
|
|
668
|
+
key = None
|
|
669
|
+
for line in lines[1:end]:
|
|
670
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
671
|
+
continue
|
|
672
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
673
|
+
if m:
|
|
674
|
+
key, raw = m.group(1), m.group(2).strip()
|
|
675
|
+
if raw == "":
|
|
676
|
+
fm[key] = []
|
|
677
|
+
elif raw.startswith("[") and raw.endswith("]"):
|
|
678
|
+
fm[key] = [x.strip().strip("\"'") for x in raw[1:-1].split(",") if x.strip()]
|
|
679
|
+
else:
|
|
680
|
+
fm[key] = raw.strip("\"'")
|
|
681
|
+
elif key and line.lstrip().startswith("- ") and isinstance(fm.get(key), list):
|
|
682
|
+
fm[key].append(line.lstrip()[2:].strip().strip("\"'")) # type: ignore[union-attr]
|
|
683
|
+
body = "\n".join(lines[end + 1:])
|
|
684
|
+
return fm, body
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def dump_frontmatter(fm: "OrderedDict[str, object]") -> str:
|
|
688
|
+
out = ["---"]
|
|
689
|
+
for k, v in fm.items():
|
|
690
|
+
if isinstance(v, list):
|
|
691
|
+
out.append(f"{k}: [{', '.join(str(x) for x in v)}]")
|
|
692
|
+
else:
|
|
693
|
+
s = str(v).replace('"', "'")
|
|
694
|
+
out.append(f'{k}: "{s}"')
|
|
695
|
+
out.append("---")
|
|
696
|
+
return "\n".join(out)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --------------------------------------------------------------------------- storage
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def write_text(path: str, text: str) -> None:
|
|
703
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
704
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
705
|
+
fh.write(text if text.endswith("\n") else text + "\n")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def load_template(name: str) -> str:
|
|
709
|
+
with open(TEMPLATES / name, "r", encoding="utf-8") as fh:
|
|
710
|
+
return fh.read()
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def session_dir_name(started: Optional[dt.datetime], title: str, handle: str, short_id: str) -> str:
|
|
714
|
+
date = (started or dt.datetime.now(dt.timezone.utc)).strftime("%Y-%m-%d")
|
|
715
|
+
return f"{date}_{slugify(title)}_{handle}_{short_id}"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def existing_session_dir(root: str, session_id: str, short_id: str) -> Optional[str]:
|
|
719
|
+
base = os.path.join(root, SESSIONS_REL)
|
|
720
|
+
for cand in glob.glob(os.path.join(base, f"*_{short_id}")):
|
|
721
|
+
meta = os.path.join(cand, "meta.json")
|
|
722
|
+
try:
|
|
723
|
+
with open(meta, "r", encoding="utf-8") as fh:
|
|
724
|
+
if json.load(fh).get("session_id") == session_id:
|
|
725
|
+
return cand
|
|
726
|
+
except (OSError, ValueError):
|
|
727
|
+
if os.path.isdir(cand):
|
|
728
|
+
return cand
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def config_dir() -> str:
|
|
733
|
+
return os.path.abspath(os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def script_path() -> str:
|
|
737
|
+
"""Absolute path of the entry script the user invoked, for the SCRIPT: line.
|
|
738
|
+
|
|
739
|
+
The shim runs sessions.py, but the shim itself is what skills should call, so report it when present.
|
|
740
|
+
"""
|
|
741
|
+
if not (sys.argv and sys.argv[0]):
|
|
742
|
+
return "agent-sessions"
|
|
743
|
+
p = os.path.abspath(sys.argv[0])
|
|
744
|
+
if os.path.basename(p) == "sessions.py":
|
|
745
|
+
sh = os.path.join(os.path.dirname(p), "sessions.sh")
|
|
746
|
+
if os.path.exists(sh):
|
|
747
|
+
return sh
|
|
748
|
+
return p
|