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/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